diff --git a/web/src/admin/IdentityDetails.vue b/web/src/admin/IdentityDetails.vue
index 4e5e87d..315ad96 100644
--- a/web/src/admin/IdentityDetails.vue
+++ b/web/src/admin/IdentityDetails.vue
@@ -1,6 +1,10 @@
Identity: {{ $route.params.subjectID }}
+
+ {{ requestCredentialError }}
+ {{ formattedRequestCredentialErrorDescription }}
+
@@ -136,19 +140,56 @@ export default {
data() {
return {
fetchError: undefined,
+ requestCredentialError: undefined,
+ requestCredentialErrorDescription: undefined,
details: undefined,
shownDIDDocument: undefined,
discoveryServices: {},
credentialProfiles: [],
}
},
+ computed: {
+ formattedRequestCredentialErrorDescription() {
+ if (!this.requestCredentialErrorDescription) {
+ return ''
+ }
+ return this.requestCredentialErrorDescription.replace(/, ([a-zA-Z]+):/g, '\n$1:')
+ }
+ },
created() {
+ this.captureRedirectError()
this.fetchData()
},
methods: {
updateStatus(event) {
this.$emit('statusUpdate', event)
},
+ captureRedirectError() {
+ // OAuth-compliant issuers put error params in the URL's query component (window.location.search),
+ // which is outside the hash-router's view. Check both locations.
+ const searchParams = new URLSearchParams(window.location.search)
+ const error = searchParams.get('error') || this.$route.query.error
+ const errorDescription = searchParams.get('error_description') || this.$route.query.error_description
+ if (!error) {
+ return
+ }
+ this.requestCredentialError = error
+ this.requestCredentialErrorDescription = errorDescription
+
+ if (searchParams.has('error') || searchParams.has('error_description')) {
+ searchParams.delete('error')
+ searchParams.delete('error_description')
+ const newSearch = searchParams.toString()
+ const newUrl = window.location.pathname + (newSearch ? '?' + newSearch : '') + window.location.hash
+ window.history.replaceState(window.history.state, '', newUrl)
+ }
+ if (this.$route.query.error || this.$route.query.error_description) {
+ const rest = Object.fromEntries(
+ Object.entries(this.$route.query).filter(([k]) => k !== 'error' && k !== 'error_description')
+ )
+ this.$router.replace({name: this.$route.name, params: this.$route.params, query: rest})
+ }
+ },
fetchData() {
this.$api.get('api/config')
.then(data => {
diff --git a/web/src/admin/credentials/RequestCredential.vue b/web/src/admin/credentials/RequestCredential.vue
index 54e5a1f..5d0fde9 100644
--- a/web/src/admin/credentials/RequestCredential.vue
+++ b/web/src/admin/credentials/RequestCredential.vue
@@ -28,6 +28,14 @@
{{ getIssuerForType(selectedCredentialType) }}
+
+ Authorization Details (JSON)
+
+ {{ authorizationDetailsError }}
+
@@ -43,6 +51,9 @@ export default {
return {
selectedCredentialType: '',
selectedWalletDID: '',
+ authorizationDetails: '',
+ authorizationDetailsError: undefined,
+ authorizationDetailsPlaceholder: '[{"type": "openid_credential", ...}]',
issueError: undefined,
credentialProfiles: [],
walletDIDs: [],
@@ -53,6 +64,11 @@ export default {
return this.$route.params.subjectID
}
},
+ watch: {
+ authorizationDetails() {
+ this.authorizationDetailsError = undefined
+ }
+ },
created() {
// Fetch config for credential profiles
this.$api.get('api/config')
@@ -93,6 +109,7 @@ export default {
},
issueCredential() {
this.issueError = undefined
+ this.authorizationDetailsError = undefined
const issuerDID = this.getIssuerForType(this.selectedCredentialType)
if (!issuerDID) {
this.issueError = 'No issuer found for selected credential type'
@@ -104,6 +121,28 @@ export default {
return
}
+ let parsedAuthorizationDetails
+ const trimmedAuthorizationDetails = this.authorizationDetails.trim()
+ if (trimmedAuthorizationDetails) {
+ try {
+ parsedAuthorizationDetails = JSON.parse(trimmedAuthorizationDetails)
+ } catch (e) {
+ this.authorizationDetailsError = 'Invalid JSON: ' + e.message
+ return
+ }
+ if (!Array.isArray(parsedAuthorizationDetails)) {
+ this.authorizationDetailsError = 'Authorization Details must be a JSON array'
+ return
+ }
+ const nonObject = parsedAuthorizationDetails.find(
+ entry => entry === null || typeof entry !== 'object' || Array.isArray(entry)
+ )
+ if (nonObject !== undefined) {
+ this.authorizationDetailsError = 'Each Authorization Details entry must be a JSON object'
+ return
+ }
+ }
+
const redirectUri = `${window.location.origin}${window.location.pathname}${this.$router.resolve({name: 'admin.identityDetails', params: {subjectID: this.subjectID}}).href}`
const requestBody = {
@@ -112,6 +151,9 @@ export default {
wallet_did: this.selectedWalletDID,
redirect_uri: redirectUri,
}
+ if (parsedAuthorizationDetails !== undefined) {
+ requestBody.authorization_details = parsedAuthorizationDetails
+ }
this.$api.post(`api/proxy/internal/auth/v2/${encodeURIPath(this.subjectID)}/request-credential`, requestBody)
.then(data => {
@@ -147,5 +189,22 @@ export default {
:deep(select) {
width: 100%;
}
+
+details {
+ margin-top: 0.5rem;
+}
+
+details > summary {
+ cursor: pointer;
+ user-select: none;
+ font-weight: 500;
+}
+
+details > textarea {
+ width: 100%;
+ margin-top: 0.5rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.875rem;
+}
diff --git a/web/src/components/ErrorMessage.vue b/web/src/components/ErrorMessage.vue
index 8c1bee9..efb4ee0 100644
--- a/web/src/components/ErrorMessage.vue
+++ b/web/src/components/ErrorMessage.vue
@@ -3,7 +3,7 @@
class="appearance-none mb-2 mt-2 px-6 py-3.5 w-full text-sm text-red-500 bg-red-100 placeholder-red-500 outline-hidden border border-red-500 focus:ring-4 focus:ring-blue-200 rounded-md"
role="alert">
{{ title }}
- {{ message }}
+ {{ message }}