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:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -279,6 +279,10 @@ button:disabled {
|
||||
filter: opacity(0.6);
|
||||
}
|
||||
|
||||
output[title="Click to copy"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
@@ -317,9 +321,11 @@ button:disabled {
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
input:not([type]),
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="email"],
|
||||
input[type="tel"],
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
@@ -332,6 +338,18 @@ 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,
|
||||
input[type="tel"]: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 +520,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);
|
||||
@@ -807,7 +825,7 @@ th {
|
||||
display: grid;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
padding: 1.1rem 1.25rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="credential in credentials"
|
||||
:key="credential.uuid"
|
||||
:key="credential.credential"
|
||||
:class="['credential-item', {
|
||||
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
|
||||
'is-hovered': hoveredCredentialUuid === credential.uuid,
|
||||
'is-linked-session': hoveredSessionCredentialUuid === credential.uuid
|
||||
'is-hovered': hoveredCredentialUuid === credential.credential,
|
||||
'is-linked-session': hoveredSessionCredentialUuid === credential.credential
|
||||
}]"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click.capture="handleCardClick"
|
||||
@focusin="handleCredentialFocus(credential.uuid)"
|
||||
@focusin="handleCredentialFocus(credential.credential)"
|
||||
@focusout="handleCredentialBlur($event)"
|
||||
@keydown="handleItemKeydown($event, credential)"
|
||||
>
|
||||
@@ -33,8 +33,8 @@
|
||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredCredentialUuid === credential.uuid" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSessionCredentialUuid === credential.uuid" class="badge badge-current">Linked</span>
|
||||
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
||||
<button
|
||||
v-if="allowDelete"
|
||||
@click="$emit('delete', credential)"
|
||||
@@ -147,9 +147,9 @@ const getCredentialAuthName = (credential) => {
|
||||
const getCredentialAuthIcon = (credential) => {
|
||||
const info = props.aaguidInfo?.[credential.aaguid]
|
||||
if (!info) return null
|
||||
const isDarkMode = document.documentElement.classList.contains('dark')
|
||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
||||
return info[iconKey] || info.icon || null
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
// Fall back to icon if icon_dark is not available
|
||||
return (isDarkMode && info.icon_dark) || info.icon || null
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
:visits="authStore.userInfo?.visits || 0"
|
||||
:created-at="authStore.userInfo?.created_at"
|
||||
:last-seen="authStore.userInfo?.last_seen"
|
||||
:email="ctx.user.email"
|
||||
:telephone="ctx.user.telephone"
|
||||
:org-display-name="orgDisplayName"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
@@ -78,9 +80,9 @@ const currentHost = window.location.host
|
||||
const userInfoSection = ref(null)
|
||||
const buttonRow = ref(null)
|
||||
|
||||
const ctx = computed(() => authStore.userInfo?.ctx || null)
|
||||
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
|
||||
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
|
||||
const ctx = computed(() => authStore.userInfo || null)
|
||||
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
|
||||
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
const service = authStore.settings?.rp_name
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
|
||||
<slot />
|
||||
</dialog>
|
||||
<div class="dialog-overlay" @click="$emit('close')">
|
||||
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
|
||||
const props = defineProps({
|
||||
// Optional: provide a fallback element to focus if original element is gone
|
||||
@@ -17,7 +20,7 @@ const props = defineProps({
|
||||
focusSiblingSelector: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// Dialog element reference
|
||||
const dialog = ref(null)
|
||||
@@ -76,6 +79,13 @@ const restoreFocus = () => {
|
||||
}
|
||||
|
||||
const handleDialogKeydown = (event) => {
|
||||
// ESC to close (previously handled by <dialog> natively)
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
const direction = getDirection(event)
|
||||
if (!direction) return
|
||||
|
||||
@@ -111,11 +121,11 @@ onMounted(() => {
|
||||
// Save currently focused element before modal takes focus
|
||||
previouslyFocusedElement.value = document.activeElement
|
||||
|
||||
// Show the dialog as a modal
|
||||
holdGlobalBackdrop()
|
||||
|
||||
// Focus the most appropriate element
|
||||
nextTick(() => {
|
||||
if (dialog.value) {
|
||||
dialog.value.showModal()
|
||||
|
||||
// Autofocus the most appropriate element:
|
||||
// - For form dialogs (rename, edit): focus first input and select text
|
||||
// - For other dialogs: focus primary button (or fallback)
|
||||
@@ -131,14 +141,16 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
releaseGlobalBackdrop()
|
||||
// Restore focus when modal closes
|
||||
restoreFocus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
dialog {
|
||||
background: var(--color-surface);
|
||||
.modal-panel {
|
||||
background: var(--color-dialog);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
@@ -147,65 +159,36 @@ dialog {
|
||||
width: min(500px, 90vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: transparent;
|
||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
}
|
||||
|
||||
dialog :deep(.modal-title),
|
||||
dialog :deep(h3) {
|
||||
.modal-panel :deep(.modal-title),
|
||||
.modal-panel :deep(h3) {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
dialog :deep(form) {
|
||||
.modal-panel :deep(form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
dialog :deep(.modal-form) {
|
||||
.modal-panel :deep(.modal-form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
dialog :deep(.modal-form label) {
|
||||
.modal-panel :deep(.modal-form label) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
dialog :deep(.modal-form input),
|
||||
dialog :deep(.modal-form textarea) {
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
dialog :deep(.modal-actions) {
|
||||
.modal-panel :deep(.modal-actions) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<header class="view-header">
|
||||
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
||||
<p class="view-lede">Account dashboard to manage your profile and authentications.</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,18 @@
|
||||
v-if="authStore.userInfo?.user"
|
||||
ref="userBasicInfo"
|
||||
:name="authStore.userInfo.user.display_name"
|
||||
:email="authStore.userInfo.user.email"
|
||||
:preferred_username="authStore.userInfo.user.preferred_username"
|
||||
:telephone="authStore.userInfo.user.telephone"
|
||||
:visits="authStore.userInfo.user.visits"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
:loading="authStore.isLoading"
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
:org-display-name="authStore.ctx?.org.display_name"
|
||||
:role-name="authStore.ctx?.role.display_name"
|
||||
update-endpoint="/auth/api/user/info"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
@edit="openEditDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
>
|
||||
<div class="remote-auth-inline">
|
||||
@@ -35,7 +40,7 @@
|
||||
@device-info-visible="showDeviceInfo = $event"
|
||||
/>
|
||||
</div>
|
||||
<p class="remote-auth-description">Provided by another device requesting remote auth.</p>
|
||||
<p class="remote-auth-description">Login from another device</p>
|
||||
</UserBasicInfo>
|
||||
</section>
|
||||
|
||||
@@ -51,7 +56,7 @@
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:loading="authStore.isLoading"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSessionCredential"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
allow-delete
|
||||
@delete="handleDelete"
|
||||
@@ -68,11 +73,12 @@
|
||||
<SessionList
|
||||
ref="sessionList"
|
||||
:sessions="sessions"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
:section-class="useWideLayout ? '' : 'section-block--constrained'"
|
||||
@terminate="terminateSession"
|
||||
@session-hover="handleSessionHover"
|
||||
@session-hover="hoveredSession = $event"
|
||||
@navigate-out="handleSessionNavigateOut"
|
||||
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
|
||||
/>
|
||||
@@ -100,15 +106,28 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||
<h3>Edit Display Name</h3>
|
||||
<form @submit.prevent="saveName" class="modal-form">
|
||||
<NameEditForm
|
||||
label="Display Name"
|
||||
v-model="newName"
|
||||
:busy="saving"
|
||||
@cancel="showNameDialog = false"
|
||||
/>
|
||||
<Modal v-if="showEditDialog" @close="showEditDialog = false">
|
||||
<h3>Edit Profile</h3>
|
||||
<form @submit.prevent="saveProfile" class="modal-form">
|
||||
<div class="profile-edit-form">
|
||||
<label for="edit-display-name">Display Name
|
||||
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
|
||||
</label>
|
||||
<label for="edit-email">Email
|
||||
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" />
|
||||
</label>
|
||||
<label for="edit-username">Preferred Username
|
||||
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
|
||||
</label>
|
||||
<label for="edit-telephone">Telephone
|
||||
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" />
|
||||
</label>
|
||||
<div v-if="editError" class="error small">{{ editError }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
|
||||
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -128,7 +147,6 @@ import CredentialList from '@/components/CredentialList.vue'
|
||||
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
|
||||
@@ -141,13 +159,16 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showNameDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showRegLink = ref(false)
|
||||
const newName = ref('')
|
||||
const editName = ref('')
|
||||
const editEmail = ref('')
|
||||
const editUsername = ref('')
|
||||
const editTelephone = ref('')
|
||||
const saving = ref(false)
|
||||
const editError = ref('')
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const hoveredSessionCredential = ref(null)
|
||||
const showDeviceInfo = ref(false)
|
||||
const pairingEntry = ref(null)
|
||||
const credentialList = ref(null)
|
||||
@@ -159,9 +180,17 @@ const userBasicInfo = ref(null)
|
||||
const userInfoSection = ref(null)
|
||||
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
||||
const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
|
||||
|
||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
|
||||
watch(showEditDialog, (open) => {
|
||||
if (!open) return
|
||||
const user = authStore.userInfo?.user
|
||||
editName.value = user?.display_name ?? ''
|
||||
editEmail.value = user?.email ?? ''
|
||||
editUsername.value = user?.preferred_username ?? ''
|
||||
editTelephone.value = user?.telephone ?? ''
|
||||
editError.value = ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||
@@ -169,11 +198,6 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
||||
|
||||
const handleSessionHover = (session) => {
|
||||
hoveredSession.value = session
|
||||
hoveredSessionCredential.value = session?.credential || null
|
||||
}
|
||||
|
||||
const addNewCredential = async () => {
|
||||
try {
|
||||
await passkey.register(null, null, () => {
|
||||
@@ -307,7 +331,7 @@ const handleLogoutButtonKeydown = (event) => {
|
||||
}
|
||||
|
||||
const handleDelete = async (credential) => {
|
||||
const credentialId = credential?.uuid
|
||||
const credentialId = credential?.credential
|
||||
if (!credentialId) return
|
||||
try {
|
||||
await authStore.deleteCredential(credentialId)
|
||||
@@ -317,37 +341,41 @@ const handleDelete = async (credential) => {
|
||||
|
||||
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
||||
const credentials = computed(() => {
|
||||
const creds = authStore.userInfo?.credentials || {}
|
||||
return Object.entries(creds).map(([uuid, c]) => ({ ...c, uuid })).sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
|
||||
})
|
||||
const sessions = computed(() => authStore.userInfo?.sessions || [])
|
||||
const sessions = computed(() => authStore.userInfo?.sessions || {})
|
||||
const currentSessionHost = computed(() => {
|
||||
const currentSession = sessions.value.find(session => session.is_current)
|
||||
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
||||
return currentSession?.host || 'this host'
|
||||
})
|
||||
const terminatingSessions = ref({})
|
||||
|
||||
const terminateSession = async (session) => {
|
||||
if (session.is_current) {
|
||||
await logout()
|
||||
} else {
|
||||
try { await authStore.deleteCredential(session.credential) }
|
||||
catch (error) { authStore.showMessage(error.message || 'Failed to delete credential', 'error', 5000) }
|
||||
const sessionKey = session?.key
|
||||
if (!sessionKey) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||
try { await authStore.terminateSession(sessionKey) }
|
||||
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
||||
finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionKey]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
|
||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||
const logout = async () => { await authStore.logout() }
|
||||
const openNameDialog = () => { newName.value = authStore.userInfo?.user.display_name ?? ''; showNameDialog.value = true }
|
||||
const openEditDialog = () => { showEditDialog.value = true }
|
||||
const isAdmin = computed(() => {
|
||||
const perms = authStore.ctx?.permissions
|
||||
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
|
||||
})
|
||||
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
||||
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
||||
const credentials = computed(() =>
|
||||
Object.entries(authStore.userInfo?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||
)
|
||||
const useWideLayout = computed(() => {
|
||||
// Check if any single site has more than 8 sessions
|
||||
const groups = {}
|
||||
for (const session of sessions.value) {
|
||||
for (const session of Object.values(sessions.value)) {
|
||||
const host = session.host || ''
|
||||
if (!groups[host]) groups[host] = []
|
||||
groups[host].push(session)
|
||||
@@ -361,17 +389,30 @@ const useWideLayout = computed(() => {
|
||||
})
|
||||
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
||||
|
||||
const saveName = async () => {
|
||||
const name = newName.value.trim()
|
||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||
const saveProfile = async () => {
|
||||
const name = editName.value.trim()
|
||||
if (!name) { editError.value = 'Name cannot be empty'; return }
|
||||
const user = authStore.userInfo.user
|
||||
const emailVal = editEmail.value.trim() || null
|
||||
const usernameVal = editUsername.value.trim() || null
|
||||
const telephoneVal = editTelephone.value.trim() || null
|
||||
try {
|
||||
editError.value = ''
|
||||
saving.value = true
|
||||
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
|
||||
showNameDialog.value = false
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||
finally { saving.value = false }
|
||||
const body = {}
|
||||
if (name !== user.display_name) body.display_name = name
|
||||
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
|
||||
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||
if (Object.keys(body).length) {
|
||||
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||
}
|
||||
showEditDialog.value = false
|
||||
} catch (e) {
|
||||
editError.value = e.message || 'Failed to update profile'
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -386,4 +427,5 @@ const saveName = async () => {
|
||||
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
||||
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
|
||||
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
|
||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||
<div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
|
||||
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||
<div class="reg-header-row">
|
||||
<h2 id="regTitle" class="reg-title">
|
||||
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
|
||||
@@ -14,7 +15,6 @@
|
||||
</p>
|
||||
|
||||
<QRCodeDisplay
|
||||
v-if="linkUrl"
|
||||
:url="linkUrl"
|
||||
:show-link="true"
|
||||
@copied="onCopied"
|
||||
@@ -29,14 +29,15 @@
|
||||
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
|
||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||
import { apiJson } from 'paskia'
|
||||
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -78,16 +79,13 @@ async function generateLink() {
|
||||
expiresAt.value = data.expires ? new Date(data.expires) : null
|
||||
tokenType.value = data.token_type || null
|
||||
|
||||
// Show the dialog as modal
|
||||
await nextTick()
|
||||
if (dialog.value) {
|
||||
dialog.value.showModal()
|
||||
holdGlobalBackdrop()
|
||||
|
||||
// Focus primary button (or first button if no primary) after content renders
|
||||
const actions = actionsRow.value
|
||||
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
|
||||
target?.focus()
|
||||
}
|
||||
// Focus primary button (or first button if no primary) after content renders
|
||||
await nextTick()
|
||||
const actions = actionsRow.value
|
||||
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
|
||||
target?.focus()
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
@@ -102,7 +100,12 @@ function onCopied() {
|
||||
}
|
||||
|
||||
const handleDialogKeydown = (event) => {
|
||||
// ESC is handled automatically by <dialog>
|
||||
// ESC to close
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
// Handle other key navigation
|
||||
const direction = getDirection(event)
|
||||
if (!direction) return
|
||||
@@ -148,6 +151,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (linkUrl.value) releaseGlobalBackdrop()
|
||||
// Restore focus when modal closes
|
||||
const prev = previouslyFocusedElement.value
|
||||
if (prev && document.body.contains(prev) && !prev.disabled) {
|
||||
@@ -157,23 +161,6 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
dialog {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
||||
backdrop-filter: blur(.2rem) brightness(0.5);
|
||||
}
|
||||
|
||||
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
||||
.icon-btn:hover { opacity: 1; }
|
||||
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
||||
|
||||
@@ -771,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ async function startRemoteAuth() {
|
||||
} else if (msg.status === 'authenticated') {
|
||||
// Success
|
||||
completed.value = true
|
||||
emit('authenticated', { session_token: msg.session_token })
|
||||
emit('authenticated', { exchange_code: msg.exchange_code })
|
||||
break
|
||||
} else if (msg.status === 'denied') {
|
||||
// Explicitly denied by the authenticating device
|
||||
|
||||
@@ -66,7 +66,11 @@ const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
|
||||
validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
|
||||
},
|
||||
oidcQueryString: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -163,7 +167,7 @@ async function authenticateUser() {
|
||||
loading.value = true
|
||||
showMessage('Starting authentication…', 'info')
|
||||
let result
|
||||
try { result = await passkey.authenticate() } catch (error) {
|
||||
try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Passkey authentication cancelled'
|
||||
const cancelled = message === 'Passkey authentication cancelled'
|
||||
@@ -171,7 +175,13 @@ async function authenticateUser() {
|
||||
emit('auth-error', { message, cancelled })
|
||||
return
|
||||
}
|
||||
try { await setSessionCookie(result) } catch (error) {
|
||||
// OIDC flow: no session cookie, just emit the redirect_url
|
||||
if (result.redirect_url) {
|
||||
loading.value = false
|
||||
emit('authenticated', result)
|
||||
return
|
||||
}
|
||||
try { await exchangeCode(result) } catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
@@ -202,13 +212,13 @@ function openProfile() {
|
||||
if (profileWindow) profileWindow.focus()
|
||||
}
|
||||
|
||||
async function setSessionCookie(result) {
|
||||
if (!result?.session_token) {
|
||||
console.error('setSessionCookie called with missing session_token:', result)
|
||||
throw new Error('Authentication response missing session_token')
|
||||
async function exchangeCode(result) {
|
||||
if (!result?.exchange_code) {
|
||||
console.error('exchangeCode called with missing exchange_code:', result)
|
||||
throw new Error('Authentication response missing exchange_code')
|
||||
}
|
||||
return await fetchJson('/auth/api/set-session', {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
|
||||
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,7 +233,7 @@ function switchToLocal() {
|
||||
async function handleRemoteAuthenticated(result) {
|
||||
showMessage('Authenticated from another device!', 'success', 2000)
|
||||
try {
|
||||
await setSessionCookie(result)
|
||||
await exchangeCode(result)
|
||||
} catch (error) {
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
@@ -265,7 +275,12 @@ watch(initializing, (newVal) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
await validateSession()
|
||||
// OIDC mode doesn't depend on session state - skip validation
|
||||
if (props.mode !== 'oidc') {
|
||||
await validateSession()
|
||||
} else {
|
||||
currentView.value = 'login'
|
||||
}
|
||||
initializing.value = false
|
||||
|
||||
// Add click handler for inline links
|
||||
|
||||
@@ -6,20 +6,21 @@
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div>
|
||||
<template v-if="Array.isArray(sessions) && sessions.length">
|
||||
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
|
||||
<template v-if="sessionsArray.length">
|
||||
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
|
||||
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
||||
<span class="session-group-icon">🌐</span>
|
||||
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
|
||||
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
|
||||
<template v-if="group.isOIDC">{{ group.displayName }}</template>
|
||||
<a v-else-if="key" :href="hostUrl(key)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ key }}</a>
|
||||
<template v-else>Unbound host</template>
|
||||
</span>
|
||||
<div class="session-list">
|
||||
<div
|
||||
v-for="(session, index) in group.sessions"
|
||||
:key="index"
|
||||
v-for="session in group.sessions"
|
||||
:key="session.key"
|
||||
:class="['session-item', {
|
||||
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
||||
'is-hovered': hoveredSession === session,
|
||||
'is-hovered': hoveredSession?.key === session.key,
|
||||
'is-linked-credential': hoveredCredentialUuid === session.credential
|
||||
}]"
|
||||
tabindex="-1"
|
||||
@@ -33,13 +34,14 @@
|
||||
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredSession === session" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
|
||||
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
|
||||
<button
|
||||
@click="$emit('terminate', session)"
|
||||
class="btn-card-delete"
|
||||
:title="'Delete associated passkey'"
|
||||
:disabled="isTerminating(session.key)"
|
||||
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
|
||||
tabindex="-1"
|
||||
>❌</button>
|
||||
</div>
|
||||
@@ -68,9 +70,10 @@ import { hostIP } from '@/utils/helpers'
|
||||
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
sessions: { type: Array, default: () => [] },
|
||||
sessions: { type: Object, default: () => ({}) },
|
||||
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
||||
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
||||
terminatingSessions: { type: Object, default: () => ({}) },
|
||||
hoveredCredentialUuid: { type: String, default: null },
|
||||
navigationDisabled: { type: Boolean, default: false },
|
||||
sectionClass: { type: String, default: '' },
|
||||
@@ -105,6 +108,8 @@ const handleCardClick = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
|
||||
|
||||
const handleGroupKeydown = (event, host) => {
|
||||
const group = event.currentTarget
|
||||
const sessionList = group.querySelector('.session-list')
|
||||
@@ -146,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
|
||||
const handleItemKeydown = (event, session) => {
|
||||
// Handle delete (always allowed even with modal)
|
||||
handleDeleteKey(event, () => {
|
||||
if (!isTerminating(session.id)) emit('terminate', session)
|
||||
if (!isTerminating(session.key)) emit('terminate', session)
|
||||
})
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
@@ -205,9 +210,14 @@ const copyIp = async (ip) => {
|
||||
|
||||
const displayIp = ip => hostIP(ip) ?? ip
|
||||
|
||||
// Convert sessions dict to array with key attached
|
||||
const sessionsArray = computed(() =>
|
||||
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
|
||||
)
|
||||
|
||||
const currentHostIP = computed(() => {
|
||||
if (hoveredIp.value) return hostIP(hoveredIp.value)
|
||||
const current = props.sessions.find(s => s.is_current)
|
||||
const current = sessionsArray.value.find(s => s.is_current)
|
||||
return current ? hostIP(current.ip) : null
|
||||
})
|
||||
|
||||
@@ -215,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
|
||||
|
||||
const groupedSessions = computed(() => {
|
||||
const groups = {}
|
||||
for (const session of props.sessions) {
|
||||
const host = session.host || ''
|
||||
if (!groups[host]) {
|
||||
groups[host] = { sessions: [], isCurrentSite: false }
|
||||
}
|
||||
groups[host].sessions.push(session)
|
||||
if (session.is_current_host) {
|
||||
groups[host].isCurrentSite = true
|
||||
for (const session of sessionsArray.value) {
|
||||
const groupKey = session.client || session.host || ''
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
|
||||
}
|
||||
groups[groupKey].sessions.push(session)
|
||||
if (session.is_current_host) groups[groupKey].isCurrentSite = true
|
||||
}
|
||||
// Sort sessions within each group by last_renewed descending
|
||||
for (const host in groups) {
|
||||
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
||||
}
|
||||
// Sort groups by host name (natural sort)
|
||||
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
const sortedHosts = Object.keys(groups).sort(collator.compare)
|
||||
const sortedGroups = {}
|
||||
for (const host of sortedHosts) {
|
||||
sortedGroups[host] = groups[host]
|
||||
}
|
||||
return sortedGroups
|
||||
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
|
||||
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
|
||||
return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
|
||||
})
|
||||
return Object.fromEntries(sorted)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
<template>
|
||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||
<h3 class="user-name-heading">
|
||||
<span class="icon">👤</span>
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name">✏️</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<span class="date-label"><strong>Visits:</strong></span>
|
||||
<span class="date-value">{{ visits || 0 }}</span>
|
||||
<span class="date-label"><strong>Registered:</strong></span>
|
||||
<span class="date-value">{{ formatDate(createdAt) }}</span>
|
||||
<span class="date-label"><strong>Last seen:</strong></span>
|
||||
<span class="date-value">{{ formatDate(lastSeen) }}</span>
|
||||
<div class="user-info-content">
|
||||
<div class="user-picture">
|
||||
<span>👤</span>
|
||||
</div>
|
||||
<h3 class="user-name-heading">
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile">✏️</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||
</div>
|
||||
<div class="info-fields-block">
|
||||
<div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
|
||||
<a v-if="email" :href="`mailto:${email}`" class="contact-link">✉️ {{ email }}</a>
|
||||
<a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
|
||||
</div>
|
||||
<div class="info-line">
|
||||
<span v-if="visits">
|
||||
<span class="info-date">{{ formatDate(createdAt) }}</span>
|
||||
<span class="info-punct"> – </span>
|
||||
<span class="info-date">{{ formatDate(lastSeen) }}</span>
|
||||
<span class="info-punct"> ×</span>
|
||||
<span class="info-count">{{ visits }}</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="info-label">Created </span>
|
||||
<span class="info-date">{{ formatDate(createdAt) }}</span>
|
||||
<span class="info-punct"> — Never signed in</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="user-info-extra">
|
||||
<slot></slot>
|
||||
@@ -26,12 +41,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
email: { type: String, default: null },
|
||||
preferred_username: { type: String, default: null },
|
||||
telephone: { type: String, default: null },
|
||||
visits: { type: [Number, String], default: 0 },
|
||||
createdAt: { type: [String, Number, Date], default: null },
|
||||
lastSeen: { type: [String, Number, Date], default: null },
|
||||
@@ -42,7 +60,7 @@ const props = defineProps({
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'editName'])
|
||||
const emit = defineEmits(['saved', 'edit'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
@@ -50,55 +68,56 @@ const userLoaded = computed(() => !!props.name)
|
||||
|
||||
<style scoped>
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr 2fr;
|
||||
grid-template-columns: minmax(0, 1fr) 14rem;
|
||||
grid-template-areas:
|
||||
"heading heading extra"
|
||||
"org org extra"
|
||||
"label1 value1 extra"
|
||||
"label2 value2 extra"
|
||||
"label3 value3 extra";
|
||||
"content extra";
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-info:not(.has-extra) {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"heading heading"
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3";
|
||||
"content";
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"heading heading"
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3"
|
||||
"extra extra";
|
||||
"content"
|
||||
"extra";
|
||||
}
|
||||
}
|
||||
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
||||
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
||||
.info-label:nth-of-type(1) { grid-area: label1; }
|
||||
.info-value:nth-of-type(2) { grid-area: value1; }
|
||||
.info-label:nth-of-type(3) { grid-area: label2; }
|
||||
.info-value:nth-of-type(4) { grid-area: value2; }
|
||||
.info-label:nth-of-type(5) { grid-area: label3; }
|
||||
.info-value:nth-of-type(6) { grid-area: value3; }
|
||||
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
|
||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
||||
.user-name-row.editing { flex: 1 1 auto; }
|
||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
|
||||
.user-name-heading .name-input { width: auto; }
|
||||
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
|
||||
.user-info-content {
|
||||
grid-area: content;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"picture heading fields"
|
||||
"picture org fields"
|
||||
". info info";
|
||||
gap: 0 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
|
||||
.info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.contact-link:hover { transform: scale(1.01); }
|
||||
.info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
|
||||
.info-date { color: var(--color-text) !important; }
|
||||
.info-label { color: var(--color-text) !important; }
|
||||
.info-punct { color: var(--color-text-muted) !important; }
|
||||
.info-count { color: var(--color-text-muted) !important; }
|
||||
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
|
||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
|
||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
|
||||
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
|
||||
@@ -87,7 +87,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
updateThemeFromSession(this.ctx)
|
||||
console.log('User info loaded:', this.userInfo)
|
||||
} catch (error) {
|
||||
@@ -104,9 +104,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||
await this.loadUserInfo()
|
||||
},
|
||||
async terminateSession(sessionId) {
|
||||
async terminateSession(sessionKey) {
|
||||
try {
|
||||
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
||||
const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
|
||||
if (payload?.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
|
||||
@@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticate() {
|
||||
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
|
||||
export async function authenticate(queryString = null) {
|
||||
// Build URL, optionally appending raw query string (e.g. for OIDC params)
|
||||
let url = await makeUrl('/auth/ws/authenticate')
|
||||
if (queryString) {
|
||||
url += queryString.startsWith('?') ? queryString : `?${queryString}`
|
||||
}
|
||||
const ws = await aWebSocket(url)
|
||||
try {
|
||||
let res = await ws.receive_json()
|
||||
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
|
||||
|
||||
Reference in New Issue
Block a user