OAuth2 OpenID Connect provider support, API and DB refactoring (#3)
Allows Paskia to authenticate the user to a client site. - User friendly client registration flow on the admin app - Redirect-based authentication flow (per spec) - Backchannel logout both ways to keep sessions synchronized - Groups integrated with Paskia's permission system - Adds email, preferred username and telephone fields on user profile - All new user basic info layout to show the new information, better looks - API and DB structures redesigned - Various unrelated fixes to theming and layout
This commit is contained in:
1 parent
557ffaa0cd
commit
68dccc1378
71 files changed
+3706
-805
No files matched your search
@@ -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,10 +10,20 @@ const props = defineProps({
|
||||
settings: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
||||
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([])
|
||||
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>
|
||||
@@ -25,6 +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-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">
|
||||
@@ -76,19 +88,19 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
||||
</label>
|
||||
<label>Permission Scope
|
||||
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
||||
<input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
||||
</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" 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==='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 +117,28 @@ 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-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; }
|
||||
.oidc-groups { cursor: default; }
|
||||
.oidc-group { cursor: pointer; }
|
||||
.oidc-group output { white-space: normal; word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { getDirection, navigateButtonRow } from '@/utils/keynav'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
permissions: Array,
|
||||
isNew: { type: Boolean, default: false },
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'cancel', 'delete', 'resetSecret', 'createPermission', 'navigateOut'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const headerRef = ref(null)
|
||||
|
||||
// Helper function to build URLs
|
||||
function authSitePath(path) {
|
||||
const url = new URL(authStore.settings.auth_site_url)
|
||||
url.pathname = path
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// Local form state
|
||||
const name = ref('')
|
||||
const redirectUris = ref('')
|
||||
const clientSecret = ref(null)
|
||||
|
||||
// Computed
|
||||
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
|
||||
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
|
||||
const iconUrl = computed(() => authSitePath('/favicon.ico'))
|
||||
|
||||
// Groups (permissions) scoped to this client
|
||||
const clientGroups = computed(() => {
|
||||
if (!props.client || !props.permissions) return []
|
||||
const clientUuid = props.client.uuid || props.client.client_id
|
||||
return props.permissions.filter(p => p.domain === clientUuid).sort((a, b) => a.scope.localeCompare(b.scope))
|
||||
})
|
||||
|
||||
// Initialize form data from props
|
||||
watch(() => props.client, (c) => {
|
||||
if (c) {
|
||||
name.value = c.name || ''
|
||||
redirectUris.value = Array.isArray(c.redirect_uris) ? c.redirect_uris.join('\n') : (c.redirect_uris || '')
|
||||
clientSecret.value = c.client_secret || null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Copy-to-clipboard helper
|
||||
function copyText(value, label) {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||||
})
|
||||
}
|
||||
|
||||
function handleResetSecret() {
|
||||
emit('resetSecret', clientId.value)
|
||||
}
|
||||
|
||||
// When parent resets secret, update local state
|
||||
watch(() => props.client?.client_secret, (newSecret) => {
|
||||
if (newSecret) {
|
||||
clientSecret.value = newSecret
|
||||
}
|
||||
})
|
||||
|
||||
function handleSave() {
|
||||
const trimmedName = name.value.trim()
|
||||
if (!trimmedName) {
|
||||
authStore.showMessage('Client name is required', 'error')
|
||||
return
|
||||
}
|
||||
const uris = redirectUris.value.trim()
|
||||
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
|
||||
|
||||
emit('save', {
|
||||
client_id: clientId.value,
|
||||
client_secret: clientSecret.value,
|
||||
name: trimmedName,
|
||||
redirect_uris,
|
||||
isNew: props.isNew
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit('delete', props.client)
|
||||
}
|
||||
|
||||
function handleCreatePermission() {
|
||||
emit('createPermission', clientId.value)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
function handleHeaderKeydown(event) {
|
||||
if (props.navigationDisabled) return
|
||||
|
||||
const direction = getDirection(event)
|
||||
if (!direction) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
navigateButtonRow(headerRef.value, event.target, direction, { itemSelector: 'button, a' })
|
||||
} else if (direction === 'up') {
|
||||
emit('navigateOut', 'up')
|
||||
}
|
||||
}
|
||||
|
||||
function focusFirstElement() {
|
||||
const firstFocusable = headerRef.value?.querySelector('button, a, input')
|
||||
if (firstFocusable) firstFocusable.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focusFirstElement })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="oidc-detail">
|
||||
<form @submit.prevent="handleSave" class="oidc-form">
|
||||
<!-- Client credentials section -->
|
||||
<section class="section-block">
|
||||
<div class="section-header">
|
||||
<h2>Client Configuration</h2>
|
||||
<p class="section-description">Configure these values in the client application.</p>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
|
||||
<dl class="oidc-dl">
|
||||
<dt>Authentication Name</dt>
|
||||
<dd>
|
||||
<output @click="copyText(authStore.settings.rp_name, 'Authentication Name')" title="Click to copy">{{ authStore.settings.rp_name }}</output>
|
||||
<span class="small muted"> (Login With, may affect URLs – optional)</span>
|
||||
</dd>
|
||||
|
||||
<dt>Client ID</dt>
|
||||
<dd><output @click="copyText(clientId, 'Client ID')" title="Click to copy">{{ clientId }}</output></dd>
|
||||
|
||||
<dt>Client Secret <button v-if="!clientSecret" type="button" class="icon-btn" @click="handleResetSecret" title="Revoke and re-generate secret">🔄</button></dt>
|
||||
<dd>
|
||||
<output v-if="clientSecret" @click="copyText(clientSecret, 'Client Secret')" title="Click to copy">{{ clientSecret }}</output>
|
||||
<span v-else class="small muted">(only stored in hashed form)</span>
|
||||
</dd>
|
||||
|
||||
<dt>Auto Discovery URL</dt>
|
||||
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
|
||||
|
||||
<dt>Icon URL</dt>
|
||||
<dd>
|
||||
<output @click="copyText(iconUrl, 'Icon URL')" title="Click to copy">{{ iconUrl }}</output>
|
||||
<span class="small muted"> (optional)</span>
|
||||
</dd>
|
||||
|
||||
|
||||
<template v-if="clientGroups.length">
|
||||
<dt>Groups Claim Name</dt>
|
||||
<dd>
|
||||
<output @click="copyText('groups', 'Groups Claim Name')" title="Click to copy">groups</output>
|
||||
</dd>
|
||||
</template>
|
||||
|
||||
<dt>Groups <button type="button" class="icon-btn" @click="handleCreatePermission" title="Add permission scoped to this client">➕</button></dt>
|
||||
<dd class="oidc-groups">
|
||||
<template v-if="clientGroups.length">
|
||||
<output v-for="group in clientGroups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">{{ group.scope }}</output>
|
||||
</template>
|
||||
<span v-else class="small muted">(no permissions defined)</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<span class="warning-text">
|
||||
<strong v-if="clientSecret">⚠️ {{ isNew ? 'Save the secret now — it cannot be retrieved later.' : 'Saving will prevent access with the old secret.' }}</strong>
|
||||
<span v-else>ℹ️ The client may use groups to check for required permissions.</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Editable fields -->
|
||||
<section class="section-block">
|
||||
<div class="section-header">
|
||||
<h2>Paskia Configuration</h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<label>Client Name
|
||||
<input v-model="name" required />
|
||||
</label>
|
||||
|
||||
<label>Redirect URIs
|
||||
<p class="small muted">This should be provided by the client application.</p>
|
||||
<textarea v-model="redirectUris" placeholder="(autodiscover one on first use)" rows="3"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="oidc-actions">
|
||||
<button type="button" class="btn-secondary" @click="handleCancel">Cancel</button>
|
||||
<button v-if="!isNew" type="button" class="btn-danger" @click="handleDelete">Delete Client</button>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.oidc-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.oidc-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.oidc-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.oidc-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.oidc-dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.3rem 1rem;
|
||||
align-items: baseline;
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.oidc-dl dt {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oidc-dl dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.oidc-dl output {
|
||||
font-family: var(--font-mono, monospace);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
.oidc-group { display: block; }
|
||||
.oidc-group { white-space: normal; word-break: break-all; }
|
||||
|
||||
.section-body label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.oidc-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
</style>
|
||||
@@ -16,16 +16,22 @@ const permMatrixRef = ref(null)
|
||||
const rolesGridRef = ref(null)
|
||||
|
||||
const sortedRoles = computed(() => {
|
||||
return Object.entries(props.selectedOrg.roles)
|
||||
.map(([uuid, r]) => ({ uuid, ...r }))
|
||||
.sort((a, b) => {
|
||||
const nameA = a.display_name.toLowerCase()
|
||||
const nameB = b.display_name.toLowerCase()
|
||||
if (nameA !== nameB) {
|
||||
return nameA.localeCompare(nameB)
|
||||
}
|
||||
return a.uuid.localeCompare(b.uuid)
|
||||
})
|
||||
// o.roles is dict[UUID, Role], convert to array for sorting with uuid added
|
||||
return Object.entries(props.selectedOrg.roles).map(([uuid, r]) => ({ uuid, ...r })).sort((a, b) => {
|
||||
const nameA = a.display_name.toLowerCase()
|
||||
const nameB = b.display_name.toLowerCase()
|
||||
if (nameA !== nameB) {
|
||||
return nameA.localeCompare(nameB)
|
||||
}
|
||||
return a.uuid.localeCompare(b.uuid)
|
||||
})
|
||||
})
|
||||
|
||||
// Get org's grantable permissions as full permission objects (with UUIDs)
|
||||
const orgPermissions = computed(() => {
|
||||
// props.selectedOrg.permissions is dict[UUID, Permission]
|
||||
const uuidSet = new Set(Object.keys(props.selectedOrg.permissions || {}))
|
||||
return props.permissions.filter(p => uuidSet.has(p.uuid))
|
||||
})
|
||||
|
||||
// Get users for a role as sorted array of { uuid, ...user }
|
||||
@@ -44,19 +50,12 @@ function roleUserCount(roleUuid) {
|
||||
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
|
||||
}
|
||||
|
||||
// Get org's grantable permissions as full permission objects (with UUIDs)
|
||||
const orgPermissions = computed(() => {
|
||||
return Object.entries(props.selectedOrg.permissions)
|
||||
.map(([uuid, p]) => ({ uuid, ...p }))
|
||||
.sort((a, b) => a.scope.localeCompare(b.scope))
|
||||
})
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
}
|
||||
|
||||
function toggleRolePermission(roleUuid, role, pid, checked) {
|
||||
emit('toggleRolePermission', roleUuid, role, pid, checked)
|
||||
function toggleRolePermission(role, pid, checked) {
|
||||
emit('toggleRolePermission', role, pid, checked)
|
||||
}
|
||||
|
||||
// Handle org title header keynav
|
||||
@@ -102,7 +101,7 @@ function handleMatrixKeydown(event) {
|
||||
|
||||
// Calculate grid dimensions
|
||||
const cols = sortedRoles.value.length
|
||||
const rows = props.selectedOrg.permissions.length
|
||||
const rows = Object.keys(props.selectedOrg.permissions).length
|
||||
|
||||
const currentRow = Math.floor(currentIndex / cols)
|
||||
const currentCol = currentIndex % cols
|
||||
@@ -238,7 +237,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
|
||||
if (checkboxes?.length) {
|
||||
// Focus the checkbox in the corresponding column
|
||||
const cols = sortedRoles.value.length
|
||||
const rows = props.selectedOrg.permissions.length
|
||||
const rows = Object.keys(props.selectedOrg.permissions).length
|
||||
const targetIndex = (rows - 1) * cols + roleIndex
|
||||
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
|
||||
else checkboxes[checkboxes.length - 1].focus()
|
||||
@@ -337,7 +336,7 @@ defineExpose({ focusFirstElement })
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="p.uuid in (r.permissions || {})"
|
||||
@change="e => toggleRolePermission(r.uuid, r, p.uuid, e.target.checked)"
|
||||
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
<div class="matrix-cell add-role-cell" />
|
||||
@@ -352,13 +351,13 @@ defineExpose({ focusFirstElement })
|
||||
:key="r.uuid"
|
||||
class="role-column"
|
||||
@dragover="$emit('onRoleDragOver', $event)"
|
||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r.uuid)"
|
||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
|
||||
>
|
||||
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
||||
<strong class="role-name" :title="r.uuid">
|
||||
<span>{{ r.display_name }}</span>
|
||||
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button>
|
||||
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r.uuid, r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role">❌</button>
|
||||
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role">❌</button>
|
||||
</strong>
|
||||
<div class="role-actions">
|
||||
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||
|
||||
@@ -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', 'openOidcClient', 'deleteOidcClient', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgSection = ref(null)
|
||||
@@ -19,11 +21,41 @@ 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.org.display_name.localeCompare(b.org.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
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -35,6 +67,7 @@ function permissionDisplayName(scope) {
|
||||
}
|
||||
|
||||
function getRoleNames(org) {
|
||||
// org.roles is dict[UUID, Role]
|
||||
return Object.values(org.roles)
|
||||
.slice()
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||
@@ -343,7 +376,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>
|
||||
@@ -352,6 +385,52 @@ 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">Groups</th>
|
||||
<th scope="col" class="center">Sessions</th>
|
||||
<th scope="col" class="center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!oidcClients || oidcClients.length === 0">
|
||||
<td colspan="4" 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">
|
||||
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
|
||||
</div>
|
||||
<div class="perm-id-info">
|
||||
<span class="id-text">{{ client.uuid }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="client-groups">
|
||||
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
<td class="center">{{ client.active_sessions || 0 }}</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>
|
||||
@@ -371,4 +450,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); }
|
||||
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
||||
</style>
|
||||
@@ -20,9 +20,15 @@ const props = defineProps({
|
||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const terminatingSessions = ref({})
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
|
||||
// Convert credentials dict to array with uuid attached as 'credential'
|
||||
const credentials = computed(() =>
|
||||
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||
)
|
||||
|
||||
// Template refs for navigation
|
||||
const userInfoRef = ref(null)
|
||||
const regActionsRef = ref(null)
|
||||
@@ -44,7 +50,7 @@ function handleEditName() {
|
||||
|
||||
async function handleDelete(credential) {
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.uuid}`, { method: 'DELETE' })
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
} else {
|
||||
@@ -56,15 +62,29 @@ async function handleDelete(credential) {
|
||||
}
|
||||
|
||||
async function handleTerminateSession(session) {
|
||||
const credentialUuid = session?.credential
|
||||
if (!credentialUuid) return
|
||||
const sessionKey = session?.key
|
||||
if (!sessionKey) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||
try {
|
||||
await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credentialUuid}`, { method: 'DELETE' })
|
||||
emit('refreshUserDetail')
|
||||
authStore.showMessage('Credential deleted', 'success', 2500)
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
if (data.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
emit('refreshUserDetail') // Refresh without showing rename message
|
||||
authStore.showMessage('Session terminated', 'success', 2500)
|
||||
} else {
|
||||
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete credential error', err)
|
||||
authStore.showMessage(err.message || 'Failed to delete credential', 'error')
|
||||
console.error('Terminate session error', err)
|
||||
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
||||
} finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionKey]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,23 +185,26 @@ defineExpose({ focusFirstElement })
|
||||
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
|
||||
<UserBasicInfo
|
||||
v-if="userDetail && !userDetail.error"
|
||||
:name="userDetail.user.display_name"
|
||||
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||
:visits="userDetail.user.visits"
|
||||
:created-at="userDetail.user.created_at"
|
||||
:last-seen="userDetail.user.last_seen"
|
||||
:email="userDetail.user.email"
|
||||
:telephone="userDetail.user.telephone"
|
||||
:loading="loading"
|
||||
:org-display-name="selectedOrg?.org?.display_name"
|
||||
:role-name="selectedUser?.role_display_name"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role.display_name"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@edit-name="handleEditName"
|
||||
@edit="handleEditName"
|
||||
>
|
||||
<div class="admin-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
@click="$emit('generateUserRegistrationLink', selectedUser)"
|
||||
:disabled="loading"
|
||||
>{{ Object.keys(userDetail?.credentials || {}).length ? 'Recovery Link' : 'Registration Link' }}</button>
|
||||
title="Generate a one-time link for this user"
|
||||
>{{ userDetail?.credentials && Object.keys(userDetail.credentials).length > 0 ? 'Recovery Link' : 'Registration Link' }}</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
@click="handleDeleteUser"
|
||||
@@ -200,11 +223,11 @@ defineExpose({ focusFirstElement })
|
||||
<div class="section-body">
|
||||
<CredentialList
|
||||
ref="credentialListRef"
|
||||
:credentials="Object.entries(userDetail.credentials).map(([uuid, c]) => ({ ...c, uuid })).sort((a, b) => new Date(a.created_at) - new Date(b.created_at))"
|
||||
:credentials="credentials"
|
||||
:aaguid-info="userDetail.aaguid_info"
|
||||
:allow-delete="true"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential || null"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@delete="handleDelete"
|
||||
@credential-hover="hoveredCredentialUuid = $event"
|
||||
@@ -214,7 +237,8 @@ defineExpose({ focusFirstElement })
|
||||
</section>
|
||||
<SessionList
|
||||
ref="sessionListRef"
|
||||
:sessions="userDetail.sessions"
|
||||
:sessions="userDetail.sessions || {}"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
:empty-message="'This user has no active sessions.'"
|
||||
@@ -230,7 +254,7 @@ defineExpose({ focusFirstElement })
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
|
||||
:user-name="userDetail?.user.display_name || selectedUser.display_name"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user