Permissions refactor. Permissions have UUID and scope (previously id) and the latter no longer needs to be unique. Org admin uses a single global permission now. Domain scoped permissions. Removed from user info the admin fields, use effective_permission checks instead.

This commit is contained in:
2026-01-23 13:54:31 +00:00
parent 236d52aa55
commit 3430c7f0cf
16 changed files with 729 additions and 318 deletions
+31 -22
View File
@@ -46,6 +46,10 @@ const adminUserDetailRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
// Derive admin status from permissions
const isGlobalAdmin = computed(() => info.value?.permissions?.includes('auth:admin') ?? false)
const isOrgAdmin = computed(() => info.value?.permissions?.includes('auth:org:admin') ?? false)
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
function handleGlobalClick(e) {
@@ -108,7 +112,7 @@ const permissionSummary = computed(() => {
return display
})
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) }
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, scope: p.scope, display_name: p.display_name, domain: p.domain || '' }) }
function parseHash() {
@@ -153,7 +157,7 @@ async function load() {
// If we get here, user has admin access - now fetch user info for display
await loadUserInfo()
if (!info.value.is_global_admin && info.value.is_org_admin && orgs.value.length === 1) {
if (!isGlobalAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}`
@@ -186,7 +190,7 @@ async function performOrgDeletion(orgUuid) {
}
function deleteOrg(org) {
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
if (!isGlobalAdmin.value) { authStore.showMessage('Global admin only'); return }
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
@@ -289,20 +293,20 @@ async function toggleRolePermission(role, pid, checked) {
}
// Permission actions
async function performPermissionDeletion(permissionId) {
const params = new URLSearchParams({ permission_id: permissionId })
async function performPermissionDeletion(permissionScope) {
const params = new URLSearchParams({ permission_id: permissionScope })
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
await loadPermissions()
}
function deletePermission(p) {
const userCount = permissionSummary.value[p.id]?.userCount || 0
const userCount = permissionSummary.value[p.scope]?.userCount || 0
// Count roles that have this permission
let roleCount = 0
for (const org of orgs.value) {
for (const role of org.roles) {
if (role.permissions.includes(p.id)) {
if (role.permissions.includes(p.scope)) {
roleCount++
}
}
@@ -310,7 +314,7 @@ function deletePermission(p) {
if (roleCount === 0) {
// No roles have this permission, safe to delete directly
performPermissionDeletion(p.id)
performPermissionDeletion(p.scope)
.then(() => {
authStore.showMessage(`Permission "${p.display_name}" deleted.`, 'success', 2500)
})
@@ -326,7 +330,7 @@ function deletePermission(p) {
const affects = parts.join(', ')
openDialog('confirm', { message: `Delete permission "${p.display_name}" (${affects})?`, action: async () => {
await performPermissionDeletion(p.id)
await performPermissionDeletion(p.scope)
} })
}
@@ -626,21 +630,24 @@ async function submitDialog() {
return // Don't call closeDialog() again
} else if (t === 'perm-display') {
const { permission } = dialog.value.data
const newId = dialog.value.data.id?.trim()
const newId = dialog.value.data.scope?.trim()
const newDisplay = dialog.value.data.display_name?.trim()
const newDomain = dialog.value.data.domain?.trim() || ''
if (!newDisplay) throw new Error('Display name required')
if (!newId) throw new Error('ID required')
if (!newId) throw new Error('Scope required')
// Close dialog immediately, then perform async operation
closeDialog()
const oldDomain = permission.domain || ''
let apiCall;
if (newId !== permission.id) {
// ID changed, use rename endpoint
apiCall = apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_id: permission.id, new_id: newId, display_name: newDisplay } })
} else if (newDisplay !== permission.display_name) {
// Only display name changed
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
if (newId !== permission.scope) {
// Scope changed, use rename endpoint (also update domain)
apiCall = apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_scope: permission.scope, new_scope: newId, display_name: newDisplay, domain: newDomain } })
} else if (newDisplay !== permission.display_name || newDomain !== oldDomain) {
// Display name or domain changed
const params = new URLSearchParams({ permission_id: permission.scope, display_name: newDisplay })
if (newDomain) params.set('domain', newDomain)
apiCall = apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' })
} else {
// No changes
@@ -655,13 +662,15 @@ async function submitDialog() {
.catch(e => {
authStore.showMessage(e.message || 'Failed to update permission', 'error')
})
return // Don't call closeDialog() again else if (t === 'perm-create') {
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
return // Don't call closeDialog() again
} else if (t === 'perm-create') {
const scope = dialog.value.data.scope?.trim(); if (!scope) throw new Error('Scope required')
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
const domain = dialog.value.data.domain?.trim() || ''
// Close dialog immediately, then perform async operation
closeDialog()
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { id, display_name } })
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
.then(() => {
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
loadPermissions()
@@ -689,7 +698,7 @@ async function submitDialog() {
v-else-if="showBackMessage"
@reload="reloadPage"
/>
<section v-else-if="authenticated && (info?.is_global_admin || info?.is_org_admin)" class="view-root view-root--wide view-admin">
<section v-else-if="authenticated && (isGlobalAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
@@ -700,7 +709,7 @@ async function submitDialog() {
<div v-if="error" class="surface surface--tight error">{{ error }}</div>
<div v-else class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
v-if="!selectedUser && !selectedOrg && (isGlobalAdmin || isOrgAdmin)"
ref="adminOverviewRef"
:info="info"
:orgs="orgs"
+8 -3
View File
@@ -72,10 +72,14 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
<label>Display Name
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label>
<label>Permission ID
<input v-model="dialog.data.id" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
<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" />
</label>
<p class="small muted">The permission ID is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p>
<label>Domain Scope <span class="optional">(optional)</span>
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
</label>
<p class="small muted">If set, this permission only applies when accessed from the specified domain. Must be the RP ID or a subdomain of it.</p>
<p class="small muted">The permission scope is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
@@ -106,4 +110,5 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
</style>
+2 -2
View File
@@ -26,8 +26,8 @@ const sortedRoles = computed(() => {
})
})
function permissionDisplayName(id) {
return props.permissions.find(p => p.id === id)?.display_name || id
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function toggleRolePermission(role, pid, checked) {
+29 -22
View File
@@ -24,10 +24,14 @@ const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
}))
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.id.localeCompare(b.id)))
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
function permissionDisplayName(id) {
return props.permissions.find(p => p.id === id)?.display_name || id
// Derive admin status from permissions
const isGlobalAdmin = computed(() => props.info?.permissions?.includes('auth:admin') ?? false)
const isOrgAdmin = computed(() => props.info?.permissions?.includes('auth:org:admin') ?? false)
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function getRoleNames(org) {
@@ -89,7 +93,7 @@ function handleTableKeydown(event, tableType) {
} else if (direction === 'down' && currentIndex === rows.length - 1) {
// At bottom of org table, navigate to permissions section
event.preventDefault()
if (tableType === 'org' && props.info.is_global_admin) {
if (tableType === 'org' && isGlobalAdmin.value) {
// Navigate to permissions matrix or actions
if (permMatrixRef.value) {
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
@@ -232,7 +236,7 @@ function handlePermActionsKeydown(event) {
// Focus helper for external navigation
function focusFirstElement() {
if (props.info.is_global_admin) {
if (isGlobalAdmin.value) {
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
} else {
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
@@ -245,9 +249,9 @@ defineExpose({ focusFirstElement })
<template>
<div class="permissions-section" ref="orgSection">
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
<h2>{{ isGlobalAdmin ? 'Organizations' : 'Your Organizations' }}</h2>
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
<button v-if="isGlobalAdmin" @click="$emit('createOrg')">+ Create Org</button>
</div>
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
<thead>
@@ -255,18 +259,18 @@ defineExpose({ focusFirstElement })
<th>Name</th>
<th>Roles</th>
<th>Members</th>
<th v-if="info.is_global_admin">Actions</th>
<th v-if="isGlobalAdmin">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid">
<td>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
<button v-if="info.is_global_admin || info.is_org_admin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
<button v-if="isGlobalAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
</td>
<td class="role-names">{{ getRoleNames(o) }}</td>
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
<td v-if="info.is_global_admin" class="center">
<td v-if="isGlobalAdmin" class="center">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
</tr>
@@ -274,7 +278,7 @@ defineExpose({ focusFirstElement })
</table>
</div>
<div v-if="info.is_global_admin" class="permissions-section">
<div v-if="isGlobalAdmin" class="permissions-section">
<h2>Permissions</h2>
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
<div class="matrix-scroll">
@@ -292,19 +296,19 @@ defineExpose({ focusFirstElement })
<span>{{ o.display_name }}</span>
</div>
<template v-for="p in sortedPermissions" :key="p.id">
<div class="perm-name" :title="p.id">
<template v-for="p in sortedPermissions" :key="p.scope">
<div class="perm-name" :title="p.scope">
<span class="display-text">{{ p.display_name }}</span>
</div>
<div
v-for="o in sortedOrgs"
:key="o.uuid + '-' + p.id"
:key="o.uuid + '-' + p.scope"
class="matrix-cell"
>
<input
type="checkbox"
:checked="o.permissions.includes(p.id)"
@change="e => $emit('toggleOrgPermission', o, p.id, e.target.checked)"
:checked="o.permissions.includes(p.scope)"
@change="e => $emit('toggleOrgPermission', o, p.scope, e.target.checked)"
/>
</div>
</template>
@@ -313,28 +317,30 @@ defineExpose({ focusFirstElement })
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
</div>
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button>
<button v-if="isGlobalAdmin" @click="$emit('openDialog', 'perm-create', { display_name: '', scope: '', domain: '' })">+ Create Permission</button>
</div>
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
<thead>
<tr>
<th scope="col">Permission</th>
<th scope="col">Domain</th>
<th scope="col" class="center">Members</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="p in sortedPermissions" :key="p.id">
<tr v-for="p in sortedPermissions" :key="p.scope">
<td class="perm-name-cell">
<div class="perm-title">
<span class="display-text">{{ p.display_name }}</span>
<button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-display-btn" aria-label="Edit display name" title="Edit display name"></button>
<button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-display-btn" aria-label="Edit permission" title="Edit permission"></button>
</div>
<div class="perm-id-info">
<span class="id-text">{{ p.id }}</span>
<span class="id-text">{{ p.scope }}</span>
</div>
</td>
<td class="perm-members center">{{ permissionSummary[p.id]?.userCount || 0 }}</td>
<td class="perm-domain">{{ p.domain || '—' }}</td>
<td class="perm-members center">{{ permissionSummary[p.scope]?.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>
</td>
@@ -355,7 +361,8 @@ defineExpose({ focusFirstElement })
.org-table .role-names { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
.perm-title { font-weight: 600; color: var(--color-heading); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.delete-icon { color: var(--color-danger); }
+4 -1
View File
@@ -324,7 +324,10 @@ const terminateSession = async (session) => {
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const logout = async () => { await authStore.logout() }
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
const isAdmin = computed(() => {
const perms = authStore.userInfo?.permissions ?? []
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })