Renamed OIDC permissions claim to more commonly used groups. Move jwtk to a more convenient location. Draft admin app OIDC client configuratioon.

This commit is contained in:
Leo Vasanko
2026-02-16 16:54:53 +00:00
parent eece6d4a21
commit b73b2d6fe9
11 changed files with 295 additions and 22 deletions
+107
View File
@@ -24,6 +24,7 @@ const showBackMessage = ref(false)
const error = ref(null)
const orgs = ref([])
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 userDetail = ref(null) // cached user detail object
@@ -143,6 +144,24 @@ async function loadPermissions() {
permissions.value = await apiJson('/auth/api/admin/permissions')
}
async function loadOidcClients() {
// Only master admins can view OIDC clients
if (!isMasterAdmin.value) {
oidcClients.value = []
return
}
try {
oidcClients.value = await apiJson('/auth/api/admin/oidc-clients')
} catch (e) {
// If 403, user is not master admin - silently skip
if (e.message?.includes('403') || e.message?.includes('Forbidden')) {
oidcClients.value = []
} else {
throw e
}
}
}
async function loadUserInfo() {
const data = await apiJson('/auth/api/validate', { method: 'POST' })
info.value = data
@@ -153,6 +172,7 @@ function clearSensitiveState() {
info.value = null
orgs.value = []
permissions.value = []
oidcClients.value = []
userDetail.value = null
authenticated.value = false
}
@@ -181,6 +201,8 @@ async function load() {
await Promise.all([loadOrgs(), loadPermissions()])
// If we get here, user has admin access - now fetch user info for display
await loadUserInfo()
// Load OIDC clients after authentication (master admin only)
await loadOidcClients()
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
if (!window.location.hash || window.location.hash === '#overview') {
@@ -383,6 +405,34 @@ function deletePermission(p) {
} })
}
// OIDC Client actions
function createOidcClient() {
openDialog('oidc-create', { name: '', redirect_uris: '' })
}
function editOidcClient(client) {
openDialog('oidc-edit', {
client,
name: client.name,
redirect_uris: client.redirect_uris.join('\n')
})
}
function deleteOidcClient(client) {
openDialog('confirm', {
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
action: async () => {
await performOidcClientDeletion(client.uuid, client.name)
}
})
}
async function performOidcClientDeletion(clientUuid, clientName) {
await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' })
authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500)
await loadOidcClients()
}
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
function openOrg(o) {
@@ -721,6 +771,59 @@ async function submitDialog() {
authStore.showMessage(e.message || 'Failed to create permission', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'oidc-create') {
const name = dialog.value.data.name?.trim()
const uris = dialog.value.data.redirect_uris?.trim()
if (!name) throw new Error('Client name required')
if (!uris) throw new Error('Redirect URIs required')
const redirect_uris = uris.split('\n').map(u => u.trim()).filter(u => u)
if (redirect_uris.length === 0) throw new Error('At least one redirect URI required')
// Close dialog immediately, then perform async operation
closeDialog()
apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { name, redirect_uris } })
.then((result) => {
// Show success dialog with client credentials
openDialog('oidc-created', {
client_id: result.client_id,
client_secret: result.client_secret
})
loadOidcClients()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create OIDC client', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'oidc-edit') {
const { client } = 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')
if (!uris) throw new Error('Redirect URIs required')
const redirect_uris = uris.split('\n').map(u => u.trim()).filter(u => u)
if (redirect_uris.length === 0) throw new Error('At least one redirect URI required')
// Close dialog immediately, then perform async operation
closeDialog()
// Check if anything changed
const oldUris = [...client.redirect_uris].sort().join('\n')
const newUris = [...redirect_uris].sort().join('\n')
if (name === client.name && oldUris === newUris) {
return // No changes
}
apiJson(`/auth/api/admin/oidc-clients/${client.uuid}`, { method: 'PATCH', body: { name, redirect_uris } })
.then(() => {
authStore.showMessage(`OIDC client "${name}" updated.`, 'success', 2500)
loadOidcClients()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update OIDC client', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') {
const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage)
@@ -773,6 +876,7 @@ async function submitDialog() {
:info="info"
:orgs="orgs"
:permissions="permissions"
:oidc-clients="oidcClients"
:navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary"
@create-org="createOrg"
@@ -783,6 +887,9 @@ async function submitDialog() {
@open-dialog="openDialog"
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@edit-oidc-client="editOidcClient"
@delete-oidc-client="deleteOidcClient"
@navigate-out="handlePanelNavigateOut"
/>
+37 -1
View File
@@ -12,6 +12,7 @@ const props = defineProps({
const emit = defineEmits(['submitDialog', 'closeDialog'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set(['oidc-created'])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
</script>
@@ -25,6 +26,9 @@ 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==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -84,11 +88,32 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
</label>
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
</template>
<template v-else-if="dialog.type==='oidc-create' || 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>
</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>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-secondary"
@@ -105,10 +130,21 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button>
</div>
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-primary"
@click="$emit('closeDialog')"
>
Close
</button>
</div>
</form>
</Modal>
</template>
<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; }
</style>
+52 -1
View File
@@ -1,16 +1,18 @@
<script setup>
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
oidcClients: Array,
permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'editOidcClient', 'deleteOidcClient', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
@@ -19,6 +21,8 @@ const orgTableRef = ref(null)
const permMatrixRef = ref(null)
const permActionsRef = ref(null)
const permTableRef = ref(null)
const oidcActionsRef = ref(null)
const oidcTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
@@ -348,6 +352,48 @@ defineExpose({ focusFirstElement })
</tbody>
</table>
</div>
<div v-if="isMasterAdmin" class="oidc-clients-section">
<div class="section-header">
<h2>OAuth2 / OpenID Connect</h2>
<p class="section-description">
Allow external websites and applications to securely authenticate users through this system.
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
</p>
</div>
<div ref="oidcActionsRef">
<button @click="$emit('createOidcClient')">+ Add Site</button>
</div>
<table class="org-table" ref="oidcTableRef">
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Redirect URI</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="!oidcClients || oidcClients.length === 0">
<td colspan="3" class="center muted">No OIDC clients configured</td>
</tr>
<tr v-for="client in oidcClients" :key="client.uuid">
<td class="perm-name-cell">
<div class="perm-title">
<span class="display-text">{{ client.name }}</span>
<button @click="$emit('editOidcClient', client)" class="icon-btn edit-display-btn" aria-label="Edit OIDC client" title="Edit OIDC client"></button>
</div>
<div class="perm-id-info">
<span class="id-text">{{ client.uuid }}</span>
</div>
</td>
<td class="redirect-uris">{{ client.redirect_uris.join(', ') }}</td>
<td class="center">
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client"></button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
@@ -367,4 +413,9 @@ defineExpose({ focusFirstElement })
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; }
/* 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; }
</style>