Admin app: guard rails extended, consistent styling, also share styling with main app.
This commit is contained in:
@@ -54,7 +54,9 @@ const permissionSummary = computed(() => {
|
||||
const summary = {}
|
||||
for (const o of orgs.value) {
|
||||
const orgBase = { uuid: o.uuid, display_name: o.display_name }
|
||||
// Org-level permissions (direct)
|
||||
const orgPerms = new Set(o.permissions || [])
|
||||
|
||||
// Org-level permissions (direct) - only count if org can grant them
|
||||
for (const pid of o.permissions || []) {
|
||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||
@@ -62,9 +64,13 @@ const permissionSummary = computed(() => {
|
||||
summary[pid].orgSet.add(o.uuid)
|
||||
}
|
||||
}
|
||||
// Role-based permissions (inheritance)
|
||||
|
||||
// Role-based permissions (inheritance) - only count if org can grant them
|
||||
for (const r of o.roles) {
|
||||
for (const pid of r.permissions) {
|
||||
// Only count if the org can grant this permission
|
||||
if (!orgPerms.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)
|
||||
@@ -164,6 +170,7 @@ async function load() {
|
||||
if (!window.location.hash || window.location.hash === '#overview') {
|
||||
currentOrgId.value = orgs.value[0].uuid
|
||||
window.location.hash = `#org/${currentOrgId.value}`
|
||||
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
|
||||
} else {
|
||||
parseHash()
|
||||
}
|
||||
@@ -178,14 +185,16 @@ async function load() {
|
||||
// Org actions
|
||||
function createOrg() { openDialog('org-create', {}) }
|
||||
|
||||
function updateOrg(org) { openDialog('org-update', { org }) }
|
||||
function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) }
|
||||
|
||||
function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) }
|
||||
|
||||
function deleteOrg(org) {
|
||||
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
||||
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
await Promise.all([loadOrgs(), loadPermissions()])
|
||||
} })
|
||||
}
|
||||
|
||||
@@ -231,7 +240,7 @@ async function removeOrgPermission() { /* obsolete */ }
|
||||
// Role actions
|
||||
function createRole(org) { openDialog('role-create', { org }) }
|
||||
|
||||
function updateRole(role) { openDialog('role-update', { role }) }
|
||||
function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) }
|
||||
|
||||
function deleteRole(role) {
|
||||
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
|
||||
@@ -241,6 +250,31 @@ function deleteRole(role) {
|
||||
} })
|
||||
}
|
||||
|
||||
async function toggleRolePermission(role, pid, checked) {
|
||||
// Calculate new permissions array
|
||||
const newPermissions = checked
|
||||
? [...role.permissions, pid]
|
||||
: role.permissions.filter(p => p !== pid)
|
||||
|
||||
// Optimistic update
|
||||
const prevPermissions = [...role.permissions]
|
||||
role.permissions = newPermissions
|
||||
|
||||
try {
|
||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: role.display_name, permissions: newPermissions })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||
role.permissions = prevPermissions // revert
|
||||
}
|
||||
}
|
||||
|
||||
// Permission actions
|
||||
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
|
||||
|
||||
@@ -288,9 +322,9 @@ const selectedUser = computed(() => {
|
||||
})
|
||||
|
||||
const pageHeading = computed(() => {
|
||||
if (selectedUser.value) return 'Organization Admin'
|
||||
if (selectedOrg.value) return 'Organization Admin'
|
||||
return (authStore.settings?.rp_name || 'Passkey') + ' Admin'
|
||||
if (selectedUser.value) return 'Admin: User'
|
||||
if (selectedOrg.value) return 'Admin: Org'
|
||||
return (authStore.settings?.rp_name || 'Master') + ' Admin'
|
||||
})
|
||||
|
||||
// Breadcrumb entries for admin app.
|
||||
@@ -401,14 +435,16 @@ async function submitDialog() {
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'role-update') {
|
||||
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const permsCsv = dialog.value.data.perms || ''
|
||||
const perms = permsCsv.split(',').map(s=>s.trim()).filter(Boolean)
|
||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: perms }) })
|
||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: role.permissions }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} 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')
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} 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')
|
||||
const res = await fetch(`/auth/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await onUserNameSaved()
|
||||
} else if (t === 'perm-display') {
|
||||
const { permission } = dialog.value.data
|
||||
const newId = dialog.value.data.id?.trim()
|
||||
@@ -431,10 +467,10 @@ async function submitDialog() {
|
||||
await loadPermissions()
|
||||
} else if (t === 'perm-create') {
|
||||
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
|
||||
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Display name required')
|
||||
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) })
|
||||
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
|
||||
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name }) })
|
||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
||||
await loadPermissions(); dialog.value.data.id = ''; dialog.value.data.name = ''
|
||||
await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = ''
|
||||
} else if (t === 'confirm') {
|
||||
const action = dialog.value.data.action; if (action) await action()
|
||||
}
|
||||
@@ -454,9 +490,6 @@ async function submitDialog() {
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<p class="view-lede" v-if="info?.authenticated">
|
||||
Manage organizations, roles, permissions, and passkeys for your relying party.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block admin-section">
|
||||
@@ -498,6 +531,7 @@ async function submitDialog() {
|
||||
@go-overview="goOverview"
|
||||
@open-org="openOrg"
|
||||
@on-user-name-saved="onUserNameSaved"
|
||||
@edit-user-name="editUserName"
|
||||
@close-reg-modal="showRegModal = false"
|
||||
/>
|
||||
<AdminOrgDetail
|
||||
|
||||
@@ -1,43 +1,76 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
PERMISSION_ID_PATTERN: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
||||
|
||||
const nameInput = ref(null)
|
||||
const displayNameInput = ref(null)
|
||||
|
||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||
|
||||
watch(() => props.dialog.type, (newType) => {
|
||||
if (newType === 'org-create') {
|
||||
nextTick(() => {
|
||||
nameInput.value?.focus()
|
||||
})
|
||||
} else if (newType === 'perm-display' || newType === 'perm-create') {
|
||||
nextTick(() => {
|
||||
displayNameInput.value?.focus()
|
||||
if (newType === 'perm-display') {
|
||||
displayNameInput.value?.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="dialog.type" class="modal-overlay" @keydown.esc.prevent.stop="$emit('closeDialog')" tabindex="-1">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
|
||||
<h3 class="modal-title">
|
||||
<template v-if="dialog.type==='org-create'">Create Organization</template>
|
||||
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
|
||||
<template v-else-if="dialog.type==='role-create'">Create Role</template>
|
||||
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
|
||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||
<template v-else-if="dialog.type==='perm-create'">Create Permission</template>
|
||||
<template v-else-if="dialog.type==='perm-display'">Edit Permission Display</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 Display' }}</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
<template v-if="dialog.type==='org-create' || dialog.type==='org-update'">
|
||||
<template v-if="dialog.type==='org-create'">
|
||||
<label>Name
|
||||
<input v-model="dialog.data.name" :placeholder="dialog.type==='org-update'? dialog.data.org.display_name : 'Organization name'" required />
|
||||
<input ref="nameInput" v-model="dialog.data.name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='org-update'">
|
||||
<NameEditForm
|
||||
label="Organization Name"
|
||||
v-model="dialog.data.name"
|
||||
:busy="dialog.busy"
|
||||
:error="dialog.error"
|
||||
@cancel="$emit('closeDialog')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='role-create'">
|
||||
<label>Role Name
|
||||
<input v-model="dialog.data.name" placeholder="Role name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='role-update'">
|
||||
<label>Role Name
|
||||
<input v-model="dialog.data.name" :placeholder="dialog.data.role.display_name" required />
|
||||
</label>
|
||||
<label>Permissions (comma separated)
|
||||
<textarea v-model="dialog.data.perms" rows="2" placeholder="perm:a, perm:b"></textarea>
|
||||
</label>
|
||||
<NameEditForm
|
||||
label="Role Name"
|
||||
v-model="dialog.data.name"
|
||||
:busy="dialog.busy"
|
||||
:error="dialog.error"
|
||||
@cancel="$emit('closeDialog')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='user-create'">
|
||||
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
|
||||
@@ -45,105 +78,50 @@ const emit = defineEmits(['submitDialog', 'closeDialog'])
|
||||
<input v-model="dialog.data.name" placeholder="User display name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='perm-create'">
|
||||
<label>Permission ID
|
||||
<input v-model="dialog.data.id" placeholder="permission id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" />
|
||||
</label>
|
||||
<label>Display Name
|
||||
<input v-model="dialog.data.name" placeholder="display name" required />
|
||||
</label>
|
||||
<template v-else-if="dialog.type==='user-update-name'">
|
||||
<NameEditForm
|
||||
label="Display Name"
|
||||
v-model="dialog.data.name"
|
||||
:busy="dialog.busy"
|
||||
:error="dialog.error"
|
||||
@cancel="$emit('closeDialog')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='perm-display'">
|
||||
<label>Permission ID
|
||||
<input v-model="dialog.data.id" :placeholder="dialog.data.permission.id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" />
|
||||
</label>
|
||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">
|
||||
<label>Display Name
|
||||
<input v-model="dialog.data.display_name" :placeholder="dialog.data.permission.display_name" required />
|
||||
<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:login' : dialog.data.permission.id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" />
|
||||
</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>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
</template>
|
||||
<div v-if="dialog.error" class="error small">{{ dialog.error }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" :disabled="dialog.busy">{{ dialog.type==='confirm' ? 'OK' : 'Save' }}</button>
|
||||
<button type="button" @click="$emit('closeDialog')" :disabled="dialog.busy">Cancel</button>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="$emit('closeDialog')"
|
||||
:disabled="dialog.busy"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="dialog.busy"
|
||||
>
|
||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(.1rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: var(--space-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modal-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-form input,
|
||||
.modal-form textarea {
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-form input:focus,
|
||||
.modal-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.error { color: var(--color-danger-text); }
|
||||
.small { font-size: 0.9rem; }
|
||||
.muted { color: var(--color-text-muted); }
|
||||
|
||||
@@ -8,6 +8,17 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart'])
|
||||
|
||||
const sortedRoles = computed(() => {
|
||||
return [...props.selectedOrg.roles].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)
|
||||
})
|
||||
})
|
||||
|
||||
function permissionDisplayName(id) {
|
||||
return props.permissions.find(p => p.id === id)?.display_name || id
|
||||
}
|
||||
@@ -18,22 +29,20 @@ function toggleRolePermission(role, pid, checked) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card surface">
|
||||
<h2 class="org-title" :title="selectedOrg.uuid">
|
||||
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
||||
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
</h2>
|
||||
<div class="org-actions"></div>
|
||||
<h2 class="org-title" :title="selectedOrg.uuid">
|
||||
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
||||
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
</h2>
|
||||
|
||||
<div class="matrix-wrapper">
|
||||
<div class="matrix-scroll">
|
||||
<div
|
||||
class="perm-matrix-grid"
|
||||
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
|
||||
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedRoles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
|
||||
>
|
||||
<div class="grid-head perm-head">Permission</div>
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
v-for="r in sortedRoles"
|
||||
:key="'head-' + r.uuid"
|
||||
class="grid-head role-head"
|
||||
:title="r.display_name"
|
||||
@@ -45,7 +54,7 @@ function toggleRolePermission(role, pid, checked) {
|
||||
<template v-for="pid in selectedOrg.permissions" :key="pid">
|
||||
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
v-for="r in sortedRoles"
|
||||
:key="r.uuid + '-' + pid"
|
||||
class="matrix-cell"
|
||||
>
|
||||
@@ -63,7 +72,7 @@ function toggleRolePermission(role, pid, checked) {
|
||||
</div>
|
||||
<div class="roles-grid">
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
v-for="r in sortedRoles"
|
||||
:key="r.uuid"
|
||||
class="role-column"
|
||||
@dragover="$emit('onRoleDragOver', $event)"
|
||||
@@ -81,7 +90,14 @@ function toggleRolePermission(role, pid, checked) {
|
||||
<template v-if="r.users.length > 0">
|
||||
<ul class="user-list">
|
||||
<li
|
||||
v-for="u in r.users"
|
||||
v-for="u in r.users.slice().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)
|
||||
})"
|
||||
:key="u.uuid"
|
||||
class="user-chip"
|
||||
draggable="true"
|
||||
@@ -100,7 +116,6 @@ function toggleRolePermission(role, pid, checked) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,17 +10,28 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay'])
|
||||
|
||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> a.display_name.localeCompare(b.display_name)))
|
||||
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)))
|
||||
|
||||
function permissionDisplayName(id) {
|
||||
return props.permissions.find(p => p.id === id)?.display_name || id
|
||||
}
|
||||
|
||||
function getRoleNames(org) {
|
||||
return org.roles
|
||||
.slice()
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||
.map(r => r.display_name)
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="permissions-section">
|
||||
<h2>Organizations</h2>
|
||||
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
|
||||
<div class="actions">
|
||||
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
|
||||
</div>
|
||||
@@ -34,14 +45,14 @@ function permissionDisplayName(id) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orgs" :key="o.uuid">
|
||||
<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" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
<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>
|
||||
</td>
|
||||
<td>{{ o.roles.length }}</td>
|
||||
<td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
||||
<td v-if="info.is_global_admin">
|
||||
<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">
|
||||
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -49,7 +60,7 @@ function permissionDisplayName(id) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="permissions-section">
|
||||
<div v-if="info.is_global_admin" class="permissions-section">
|
||||
<h2>Permissions</h2>
|
||||
<div class="matrix-wrapper">
|
||||
<div class="matrix-scroll">
|
||||
@@ -88,7 +99,7 @@ function permissionDisplayName(id) {
|
||||
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', {})">+ Create Permission</button>
|
||||
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button>
|
||||
</div>
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
@@ -107,7 +118,6 @@ function permissionDisplayName(id) {
|
||||
</div>
|
||||
<div class="perm-id-info">
|
||||
<span class="id-text">{{ p.id }}</span>
|
||||
<button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-id-btn" aria-label="Edit id" title="Edit id">🆔</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="perm-members center">{{ permissionSummary[p.id]?.userCount || 0 }}</td>
|
||||
@@ -127,6 +137,8 @@ function permissionDisplayName(id) {
|
||||
.actions button { width: auto; }
|
||||
.org-table a { text-decoration: none; color: var(--color-link); }
|
||||
.org-table a:hover { text-decoration: underline; }
|
||||
.org-table .center { width: 6rem; min-width: 6rem; }
|
||||
.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); }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
selectedUser: Object,
|
||||
@@ -12,17 +13,35 @@ const props = defineProps({
|
||||
showRegModal: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal'])
|
||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName'])
|
||||
|
||||
const showRegModal = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function onLinkCopied() {
|
||||
// This could emit an event or show a message
|
||||
authStore.showMessage('Link copied to clipboard!')
|
||||
}
|
||||
|
||||
function handleEditName() {
|
||||
emit('editUserName', props.selectedUser)
|
||||
}
|
||||
|
||||
function handleDelete(credential) {
|
||||
fetch(`/auth/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
} else {
|
||||
console.error('Failed to delete credential', data)
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Delete credential error', err))
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card surface user-detail">
|
||||
<div class="user-detail">
|
||||
<UserBasicInfo
|
||||
v-if="userDetail && !userDetail.error"
|
||||
:name="userDetail.display_name || selectedUser.display_name"
|
||||
@@ -34,15 +53,15 @@ function onLinkCopied() {
|
||||
:role-name="userDetail.role"
|
||||
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@edit-name="handleEditName"
|
||||
/>
|
||||
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||
<template v-if="userDetail && !userDetail.error">
|
||||
<h3 class="cred-title">Registered Passkeys</h3>
|
||||
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" />
|
||||
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" :allow-delete="true" @delete="handleDelete" />
|
||||
</template>
|
||||
<div class="actions">
|
||||
<button @click="$emit('generateUserRegistrationLink', selectedUser)">Generate Registration Token</button>
|
||||
<button @click="$emit('goOverview')" class="icon-btn" title="Overview">🏠</button>
|
||||
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button>
|
||||
</div>
|
||||
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
|
||||
@@ -57,7 +76,6 @@ function onLinkCopied() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card.surface { padding: var(--space-lg); }
|
||||
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
||||
.cred-title { font-size: 1.25rem; font-weight: 600; color: var(--color-heading); margin-bottom: var(--space-md); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
||||
|
||||
93
frontend/src/components/Modal.vue
Normal file
93
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @keydown.esc="$emit('close')" tabindex="-1">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(.1rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: calc(var(--space-lg) - var(--space-xs));
|
||||
max-width: 500px;
|
||||
width: min(500px, 90vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal :deep(.modal-title),
|
||||
.modal :deep(h3) {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.modal :deep(form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modal :deep(.modal-form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modal :deep(.modal-form label) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal :deep(.modal-form input),
|
||||
.modal :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;
|
||||
}
|
||||
|
||||
.modal :deep(.modal-form input:focus),
|
||||
.modal :deep(.modal-form textarea:focus) {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.modal :deep(.modal-actions) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
94
frontend/src/components/NameEditForm.vue
Normal file
94
frontend/src/components/NameEditForm.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="name-edit-form">
|
||||
<label :for="resolvedInputId">{{ label }}
|
||||
<input
|
||||
:id="resolvedInputId"
|
||||
ref="inputRef"
|
||||
:type="inputType"
|
||||
:placeholder="placeholder"
|
||||
v-model="localValue"
|
||||
:disabled="busy"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div v-if="error" class="error small">{{ error }}</div>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="handleCancel"
|
||||
:disabled="busy"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="busy"
|
||||
>
|
||||
{{ submitText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
label: { type: String, default: 'Name' },
|
||||
placeholder: { type: String, default: '' },
|
||||
submitText: { type: String, default: 'Save' },
|
||||
cancelText: { type: String, default: 'Cancel' },
|
||||
busy: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
autoFocus: { type: Boolean, default: true },
|
||||
autoSelect: { type: Boolean, default: true },
|
||||
inputId: { type: String, default: null },
|
||||
inputType: { type: String, default: 'text' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'cancel'])
|
||||
const inputRef = ref(null)
|
||||
const generatedId = `name-edit-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
const localValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const resolvedInputId = computed(() => props.inputId || generatedId)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.autoFocus) return
|
||||
nextTick(() => {
|
||||
if (props.autoSelect) {
|
||||
inputRef.value?.select()
|
||||
} else {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.name-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@
|
||||
:loading="authStore.isLoading"
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -51,20 +52,44 @@
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Name Edit Dialog -->
|
||||
<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"
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
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 Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import passkey from '@/utils/passkey'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showNameDialog = ref(false)
|
||||
const newName = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
watch(showNameDialog, (newVal) => {
|
||||
if (newVal) {
|
||||
newName.value = authStore.userInfo?.user?.user_name || ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval.value = setInterval(() => {
|
||||
@@ -112,7 +137,37 @@ 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 saveName = async () => {
|
||||
const name = newName.value.trim()
|
||||
if (!name) {
|
||||
authStore.showMessage('Name cannot be empty', 'error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
saving.value = true
|
||||
const res = await fetch('/auth/api/user/display-name', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,21 +2,9 @@
|
||||
<div v-if="userLoaded" class="user-info">
|
||||
<h3 class="user-name-heading">
|
||||
<span class="icon">👤</span>
|
||||
<span v-if="!editingName" class="user-name-row">
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="startEdit" title="Edit name">✏️</button>
|
||||
</span>
|
||||
<span v-else class="user-name-row editing">
|
||||
<input
|
||||
v-model="newName"
|
||||
class="name-input"
|
||||
:placeholder="name"
|
||||
:disabled="busy || loading"
|
||||
maxlength="64"
|
||||
@keyup.enter="saveName"
|
||||
/>
|
||||
<button class="mini-btn" @click="saveName" :disabled="busy || loading" title="Save name">💾</button>
|
||||
<button class="mini-btn" @click="cancelEdit" :disabled="busy || loading" title="Cancel">✖</button>
|
||||
<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">
|
||||
@@ -49,34 +37,10 @@ const props = defineProps({
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved'])
|
||||
const emit = defineEmits(['saved', 'editName'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const editingName = ref(false)
|
||||
const newName = ref('')
|
||||
const busy = ref(false)
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
|
||||
function startEdit() { editingName.value = true; newName.value = '' }
|
||||
function cancelEdit() { editingName.value = false }
|
||||
async function saveName() {
|
||||
if (!props.updateEndpoint) { editingName.value = false; return }
|
||||
try {
|
||||
busy.value = true
|
||||
authStore.isLoading = true
|
||||
const bodyName = newName.value.trim()
|
||||
if (!bodyName) { cancelEdit(); return }
|
||||
const res = await fetch(props.updateEndpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: bodyName }) })
|
||||
let data = {}
|
||||
try { data = await res.json() } catch (_) {}
|
||||
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
||||
editingName.value = false
|
||||
authStore.showMessage('Name updated', 'success', 1500)
|
||||
emit('saved')
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||
finally { busy.value = false; authStore.isLoading = false }
|
||||
}
|
||||
watch(() => props.name, () => { if (!props.name) editingName.value = false })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
delete,
|
||||
event,
|
||||
insert,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
@@ -971,8 +972,10 @@ class DB(DatabaseInterface):
|
||||
)
|
||||
if role.permissions:
|
||||
for perm_id in set(role.permissions):
|
||||
session.add(
|
||||
RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id)
|
||||
await session.execute(
|
||||
insert(RolePermission).values(
|
||||
role_uuid=role.uuid.bytes, permission_id=perm_id
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_role(self, role_uuid: UUID) -> None:
|
||||
@@ -1200,10 +1203,15 @@ class DB(DatabaseInterface):
|
||||
org_perm_result = await session.execute(org_perm_stmt)
|
||||
organization.permissions = [row[0] for row in org_perm_result.fetchall()]
|
||||
|
||||
# Filter effective permissions: only include permissions that the org can grant
|
||||
effective_permissions = [
|
||||
p for p in permissions if p.id in organization.permissions
|
||||
]
|
||||
|
||||
return SessionContext(
|
||||
session=session_obj,
|
||||
user=user_obj,
|
||||
org=organization,
|
||||
role=role,
|
||||
permissions=permissions if permissions else None,
|
||||
permissions=effective_permissions if effective_permissions else None,
|
||||
)
|
||||
|
||||
@@ -77,12 +77,24 @@ async def admin_list_orgs(auth=Cookie(None)):
|
||||
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
from ..db import Role as RoleDC # local import to avoid cycles
|
||||
|
||||
org_uuid = uuid4()
|
||||
display_name = payload.get("display_name") or "New Organization"
|
||||
permissions = payload.get("permissions") or []
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
await db.instance.create_organization(org)
|
||||
|
||||
# Automatically create Administration role with org admin permission
|
||||
role_uuid = uuid4()
|
||||
admin_role = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
display_name="Administration",
|
||||
permissions=[f"auth:org:{org_uuid}"],
|
||||
)
|
||||
await db.instance.create_role(admin_role)
|
||||
|
||||
return {"uuid": str(org_uuid)}
|
||||
|
||||
|
||||
@@ -90,7 +102,7 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
||||
async def admin_update_org(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
await authz.verify(
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
@@ -98,6 +110,20 @@ async def admin_update_org(
|
||||
current = await db.instance.get_organization(str(org_uuid))
|
||||
display_name = payload.get("display_name") or current.display_name
|
||||
permissions = payload.get("permissions") or current.permissions or []
|
||||
|
||||
# Sanity check: prevent removing permissions that would break current user's admin access
|
||||
org_admin_perm = f"auth:org:{org_uuid}"
|
||||
|
||||
# If current user is org admin (not global admin), ensure org admin perm remains
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" in ctx.role.permissions
|
||||
):
|
||||
if org_admin_perm not in permissions:
|
||||
raise ValueError(
|
||||
"Cannot remove organization admin permission from your own organization"
|
||||
)
|
||||
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
await db.instance.update_organization(org)
|
||||
return {"status": "ok"}
|
||||
@@ -110,6 +136,21 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||
)
|
||||
if ctx.org.uuid == org_uuid:
|
||||
raise ValueError("Cannot delete the organization you belong to")
|
||||
|
||||
# Delete organization-specific permissions
|
||||
org_perm_pattern = f"org:{str(org_uuid).lower()}"
|
||||
all_permissions = await db.instance.list_permissions()
|
||||
for perm in all_permissions:
|
||||
perm_id_lower = perm.id.lower()
|
||||
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
|
||||
if (
|
||||
f":{org_perm_pattern}:" in perm_id_lower
|
||||
or perm_id_lower.startswith(f"{org_perm_pattern}:")
|
||||
or perm_id_lower.endswith(f":{org_perm_pattern}")
|
||||
or perm_id_lower == org_perm_pattern
|
||||
):
|
||||
await db.instance.delete_permission(perm.id)
|
||||
|
||||
await db.instance.delete_organization(org_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -139,7 +180,9 @@ async def admin_remove_org_permission(
|
||||
async def admin_create_role(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
await authz.verify(auth, ["auth:admin", f"auth:org:{org_uuid}"])
|
||||
await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
)
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
role_uuid = uuid4()
|
||||
@@ -166,7 +209,7 @@ async def admin_update_role(
|
||||
org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
# Verify caller is global admin or admin of provided org
|
||||
await authz.verify(
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
@@ -175,13 +218,25 @@ async def admin_update_role(
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
display_name = payload.get("display_name") or role.display_name
|
||||
permissions = payload.get("permissions") or role.permissions
|
||||
permissions = payload.get("permissions")
|
||||
if permissions is None:
|
||||
permissions = role.permissions
|
||||
org = await db.instance.get_organization(str(org_uuid))
|
||||
grantable = set(org.permissions or [])
|
||||
existing_permissions = set(role.permissions)
|
||||
for pid in permissions:
|
||||
await db.instance.get_permission(pid)
|
||||
if pid not in grantable:
|
||||
if pid not in existing_permissions and pid not in grantable:
|
||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access via role update
|
||||
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
|
||||
has_admin_access = (
|
||||
"auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions
|
||||
)
|
||||
if not has_admin_access:
|
||||
raise ValueError("Cannot update your own role to remove admin permissions")
|
||||
|
||||
updated = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
@@ -194,12 +249,17 @@ async def admin_update_role(
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
|
||||
await authz.verify(
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from deleting their own role
|
||||
if ctx.role.uuid == role_uuid:
|
||||
raise ValueError("Cannot delete your own role")
|
||||
|
||||
await db.instance.delete_role(role_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -240,7 +300,7 @@ async def admin_create_user(
|
||||
async def admin_update_user_role(
|
||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
await authz.verify(
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
)
|
||||
new_role = payload.get("role")
|
||||
@@ -255,6 +315,20 @@ async def admin_update_user_role(
|
||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||
if not any(r.display_name == new_role for r in roles):
|
||||
raise ValueError("Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
if ctx.user.uuid == user_uuid:
|
||||
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
||||
if new_role_obj:
|
||||
has_admin_access = (
|
||||
"auth:admin" in new_role_obj.permissions
|
||||
or f"auth:org:{org_uuid}" in new_role_obj.permissions
|
||||
)
|
||||
if not has_admin_access:
|
||||
raise ValueError(
|
||||
"Cannot change your own role to one without admin permissions"
|
||||
)
|
||||
|
||||
await db.instance.update_user_role_in_organization(user_uuid, new_role)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -370,14 +444,44 @@ async def admin_update_user_display_name(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
|
||||
async def admin_delete_user_credential(
|
||||
org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, auth=Cookie(None)
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
await db.instance.delete_credential(credential_uuid, user_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# -------------------- Permissions (global) --------------------
|
||||
|
||||
|
||||
@app.get("/permissions")
|
||||
async def admin_list_permissions(auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"], match=permutil.has_any)
|
||||
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
||||
perms = await db.instance.list_permissions()
|
||||
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
||||
|
||||
# Global admins see all permissions
|
||||
if "auth:admin" in ctx.role.permissions:
|
||||
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
||||
|
||||
# Org admins only see permissions their org can grant
|
||||
grantable = set(ctx.org.permissions or [])
|
||||
filtered_perms = [p for p in perms if p.id in grantable]
|
||||
return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms]
|
||||
|
||||
|
||||
@app.post("/permissions")
|
||||
@@ -418,6 +522,11 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
display_name = payload.get("display_name")
|
||||
if not old_id or not new_id:
|
||||
raise ValueError("old_id and new_id required")
|
||||
|
||||
# Sanity check: prevent renaming critical permissions
|
||||
if old_id == "auth:admin":
|
||||
raise ValueError("Cannot rename the master admin permission")
|
||||
|
||||
querysafe.assert_safe(old_id, field="old_id")
|
||||
querysafe.assert_safe(new_id, field="new_id")
|
||||
if display_name is None:
|
||||
@@ -434,5 +543,10 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
|
||||
# Sanity check: prevent deleting critical permissions
|
||||
if permission_id == "auth:admin":
|
||||
raise ValueError("Cannot delete the master admin permission")
|
||||
|
||||
await db.instance.delete_permission(permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -193,10 +193,9 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||
}
|
||||
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
||||
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
|
||||
if org_info:
|
||||
is_org_admin = f"auth:org:{org_info['uuid']}" in (
|
||||
role_info["permissions"] or []
|
||||
)
|
||||
is_org_admin = any(
|
||||
p.startswith("auth:org:") for p in (role_info["permissions"] or [])
|
||||
)
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
|
||||
Reference in New Issue
Block a user