- Domain dialog submits one origins map (in-domain + related together);
classification is derived, the submit-time split is gone
- Placeholder-row machinery deleted: an empty list now means 'nothing
allowed'; new domains get a real pre-filled '*.{rp-id}' row that
follows rp-id edits until touched
- Plain '*' is invalid; wildcards only within the domain
- Editing the domain in use: when no auth host is marked and the admin's
current page origin would no longer be allowed to run ceremonies, Save
is disabled with an explanatory error (mirrors the backend guard)
- Origin list display: single table with derived related badges; '*'
sort special case removed
1084 lines
38 KiB
Vue
1084 lines
38 KiB
Vue
<script setup>
|
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
|
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
|
import CredentialList from '@/components/CredentialList.vue'
|
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
|
import StatusMessage from '@/components/StatusMessage.vue'
|
|
import LoadingView from '@/components/LoadingView.vue'
|
|
import AccessDenied from '@/components/AccessDenied.vue'
|
|
import AdminOverview from '@/admin/AdminOverview.vue'
|
|
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
|
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
|
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
|
|
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
|
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
|
import { updateThemeFromSession } from '@/utils/theme'
|
|
import { uuidv7 } from 'uuidv7'
|
|
import { getDirection } from '@/utils/keynav'
|
|
import { originDisplayEntries } from '@/utils/helpers'
|
|
|
|
const info = ref(null)
|
|
const loading = ref(true)
|
|
const loadingMessage = ref('Loading...')
|
|
const authenticated = ref(false)
|
|
const showBackMessage = ref(false)
|
|
const error = ref(null)
|
|
const orgs = ref([])
|
|
const permissions = ref([])
|
|
const oidcClients = ref([])
|
|
const domains = ref([])
|
|
const currentOrgId = ref(null) // UUID of selected org for detail view
|
|
const currentUserId = ref(null) // UUID for user detail view
|
|
const currentOidcId = ref(null) // UUID for OIDC client detail view
|
|
const userDetail = ref(null) // cached user detail object
|
|
const editingOidcClient = ref(null) // OIDC client being edited (with local changes)
|
|
const authStore = useAuthStore()
|
|
const addingOrgForPermission = ref(null)
|
|
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
|
const editingPermId = ref(null)
|
|
const renameIdValue = ref('')
|
|
const editingPermDisplay = ref(null)
|
|
const renameDisplayValue = ref('')
|
|
const dialog = ref({ type: null, data: null, busy: false, error: '' })
|
|
const dialogPreviousFocus = ref(null) // Track element that had focus before dialog opened
|
|
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
|
|
|
|
// Template refs for navigation
|
|
const breadcrumbsRef = ref(null)
|
|
const adminOverviewRef = ref(null)
|
|
const adminOrgDetailRef = ref(null)
|
|
const adminUserDetailRef = ref(null)
|
|
const adminOidcDetailRef = ref(null)
|
|
|
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
|
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
|
|
|
// Derive admin status from permissions
|
|
const isMasterAdmin = computed(() => info.value?.ctx.permissions.includes('auth:admin'))
|
|
const isOrgAdmin = computed(() => info.value?.ctx.permissions.includes('auth:org:admin'))
|
|
|
|
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
|
|
|
function handleGlobalClick(e) {
|
|
if (!addingOrgForPermission.value) return
|
|
const menu = e.target.closest('.org-add-menu')
|
|
const trigger = e.target.closest('.add-org-btn')
|
|
if (!menu && !trigger) {
|
|
addingOrgForPermission.value = null
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
document.addEventListener('click', handleGlobalClick)
|
|
window.addEventListener('hashchange', parseHash)
|
|
await authStore.loadSettings()
|
|
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
|
await load()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', handleGlobalClick)
|
|
window.removeEventListener('hashchange', parseHash)
|
|
})
|
|
|
|
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
|
|
const permissionSummary = computed(() => {
|
|
const summary = {}
|
|
for (const o of orgs.value) {
|
|
const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
|
|
// o.permissions is a dict[UUID, Permission]
|
|
const orgPermUuids = new Set(Object.keys(o.permissions || {}))
|
|
|
|
// Org-level permissions (direct) - only count if org can grant them
|
|
for (const pid of Object.keys(o.permissions || {})) {
|
|
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
|
if (!summary[pid].orgSet.has(o.uuid)) {
|
|
summary[pid].orgs.push(orgBase)
|
|
summary[pid].orgSet.add(o.uuid)
|
|
}
|
|
}
|
|
|
|
// Role-based permissions (inheritance) - only count if org can grant them
|
|
for (const [roleUuid, r] of Object.entries(o.roles || {})) {
|
|
// r.permissions is dict[UUID, bool]
|
|
for (const pid of Object.keys(r.permissions || {})) {
|
|
// Only count if the org can grant this permission
|
|
if (!orgPermUuids.has(pid)) continue
|
|
|
|
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
|
if (!summary[pid].orgSet.has(o.uuid)) {
|
|
summary[pid].orgs.push(orgBase)
|
|
summary[pid].orgSet.add(o.uuid)
|
|
}
|
|
summary[pid].userCount += roleUserCount(o, roleUuid)
|
|
}
|
|
}
|
|
}
|
|
const display = {}
|
|
for (const [pid, v] of Object.entries(summary)) {
|
|
display[pid] = { orgs: v.orgs.sort((a,b)=>a.display_name.localeCompare(b.display_name)), userCount: v.userCount }
|
|
}
|
|
return display
|
|
})
|
|
|
|
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, scope: p.scope, display_name: p.display_name, domain: p.domain || '' }) }
|
|
|
|
|
|
function parseHash() {
|
|
const h = window.location.hash || ''
|
|
currentOrgId.value = null
|
|
currentUserId.value = null
|
|
currentOidcId.value = null
|
|
editingOidcClient.value = null
|
|
if (h.startsWith('#org/')) {
|
|
currentOrgId.value = h.slice(5)
|
|
} else if (h.startsWith('#user/')) {
|
|
currentUserId.value = h.slice(6)
|
|
} else if (h.startsWith('#oidc:')) {
|
|
const oidcUuid = h.slice(6)
|
|
currentOidcId.value = oidcUuid
|
|
// Initialize editing client data
|
|
if (oidcUuid === 'new') {
|
|
// Generate client_id and secret for new client
|
|
const bytes = new Uint8Array(32)
|
|
crypto.getRandomValues(bytes)
|
|
const client_secret = btoa(String.fromCharCode(...bytes))
|
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
editingOidcClient.value = {
|
|
client_id: uuidv7(),
|
|
client_secret,
|
|
isNew: true,
|
|
name: '',
|
|
redirect_uris: []
|
|
}
|
|
} else {
|
|
const client = oidcClients.value.find(c => c.uuid === oidcUuid)
|
|
if (client) {
|
|
editingOidcClient.value = {
|
|
...client,
|
|
client_id: client.uuid,
|
|
client_secret: null,
|
|
isNew: false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadAdminData() {
|
|
const data = await apiJson('/auth/api/admin/info')
|
|
// Convert dicts to arrays with uuid added
|
|
orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
|
|
permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
|
|
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
|
|
}
|
|
|
|
// Domain list is master-admin only; callers guard on isMasterAdmin
|
|
async function loadDomains() {
|
|
try {
|
|
domains.value = await apiJson('/auth/api/admin/domains/')
|
|
} catch (e) {
|
|
console.warn('Unable to load domains', e)
|
|
domains.value = []
|
|
}
|
|
}
|
|
|
|
// Helper to get users for a role as sorted array of [uuid, user]
|
|
function roleUsers(org, roleUuid) {
|
|
return Object.entries(org.users)
|
|
.filter(([_, u]) => u.role === roleUuid)
|
|
.sort(([, a], [, b]) => {
|
|
const nameA = a.display_name.toLowerCase()
|
|
const nameB = b.display_name.toLowerCase()
|
|
return nameA.localeCompare(nameB)
|
|
})
|
|
}
|
|
|
|
// Helper to count users in a role
|
|
function roleUserCount(org, roleUuid) {
|
|
return Object.values(org.users).filter(u => u.role === roleUuid).length
|
|
}
|
|
|
|
// Helper to count total users in an org
|
|
function orgUserCount(org) {
|
|
return Object.keys(org.users).length
|
|
}
|
|
|
|
async function loadUserInfo() {
|
|
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
|
info.value = data
|
|
updateThemeFromSession(data.ctx)
|
|
authenticated.value = true
|
|
}
|
|
|
|
function clearSensitiveState() {
|
|
info.value = null
|
|
orgs.value = []
|
|
permissions.value = []
|
|
oidcClients.value = []
|
|
domains.value = []
|
|
userDetail.value = null
|
|
editingOidcClient.value = null
|
|
authenticated.value = false
|
|
}
|
|
|
|
function onSessionLost(e) {
|
|
clearSensitiveState()
|
|
if (e.name === 'AuthCancelledError') {
|
|
showBackMessage.value = true
|
|
} else {
|
|
error.value = e.message
|
|
}
|
|
}
|
|
|
|
const userUuidGetter = () => info.value?.ctx.user.uuid
|
|
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
|
|
|
onMounted(() => sessionValidator.start())
|
|
onUnmounted(() => sessionValidator.stop())
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
loadingMessage.value = 'Loading...'
|
|
error.value = null
|
|
try {
|
|
// Load admin data first - apiJson will handle 401/403 with iframe authentication
|
|
await loadAdminData()
|
|
// If we get here, user has admin access - now fetch user info for display
|
|
await loadUserInfo()
|
|
if (isMasterAdmin.value) await loadDomains()
|
|
|
|
if (!isMasterAdmin.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}`
|
|
} else {
|
|
parseHash()
|
|
}
|
|
} else parseHash()
|
|
} catch (e) {
|
|
onSessionLost(e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Org actions
|
|
function createOrg() { openDialog('org-create', {}) }
|
|
|
|
function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) }
|
|
|
|
function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) }
|
|
|
|
async function performOrgDeletion(orgUuid) {
|
|
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
|
|
await Promise.all([loadAdminData()])
|
|
}
|
|
|
|
function deleteOrg(org) {
|
|
const userCount = orgUserCount(org)
|
|
|
|
if (userCount === 0) {
|
|
// No users in the organization, safe to delete directly
|
|
performOrgDeletion(org.uuid)
|
|
.then(() => {
|
|
authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
|
|
})
|
|
return
|
|
}
|
|
|
|
// Build detailed breakdown of users by role
|
|
const roleParts = Object.entries(org.roles)
|
|
.map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
|
|
.filter(x => x.count > 0)
|
|
.map(x => `${x.count} ${x.role.display_name}`)
|
|
|
|
const affects = roleParts.join(', ')
|
|
|
|
openDialog('confirm', { message: `Delete organization "${org.org.display_name}", including accounts of ${affects})?`, action: async () => {
|
|
await performOrgDeletion(org.uuid)
|
|
} })
|
|
}
|
|
|
|
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
|
|
|
function deleteUser(user, userDetail) {
|
|
const credentialCount = userDetail?.credentials ? Object.keys(userDetail.credentials).length : 0
|
|
const userUuid = user.uuid
|
|
const userName = user.display_name
|
|
const orgUuid = user.org // org UUID is stored in selectedUser
|
|
|
|
if (credentialCount === 0) {
|
|
// No credentials, safe to delete directly
|
|
performUserDeletion(userUuid, userName, orgUuid)
|
|
return
|
|
}
|
|
|
|
const passkeys = credentialCount === 1 ? '1 passkey' : `${credentialCount} passkeys`
|
|
openDialog('confirm', {
|
|
message: `Delete user "${userName}" with ${passkeys}? This action cannot be undone.`,
|
|
action: async () => {
|
|
await performUserDeletion(userUuid, userName, orgUuid)
|
|
}
|
|
})
|
|
}
|
|
|
|
async function performUserDeletion(userUuid, userName, orgUuid) {
|
|
try {
|
|
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
|
|
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
|
|
await loadAdminData()
|
|
window.location.hash = `#org/${orgUuid}`
|
|
} catch (e) {
|
|
authStore.showMessage(e.message || 'Failed to delete user', 'error')
|
|
}
|
|
}
|
|
|
|
async function moveUserToRole(userUuid, user, targetRoleUuid) {
|
|
if (user.role === targetRoleUuid) return
|
|
try {
|
|
await apiJson(`/auth/api/admin/users/${userUuid}/role`, {
|
|
method: 'PATCH',
|
|
body: { role_uuid: targetRoleUuid }
|
|
})
|
|
await loadAdminData()
|
|
} catch (e) {
|
|
authStore.showMessage(e.message || 'Failed to update user role')
|
|
}
|
|
}
|
|
|
|
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
|
|
const user = selectedOrg.value?.users?.[userUuid]
|
|
if (user) moveUserToRole(userUuid, user, newRoleUuid)
|
|
}
|
|
|
|
// Role actions
|
|
function createRole(org) { openDialog('role-create', { org }) }
|
|
|
|
function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) }
|
|
|
|
function deleteRole(role) {
|
|
// UI only allows deleting empty roles, so no confirmation needed
|
|
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
|
|
.then(() => {
|
|
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
|
loadAdminData()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to delete role', 'error')
|
|
})
|
|
}
|
|
|
|
async function toggleRolePermission(role, pid, checked) {
|
|
// Optimistic update - role.permissions is dict[UUID, bool]
|
|
const prevPermissions = { ...role.permissions }
|
|
const newPermissions = { ...role.permissions }
|
|
if (checked) {
|
|
newPermissions[pid] = true
|
|
} else {
|
|
delete newPermissions[pid]
|
|
}
|
|
role.permissions = newPermissions
|
|
|
|
try {
|
|
const method = checked ? 'POST' : 'DELETE'
|
|
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
|
|
method
|
|
})
|
|
await loadAdminData()
|
|
} catch (e) {
|
|
authStore.showMessage(e.message || 'Failed to update role permission')
|
|
role.permissions = prevPermissions // revert
|
|
}
|
|
}
|
|
|
|
// Permission actions
|
|
async function performPermissionDeletion(permissionUuid) {
|
|
const params = new URLSearchParams({ permission_uuid: permissionUuid })
|
|
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
|
await loadAdminData()
|
|
}
|
|
|
|
function deletePermission(p) {
|
|
const userCount = permissionSummary.value[p.uuid]?.userCount || 0
|
|
|
|
// Count roles that have this permission
|
|
let roleCount = 0
|
|
for (const org of orgs.value) {
|
|
for (const role of Object.values(org.roles)) {
|
|
if (p.uuid in (role.permissions || {})) {
|
|
roleCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if (roleCount === 0) {
|
|
// No roles have this permission, safe to delete directly
|
|
performPermissionDeletion(p.uuid)
|
|
.then(() => {
|
|
authStore.showMessage(`Permission "${p.display_name}" deleted.`, 'success', 2500)
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to delete permission', 'error')
|
|
})
|
|
return
|
|
}
|
|
|
|
const parts = []
|
|
if (roleCount > 0) parts.push(`${roleCount} role${roleCount !== 1 ? 's' : ''}`)
|
|
if (userCount > 0) parts.push(`${userCount} user${userCount !== 1 ? 's' : ''}`)
|
|
const affects = parts.join(', ')
|
|
|
|
openDialog('confirm', { message: `Delete permission "${p.display_name}" (${affects})?`, action: async () => {
|
|
await performPermissionDeletion(p.uuid)
|
|
} })
|
|
}
|
|
|
|
// OIDC Client actions
|
|
async function sha256Hex(text) {
|
|
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
|
return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
}
|
|
|
|
function createOidcClient() {
|
|
// Navigate to new OIDC client page
|
|
window.location.hash = '#oidc:new'
|
|
}
|
|
|
|
function openOidcClient(client) {
|
|
// Navigate to OIDC client detail page
|
|
window.location.hash = `#oidc:${client.uuid}`
|
|
}
|
|
|
|
function resetOidcSecret(clientId) {
|
|
// Generate new secret locally; it will be sent to server on Save
|
|
const bytes = new Uint8Array(32)
|
|
crypto.getRandomValues(bytes)
|
|
const client_secret = btoa(String.fromCharCode(...bytes))
|
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
// Update editingOidcClient if we're on the detail page
|
|
if (editingOidcClient.value?.client_id === clientId) {
|
|
editingOidcClient.value = { ...editingOidcClient.value, client_secret }
|
|
}
|
|
}
|
|
|
|
function createPermissionForClient(clientId) {
|
|
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
|
|
}
|
|
|
|
function createDomain() {
|
|
openDialog('domain-edit', {
|
|
isNew: true,
|
|
rp_id: '',
|
|
rp_name: '',
|
|
auth_host: '',
|
|
origins: [],
|
|
originValidation: [],
|
|
wellKnownCheck: null,
|
|
})
|
|
}
|
|
|
|
function openDomain(domain) {
|
|
// One combined list for editing, in display order: in-domain sites and
|
|
// related origins, classified by hostname against the rp-id.
|
|
const rows = originDisplayEntries(domain)
|
|
openDialog('domain-edit', {
|
|
isNew: false,
|
|
rp_id: domain.rp_id,
|
|
rp_name: domain.rp_name || '',
|
|
auth_host: rows.find(r => r.auth)?.key || '',
|
|
origins: rows.map(r => r.key),
|
|
originValidation: rows.map(() => null),
|
|
wellKnownCheck: null,
|
|
})
|
|
}
|
|
|
|
function deleteDomain(domain) {
|
|
openDialog('confirm', {
|
|
message: `Delete domain "${domain.rp_id}"? This is refused while any passkeys remain registered for it.`,
|
|
action: async () => {
|
|
await apiJson(`/auth/api/admin/domains/${domain.rp_id}`, { method: 'DELETE' })
|
|
authStore.showMessage(`Domain "${domain.rp_id}" deleted.`, 'success', 2500)
|
|
await loadDomains()
|
|
}
|
|
})
|
|
}
|
|
|
|
function deleteOidcClient(client) {
|
|
openDialog('confirm', {
|
|
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
|
|
action: async () => {
|
|
await performOidcClientDeletion(client.uuid, client.name)
|
|
// Navigate back to overview if we were on the detail page
|
|
if (currentOidcId.value === client.uuid) {
|
|
window.location.hash = '#overview'
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async function performOidcClientDeletion(clientUuid, clientName) {
|
|
await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' })
|
|
authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500)
|
|
await loadAdminData()
|
|
}
|
|
|
|
async function handleOidcSave(data) {
|
|
const { client_id, client_secret, name, redirect_uris, isNew } = data
|
|
|
|
try {
|
|
if (client_secret) {
|
|
const secret_hash = await sha256Hex(client_secret)
|
|
if (isNew) {
|
|
await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
|
|
} else {
|
|
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })
|
|
}
|
|
} else {
|
|
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
|
|
}
|
|
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
|
await loadAdminData()
|
|
window.location.hash = '#overview'
|
|
} catch (e) {
|
|
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
|
}
|
|
}
|
|
|
|
function handleOidcCancel() {
|
|
goOverview()
|
|
}
|
|
|
|
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
|
|
|
function openOrg(o) {
|
|
window.location.hash = `#org/${o.uuid}`
|
|
}
|
|
|
|
function goOverview() {
|
|
window.location.hash = '#overview'
|
|
}
|
|
|
|
function openUser(u) {
|
|
window.location.hash = `#user/${u.uuid}`
|
|
}
|
|
|
|
const selectedUser = computed(() => {
|
|
if (!currentUserId.value) return null
|
|
for (const o of orgs.value) {
|
|
const u = o.users[currentUserId.value]
|
|
if (u) {
|
|
const role = o.roles[u.role]
|
|
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
|
|
// Breadcrumb entries for admin app.
|
|
const breadcrumbEntries = computed(() => {
|
|
const entries = [
|
|
{ label: 'My Profile', href: makeUiHref() }
|
|
]
|
|
// For org admins, combine Admin and their org
|
|
if (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) {
|
|
const org = orgs.value[0]
|
|
entries.push({ label: `Admin: ${org.org.display_name}`, href: `#org/${org.uuid}` })
|
|
} else {
|
|
entries.push({ label: 'Admin', href: adminUiPath() })
|
|
}
|
|
// Determine organization for user view if selectedOrg not explicitly chosen.
|
|
let orgForUser = null
|
|
if (selectedUser.value) {
|
|
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
|
|
}
|
|
const orgToShow = selectedOrg.value || orgForUser
|
|
// Add org breadcrumb only if it's not already included in the Admin entry
|
|
const adminOrg = (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) ? orgs.value[0] : null
|
|
if (orgToShow && (!adminOrg || orgToShow.uuid !== adminOrg.uuid)) {
|
|
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
|
|
}
|
|
if (currentOidcId.value) {
|
|
const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client')
|
|
entries.push({ label, href: `#oidc:${currentOidcId.value}` })
|
|
}
|
|
if (selectedUser.value) {
|
|
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
|
|
}
|
|
return entries
|
|
})
|
|
|
|
watch(selectedUser, async (u) => {
|
|
if (!u) { userDetail.value = null; return }
|
|
try {
|
|
userDetail.value = await apiJson(`/auth/api/admin/users/${u.uuid}`)
|
|
} catch (e) {
|
|
userDetail.value = { error: e.message }
|
|
}
|
|
})
|
|
|
|
const showRegModal = ref(false)
|
|
function generateUserRegistrationLink(u) {
|
|
showRegModal.value = true
|
|
}
|
|
|
|
async function toggleOrgPermission(org, permId, checked) {
|
|
// org.permissions is dict[UUID, Permission]
|
|
const has = permId in org.permissions
|
|
if (checked && has) return
|
|
if (!checked && !has) return
|
|
// Optimistic update
|
|
const prev = { ...org.permissions }
|
|
if (checked) {
|
|
// Need to fetch the permission object to add it
|
|
const perm = permissions.value.find(p => p.uuid === permId)
|
|
if (perm) {
|
|
org.permissions = { ...org.permissions, [permId]: perm }
|
|
}
|
|
} else {
|
|
const next = { ...org.permissions }
|
|
delete next[permId]
|
|
org.permissions = next
|
|
}
|
|
try {
|
|
const params = new URLSearchParams({ permission_uuid: permId })
|
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
|
await loadAdminData()
|
|
} catch (e) {
|
|
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
|
|
org.permissions = prev // revert
|
|
}
|
|
}
|
|
|
|
function openDialog(type, data) {
|
|
const focused = document.activeElement
|
|
dialogPreviousFocus.value = focused
|
|
|
|
// For delete operations, store sibling info to help restore focus after deletion
|
|
if (type === 'confirm' && focused) {
|
|
const row = focused.closest('tr')
|
|
if (row) {
|
|
const tbody = row.closest('tbody')
|
|
if (tbody) {
|
|
const rows = Array.from(tbody.querySelectorAll('tr'))
|
|
const idx = rows.indexOf(row)
|
|
// Store context to find next/prev row after deletion
|
|
dialog.value.focusContext = {
|
|
tbody,
|
|
index: idx,
|
|
total: rows.length,
|
|
selector: 'button:not([disabled]), a'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dialog.value = { ...dialog.value, type, data, busy: false, error: '' }
|
|
}
|
|
|
|
function closeDialog() {
|
|
const prev = dialogPreviousFocus.value
|
|
const context = dialog.value.focusContext
|
|
dialog.value = { type: null, data: null, busy: false, error: '' }
|
|
// Restore focus after dialog closes
|
|
restoreFocusAfterDialog(prev, context)
|
|
dialogPreviousFocus.value = null
|
|
}
|
|
|
|
/**
|
|
* Restore focus to the previously focused element, or find a sibling if deleted.
|
|
*/
|
|
function restoreFocusAfterDialog(prev, context) {
|
|
if (!prev) return
|
|
|
|
// Check if the original element still exists in DOM and is focusable
|
|
if (document.body.contains(prev) && !prev.disabled) {
|
|
prev.focus()
|
|
return
|
|
}
|
|
|
|
// Element was deleted - try to find a sibling using stored context
|
|
if (context?.tbody && context.selector) {
|
|
const rows = Array.from(context.tbody.querySelectorAll('tr'))
|
|
if (rows.length > 0) {
|
|
// Try the same index (next row moved up) or the last row
|
|
const targetIdx = Math.min(context.index, rows.length - 1)
|
|
const targetRow = rows[targetIdx]
|
|
const focusable = targetRow?.querySelector(context.selector)
|
|
if (focusable) {
|
|
focusable.focus()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: try to find any focusable element in the admin panels
|
|
const container = document.querySelector('.admin-panels')
|
|
if (!container) return
|
|
|
|
const focusable = container.querySelector('button:not([disabled]), a, input:not([disabled]), [tabindex="0"]')
|
|
if (focusable) {
|
|
focusable.focus()
|
|
}
|
|
}
|
|
|
|
// Keyboard navigation handlers
|
|
function handleBreadcrumbKeydown(event) {
|
|
if (hasActiveModal.value) return
|
|
|
|
const direction = getDirection(event)
|
|
if (!direction) return
|
|
|
|
// Left/right handled internally by Breadcrumbs component
|
|
if (direction === 'down') {
|
|
event.preventDefault()
|
|
// Move to admin panel content
|
|
if (adminOverviewRef.value) {
|
|
adminOverviewRef.value.focusFirstElement?.()
|
|
} else if (adminOrgDetailRef.value) {
|
|
adminOrgDetailRef.value.focusFirstElement?.()
|
|
} else if (adminUserDetailRef.value) {
|
|
adminUserDetailRef.value.focusFirstElement?.()
|
|
}
|
|
}
|
|
}
|
|
|
|
function handlePanelNavigateOut(direction) {
|
|
if (hasActiveModal.value) return
|
|
|
|
if (direction === 'up') {
|
|
// Focus breadcrumbs - focus the current page's crumb
|
|
breadcrumbsRef.value?.focusCurrent?.()
|
|
}
|
|
}
|
|
|
|
async function refreshUserDetail() {
|
|
await loadAdminData()
|
|
if (selectedUser.value) {
|
|
try {
|
|
userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
|
|
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function submitDialog() {
|
|
if (!dialog.value.type || dialog.value.busy) return
|
|
dialog.value.busy = true; dialog.value.error = ''
|
|
try {
|
|
const t = dialog.value.type
|
|
if (t === 'org-create') {
|
|
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
|
|
|
// Close dialog immediately, then perform async operation
|
|
closeDialog()
|
|
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
|
.then(() => {
|
|
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
|
|
loadAdminData()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to create organization', 'error')
|
|
})
|
|
return // Don't call closeDialog() again
|
|
} else if (t === 'org-update') {
|
|
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
|
|
|
// Close dialog immediately, then perform async operation
|
|
closeDialog()
|
|
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
|
.then(() => {
|
|
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
|
|
loadAdminData()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to update organization', 'error')
|
|
})
|
|
return // Don't call closeDialog() again
|
|
} else if (t === 'role-create') {
|
|
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
|
|
|
// Close dialog immediately, then perform async operation
|
|
closeDialog()
|
|
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
|
.then(() => {
|
|
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
|
|
loadAdminData()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to create role', 'error')
|
|
})
|
|
return // Don't call closeDialog() again
|
|
} else if (t === 'role-update') {
|
|
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
|
|
|
// Close dialog immediately, then perform async operation
|
|
closeDialog()
|
|
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
|
.then(() => {
|
|
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
|
loadAdminData()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
|
})
|
|
return // Don't call closeDialog() again
|
|
} else if (t === 'user-create') {
|
|
const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
|
|
|
// Close dialog immediately, then perform async operation
|
|
closeDialog()
|
|
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
|
.then(() => {
|
|
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
|
|
loadAdminData()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to add user', 'error')
|
|
})
|
|
return // Don't call closeDialog() again
|
|
} else if (t === 'user-update-name') {
|
|
const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
|
|
|
// Close dialog immediately, then perform async operation
|
|
closeDialog()
|
|
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
|
|
.then(() => {
|
|
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
|
refreshUserDetail()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to update user name', 'error')
|
|
})
|
|
return // Don't call closeDialog() again
|
|
} else if (t === 'perm-display') {
|
|
const { permission } = dialog.value.data
|
|
const newScope = 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 (!newScope) throw new Error('Scope required')
|
|
|
|
// Close dialog immediately, then perform async operation
|
|
closeDialog()
|
|
|
|
const oldDomain = permission.domain || ''
|
|
// Check if anything changed
|
|
if (newScope === permission.scope && newDisplay === permission.display_name && newDomain === oldDomain) {
|
|
return // No changes
|
|
}
|
|
|
|
// Always use PATCH with permission_uuid
|
|
const params = new URLSearchParams({ permission_uuid: permission.uuid })
|
|
if (newScope !== permission.scope) params.set('scope', newScope)
|
|
if (newDisplay !== permission.display_name) params.set('display_name', newDisplay)
|
|
if (newDomain !== oldDomain) params.set('domain', newDomain || '')
|
|
|
|
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
|
|
.then(() => {
|
|
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
|
|
loadAdminData()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to update permission', 'error')
|
|
})
|
|
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: { scope, display_name, domain: domain || undefined } })
|
|
.then(() => {
|
|
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
|
|
loadAdminData()
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
|
})
|
|
return // Don't call closeDialog() again
|
|
} else if (t === 'domain-edit') {
|
|
const d = dialog.value.data
|
|
const rp_id = d.rp_id?.trim().toLowerCase()
|
|
if (!rp_id) throw new Error('Domain (rp-id) required')
|
|
const rp_name = d.rp_name?.trim() || ''
|
|
const auth_host = d.auth_host?.trim().toLowerCase() || ''
|
|
// One origins object holds in-domain sites and related origins
|
|
// (ROR) together; the server classifies each key against the rp-id.
|
|
// Keys are stored lowercased, without the https:// scheme.
|
|
const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase()
|
|
const origins = {}
|
|
for (const o of d.origins || []) {
|
|
const key = keyOf(o.trim())
|
|
if (!key) continue
|
|
origins[key] = key === auth_host ? { auth_host: true } : true
|
|
}
|
|
|
|
closeDialog()
|
|
const req = d.isNew
|
|
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins } })
|
|
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } })
|
|
req
|
|
.then(() => {
|
|
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
|
loadDomains()
|
|
// Reload settings to reflect rp_name changes
|
|
authStore.loadSettings(true).then(() => {
|
|
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
|
})
|
|
})
|
|
.catch(e => {
|
|
authStore.showMessage(e.message || 'Failed to save domain', 'error')
|
|
})
|
|
return // Don't call closeDialog() again
|
|
} else if (t === 'confirm') {
|
|
const action = dialog.value.data.action
|
|
// Close dialog first, then perform action (errors shown via showMessage)
|
|
closeDialog()
|
|
if (action) {
|
|
try {
|
|
await action()
|
|
} catch (e) {
|
|
authStore.showMessage(e.message || 'Action failed', 'error')
|
|
}
|
|
}
|
|
return // Already closed
|
|
}
|
|
closeDialog()
|
|
} catch (e) {
|
|
dialog.value.error = e.message || 'Error'
|
|
} finally { dialog.value.busy = false }
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="app-shell admin-shell">
|
|
<StatusMessage />
|
|
<main class="app-main">
|
|
<LoadingView v-if="loading" :message="loadingMessage" />
|
|
<AccessDenied v-else-if="showBackMessage" />
|
|
<AccessDenied
|
|
v-else-if="error"
|
|
icon="⚠️"
|
|
title="Error"
|
|
:message="error"
|
|
/>
|
|
<AccessDenied
|
|
v-else-if="authenticated && !isMasterAdmin && !isOrgAdmin"
|
|
icon="⛔"
|
|
message="You do not have admin permissions for this application."
|
|
/>
|
|
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
|
<header class="view-header">
|
|
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
|
</header>
|
|
|
|
<section class="section-block admin-section">
|
|
<div class="section-body admin-section-body">
|
|
<div class="admin-panels">
|
|
<AdminOverview
|
|
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
|
|
ref="adminOverviewRef"
|
|
:info="info"
|
|
:orgs="orgs"
|
|
:permissions="permissions"
|
|
:oidc-clients="oidcClients"
|
|
:domains="domains"
|
|
:current-rp-id="authStore.settings?.rp_id || ''"
|
|
:navigation-disabled="hasActiveModal"
|
|
:permission-summary="permissionSummary"
|
|
@create-org="createOrg"
|
|
@open-org="openOrg"
|
|
@update-org="updateOrg"
|
|
@delete-org="deleteOrg"
|
|
@toggle-org-permission="toggleOrgPermission"
|
|
@open-dialog="openDialog"
|
|
@delete-permission="deletePermission"
|
|
@rename-permission-display="renamePermissionDisplay"
|
|
@create-oidc-client="createOidcClient"
|
|
@open-oidc-client="openOidcClient"
|
|
@delete-oidc-client="deleteOidcClient"
|
|
@create-domain="createDomain"
|
|
@open-domain="openDomain"
|
|
@delete-domain="deleteDomain"
|
|
@navigate-out="handlePanelNavigateOut"
|
|
/>
|
|
|
|
<AdminUserDetail
|
|
v-else-if="selectedUser"
|
|
ref="adminUserDetailRef"
|
|
:selected-user="selectedUser"
|
|
:user-detail="userDetail"
|
|
:selected-org="selectedOrg"
|
|
:loading="loading"
|
|
:show-reg-modal="showRegModal"
|
|
:navigation-disabled="hasActiveModal"
|
|
@generate-user-registration-link="generateUserRegistrationLink"
|
|
@open-org="openOrg"
|
|
@refresh-user-detail="refreshUserDetail"
|
|
@edit-user-name="editUserName"
|
|
@close-reg-modal="showRegModal = false"
|
|
@navigate-out="handlePanelNavigateOut"
|
|
@delete-user="deleteUser(selectedUser, userDetail)"
|
|
/>
|
|
<AdminOrgDetail
|
|
v-else-if="selectedOrg"
|
|
ref="adminOrgDetailRef"
|
|
:selected-org="selectedOrg"
|
|
:permissions="permissions"
|
|
:navigation-disabled="hasActiveModal"
|
|
@update-org="updateOrg"
|
|
@create-role="createRole"
|
|
@update-role="updateRole"
|
|
@delete-role="deleteRole"
|
|
@create-user-in-role="createUserInRole"
|
|
@open-user="openUser"
|
|
@toggle-role-permission="toggleRolePermission"
|
|
@move-user-to-role="moveUserToRoleFromDrag"
|
|
@navigate-out="handlePanelNavigateOut"
|
|
/>
|
|
|
|
<AdminOidcDetail
|
|
v-else-if="currentOidcId && editingOidcClient"
|
|
ref="adminOidcDetailRef"
|
|
:client="editingOidcClient"
|
|
:permissions="permissions"
|
|
:domains="domains"
|
|
:is-new="editingOidcClient.isNew"
|
|
:navigation-disabled="hasActiveModal"
|
|
@save="handleOidcSave"
|
|
@cancel="handleOidcCancel"
|
|
@delete="deleteOidcClient"
|
|
@reset-secret="resetOidcSecret"
|
|
@create-permission="createPermissionForClient"
|
|
@navigate-out="handlePanelNavigateOut"
|
|
/>
|
|
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</main>
|
|
<AdminDialogs
|
|
:dialog="dialog"
|
|
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
|
@submit-dialog="submitDialog"
|
|
@close-dialog="closeDialog"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.view-admin { padding-bottom: var(--space-3xl); }
|
|
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
|
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
|
</style>
|