Admin OIDC Client editing moved to its own page.

This commit is contained in:
Leo Vasanko
2026-02-16 20:32:55 +00:00
parent 09049d3094
commit 30aeb9a310
12 changed files with 603 additions and 173 deletions
+128 -39
View File
@@ -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() {
<div class="section-body admin-section-body">
<div class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
ref="adminOverviewRef"
:info="info"
:orgs="orgs"
@@ -881,7 +954,7 @@ async function submitDialog() {
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@edit-oidc-client="editOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
@navigate-out="handlePanelNavigateOut"
/>
@@ -924,6 +997,21 @@ async function submitDialog() {
@on-user-drag-start="onUserDragStart"
/>
<AdminOidcDetail
v-else-if="currentOidcId && editingOidcClient"
ref="adminOidcDetailRef"
:client="editingOidcClient"
:permissions="permissions"
:is-new="editingOidcClient.isNew"
:navigation-disabled="hasActiveModal"
@save="handleOidcSave"
@cancel="handleOidcCancel"
@delete="deleteOidcClient"
@reset-secret="resetOidcSecret"
@create-permission="createPermissionForClient"
@navigate-out="handlePanelNavigateOut"
/>
</div>
</div>
</section>
@@ -936,6 +1024,7 @@ async function submitDialog() {
@submit-dialog="submitDialog"
@close-dialog="closeDialog"
@reset-oidc-secret="resetOidcSecret"
@create-permission-for-client="createPermissionForClient"
/>
</div>
</template>