diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 5012cba..4c9307f 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -9,10 +9,12 @@ import AccessDenied from '@/components/AccessDenied.vue' import AdminOverview from '@/admin/AdminOverview.vue' import AdminOrgDetail from '@/admin/AdminOrgDetail.vue' import AdminUserDetail from '@/admin/AdminUserDetail.vue' +import AdminOidcDetail from '@/admin/AdminOidcDetail.vue' import AdminDialogs from '@/admin/AdminDialogs.vue' import { useAuthStore } from '@/stores/auth' import { adminUiPath, makeUiHref } from '@/utils/settings' import { apiJson, SessionValidator } from 'paskia' +import { uuidv7 } from 'uuidv7' import { getDirection } from '@/utils/keynav' import { goBack } from '@/utils/helpers' @@ -27,7 +29,9 @@ const permissions = ref([]) const oidcClients = ref([]) const currentOrgId = ref(null) // UUID of selected org for detail view const currentUserId = ref(null) // UUID for user detail view +const currentOidcId = ref(null) // UUID for OIDC client detail view const userDetail = ref(null) // cached user detail object +const editingOidcClient = ref(null) // OIDC client being edited (with local changes) const authStore = useAuthStore() const addingOrgForPermission = ref(null) const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' @@ -44,6 +48,7 @@ const breadcrumbsRef = ref(null) const adminOverviewRef = ref(null) const adminOrgDetailRef = ref(null) const adminUserDetailRef = ref(null) +const adminOidcDetailRef = ref(null) // Check if any modal/dialog is open (blocks arrow key navigation) const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value) @@ -121,10 +126,40 @@ function parseHash() { const h = window.location.hash || '' currentOrgId.value = null currentUserId.value = null + currentOidcId.value = null + editingOidcClient.value = null if (h.startsWith('#org/')) { currentOrgId.value = h.slice(5) } else if (h.startsWith('#user/')) { currentUserId.value = h.slice(6) + } else if (h.startsWith('#oidc:')) { + const oidcUuid = h.slice(6) + currentOidcId.value = oidcUuid + // Initialize editing client data + if (oidcUuid === 'new') { + // Generate client_id and secret for new client + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + const client_secret = btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + editingOidcClient.value = { + client_id: uuidv7(), + client_secret, + isNew: true, + name: '', + redirect_uris: [] + } + } else { + const client = oidcClients.value.find(c => c.uuid === oidcUuid) + if (client) { + editingOidcClient.value = { + ...client, + client_id: client.uuid, + client_secret: null, + isNew: false + } + } + } } } @@ -174,6 +209,7 @@ function clearSensitiveState() { permissions.value = [] oidcClients.value = [] userDetail.value = null + editingOidcClient.value = null authenticated.value = false } @@ -406,42 +442,39 @@ function deletePermission(p) { } // OIDC Client actions -async function createOidcClient() { - try { - // Create the record on the server immediately, then open edit dialog - const result = await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: {} }) - await loadOidcClients() - openDialog('oidc-edit', { - client_id: result.client_id, - client_secret: result.client_secret, - isNew: true, - name: '', - redirect_uris: '' - }) - } catch (e) { - authStore.showMessage(e.message || 'Failed to create OIDC client', 'error') +async function sha256Hex(text) { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)) + return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('') +} + +function createOidcClient() { + // Navigate to new OIDC client page + window.location.hash = '#oidc:new' +} + +function openOidcClient(client) { + // Navigate to OIDC client detail page + window.location.hash = `#oidc:${client.uuid}` +} + +function resetOidcSecret(clientId) { + // Generate new secret locally; it will be sent to server on Save + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + const client_secret = btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + // Update editingOidcClient if we're on the detail page + if (editingOidcClient.value?.client_id === clientId) { + editingOidcClient.value = { ...editingOidcClient.value, client_secret } + } + // Also update dialog if open (for backwards compatibility) + if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) { + dialog.value.data.client_secret = client_secret } } -function editOidcClient(client) { - openDialog('oidc-edit', { - client_id: client.uuid, - name: client.name, - redirect_uris: client.redirect_uris.join('\n') - }) -} - -async function resetOidcSecret(clientId) { - try { - const result = await apiJson(`/auth/api/admin/oidc-clients/${clientId}/reset-secret`, { method: 'POST' }) - // Update the dialog data in place so the new secret is shown - if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) { - dialog.value.data.client_secret = result.client_secret - } - authStore.showMessage('Client secret has been reset.', 'success', 2500) - } catch (e) { - authStore.showMessage(e.message || 'Failed to reset client secret', 'error') - } +function createPermissionForClient(clientId) { + openDialog('perm-create', { display_name: '', scope: '', domain: clientId }) } function deleteOidcClient(client) { @@ -449,6 +482,10 @@ function deleteOidcClient(client) { message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`, action: async () => { await performOidcClientDeletion(client.uuid, client.name) + // Navigate back to overview if we were on the detail page + if (currentOidcId.value === client.uuid) { + window.location.hash = '#overview' + } } }) } @@ -459,6 +496,32 @@ async function performOidcClientDeletion(clientUuid, clientName) { await loadOidcClients() } +async function handleOidcSave(data) { + const { client_id, client_secret, name, redirect_uris, isNew } = data + + try { + if (client_secret) { + const secret_hash = await sha256Hex(client_secret) + if (isNew) { + await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } }) + } else { + await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }) + } + } else { + await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } }) + } + authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500) + await loadOidcClients() + window.location.hash = '#overview' + } catch (e) { + authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') + } +} + +function handleOidcCancel() { + goOverview() +} + const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null) function openOrg(o) { @@ -487,6 +550,7 @@ const selectedUser = computed(() => { const pageHeading = computed(() => { if (selectedUser.value) return 'Admin: User' if (selectedOrg.value) return 'Admin: Org' + if (currentOidcId.value) return 'Admin: OIDC Client' return ((authStore.settings?.rp_name) || 'Master') + ' Admin' }) @@ -505,6 +569,10 @@ const breadcrumbEntries = computed(() => { if (orgToShow) { entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` }) } + if (currentOidcId.value) { + const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client') + entries.push({ label, href: `#oidc:${currentOidcId.value}` }) + } if (selectedUser.value) { entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` }) } @@ -798,7 +866,7 @@ async function submitDialog() { }) return // Don't call closeDialog() again } else if (t === 'oidc-edit') { - const { client_id } = dialog.value.data + const { client_id, client_secret, isNew } = dialog.value.data const name = dialog.value.data.name?.trim() const uris = dialog.value.data.redirect_uris?.trim() if (!name) throw new Error('Client name required') @@ -808,13 +876,18 @@ async function submitDialog() { // Close dialog immediately, then perform async operation closeDialog() - apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } }) + const req = client_secret + ? sha256Hex(client_secret).then(secret_hash => isNew + ? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } }) + : apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })) + : apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } }) + req .then(() => { - authStore.showMessage(`OIDC client "${name}" updated.`, 'success', 2500) + authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500) loadOidcClients() }) .catch(e => { - authStore.showMessage(e.message || 'Failed to update OIDC client', 'error') + authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') }) return // Don't call closeDialog() again } else if (t === 'confirm') { @@ -864,7 +937,7 @@ async function submitDialog() {
@@ -924,6 +997,21 @@ async function submitDialog() { @on-user-drag-start="onUserDragStart" /> + +
@@ -936,6 +1024,7 @@ async function submitDialog() { @submit-dialog="submitDialog" @close-dialog="closeDialog" @reset-oidc-secret="resetOidcSecret" + @create-permission-for-client="createPermissionForClient" /> diff --git a/frontend/package.json b/frontend/package.json index bd70b85..89670f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "pinia": "^3.0.3", "qrcode": "^1.5.4", "sirv": "^3.0.2", + "uuidv7": "^1.1.0", "vue": "^3.5.17" }, "devDependencies": { diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index e3b3880..dd88d34 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -10,7 +10,7 @@ const props = defineProps({ settings: Object }) -const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret']) +const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient']) const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) const NO_SUBMIT_TYPES = new Set([]) @@ -97,36 +97,44 @@ function copyText(value, label) {

A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a groups claim to that client.

+ @@ -169,4 +177,7 @@ function copyText(value, label) { .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; } +.oidc-groups { cursor: default; } +.oidc-group { cursor: pointer; } +.oidc-group output { white-space: normal; word-break: break-all; } diff --git a/frontend/src/admin/AdminOidcDetail.vue b/frontend/src/admin/AdminOidcDetail.vue new file mode 100644 index 0000000..8fa36e6 --- /dev/null +++ b/frontend/src/admin/AdminOidcDetail.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/frontend/src/admin/AdminOverview.vue b/frontend/src/admin/AdminOverview.vue index d320598..624693c 100644 --- a/frontend/src/admin/AdminOverview.vue +++ b/frontend/src/admin/AdminOverview.vue @@ -12,7 +12,7 @@ const props = defineProps({ navigationDisabled: { type: Boolean, default: false } }) -const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'editOidcClient', 'deleteOidcClient', 'navigateOut']) +const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut']) // Template refs for navigation const orgSection = ref(null) @@ -39,6 +39,23 @@ function domainDisplay(domain) { if (!domain) return '—' return oidcClientNames.value[domain] || domain } + +// Map OIDC client UUIDs to their group permissions (sorted by scope) +const clientGroups = computed(() => { + const map = {} + for (const p of props.permissions || []) { + if (p.domain) { + if (!map[p.domain]) map[p.domain] = [] + map[p.domain].push(p) + } + } + // Sort each group array by scope + for (const key in map) { + map[key].sort((a, b) => a.scope.localeCompare(b.scope)) + } + return map +}) + const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope))) // Derive admin status from permissions (info contains ctx from validate response) @@ -379,25 +396,29 @@ defineExpose({ focusFirstElement }) Client - Redirect URI + Groups + Sessions Actions - No OIDC clients configured + No OIDC clients configured
- {{ client.name }} - + {{ client.name }}
{{ client.uuid }}
- {{ client.redirect_uris.join(', ') }} + + {{ clientGroups[client.uuid].map(g => g.scope).join(' ') }} + — + + {{ client.active_sessions || 0 }} @@ -428,5 +449,5 @@ defineExpose({ focusFirstElement }) /* OIDC Clients Section */ .oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); } .oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); } -.redirect-uris { font-size: 0.9rem; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); } diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 2e89233..65d5f9d 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -317,6 +317,7 @@ button:disabled { box-shadow: var(--shadow-soft); } +input:not([type]), input[type="text"], input[type="search"], input[type="email"], @@ -332,6 +333,17 @@ select { transition: border-color var(--transition-base), box-shadow var(--transition-base); } +input:not([type]):focus, +input[type="text"]:focus, +input[type="search"]:focus, +input[type="email"]:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: var(--focus-ring); +} + label { display: flex; flex-direction: column; @@ -502,7 +514,7 @@ th { left: 0; right: 0; margin: 0 auto; - z-index: 1200; + z-index: 2000; width: fit-content; min-width: min(520px, calc(100% - 2rem)); max-width: calc(100% - 2rem); diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue index b197d6c..f59db5e 100644 --- a/frontend/src/components/Modal.vue +++ b/frontend/src/components/Modal.vue @@ -1,12 +1,15 @@