User friendly admin app OIDC Client dialog. Permissions domain scoping to OIDC Clients.

This commit is contained in:
Leo Vasanko
2026-02-16 17:43:52 +00:00
parent b73b2d6fe9
commit e7e0097fba
9 changed files with 179 additions and 87 deletions
+45 -23
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
dialog: Object,
@@ -9,11 +10,20 @@ const props = defineProps({
settings: Object
})
const emit = defineEmits(['submitDialog', 'closeDialog'])
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set(['oidc-created'])
const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
</script>
<template>
@@ -26,9 +36,7 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-create'">Create OIDC Client</template>
<template v-else-if="dialog.type==='oidc-edit'">Edit OIDC Client</template>
<template v-else-if="dialog.type==='oidc-created'">OIDC Client Created</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -84,30 +92,40 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
</label>
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
<label>Domain Scope
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com or OIDC client UUID" data-form-type="other" />
</label>
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template>
<template v-else-if="dialog.type==='oidc-create' || dialog.type==='oidc-edit'">
<template v-else-if="dialog.type==='oidc-edit'">
<label>Client Name
<input v-model="dialog.data.name" placeholder="My Application" required />
</label>
<label>Redirect URIs (one per line)
<textarea v-model="dialog.data.redirect_uris" placeholder="https://example.com/callback&#10;https://app.example.com/auth/callback" rows="4" required></textarea>
<textarea v-model="dialog.data.redirect_uris" placeholder="https://example.com/callback&#10;https://app.example.com/auth/callback" rows="4"></textarea>
</label>
<p class="small muted">Enter the allowed callback URLs for this OIDC client, one per line.</p>
</template>
<template v-else-if="dialog.type==='oidc-created'">
<div class="oidc-success">
<p><strong> OIDC Client Created Successfully!</strong></p>
<label>Client ID
<input v-model="dialog.data.client_id" readonly />
</label>
<label>Client Secret
<input v-model="dialog.data.client_secret" readonly />
</label>
<p><strong> Important:</strong> Save the client secret now. It cannot be retrieved later!</p>
</div>
<template v-if="dialog.data.client_id">
<hr class="oidc-divider" />
<p class="small muted">Configure these in the remote application. Click a value to copy.</p>
<dl class="oidc-dl">
<dt>Client ID</dt>
<dd @click="copyText(dialog.data.client_id, 'Client ID')" title="Click to copy"><output>{{ dialog.data.client_id }}</output></dd>
<template v-if="dialog.data.client_secret">
<dt>Client Secret</dt>
<dd @click="copyText(dialog.data.client_secret, 'Client Secret')" title="Click to copy"><output>{{ dialog.data.client_secret }}</output></dd>
</template>
<dt>Discovery URL</dt>
<dd @click="copyText(discoveryUrl, 'Discovery URL')" title="Click to copy"><output>{{ discoveryUrl }}</output></dd>
</dl>
<p v-if="dialog.data.client_secret" class="small"><strong> Save the secret now it cannot be retrieved later.</strong></p>
<div v-else class="oidc-reset-row">
<button type="button" class="btn-secondary" @click="$emit('resetOidcSecret', dialog.data.client_id)">
🔄 Reset Client Secret
</button>
<span class="small muted">Generate a new secret (invalidates the current one)</span>
</div>
</template>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
@@ -145,6 +163,10 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<style scoped>
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
.oidc-success { display: flex; flex-direction: column; gap: var(--space-md); }
.oidc-success input { font-family: monospace; }
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
</style>
+12 -1
View File
@@ -28,6 +28,17 @@ const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
}))
// Map OIDC client UUIDs to display names for permission domain column
const oidcClientNames = computed(() => {
const map = {}
for (const c of props.oidcClients || []) map[c.uuid] = c.name
return map
})
function domainDisplay(domain) {
if (!domain) return '—'
return oidcClientNames.value[domain] || domain
}
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
// Derive admin status from permissions (info contains ctx from validate response)
@@ -343,7 +354,7 @@ defineExpose({ focusFirstElement })
<span class="id-text">{{ p.scope }}</span>
</div>
</td>
<td class="perm-domain">{{ p.domain || '—' }}</td>
<td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
<td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
+3 -2
View File
@@ -138,7 +138,8 @@ onUnmounted(() => {
<style scoped>
dialog {
background: var(--color-surface);
background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
@@ -202,7 +203,7 @@ dialog :deep(.modal-form input:focus),
dialog :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px #c7d2fe;
box-shadow: var(--focus-ring);
}
dialog :deep(.modal-actions) {