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 = {}
|
const summary = {}
|
||||||
for (const o of orgs.value) {
|
for (const o of orgs.value) {
|
||||||
const orgBase = { uuid: o.uuid, display_name: o.display_name }
|
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 || []) {
|
for (const pid of o.permissions || []) {
|
||||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||||
@@ -62,9 +64,13 @@ const permissionSummary = computed(() => {
|
|||||||
summary[pid].orgSet.add(o.uuid)
|
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 r of o.roles) {
|
||||||
for (const pid of r.permissions) {
|
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]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||||
summary[pid].orgs.push(orgBase)
|
summary[pid].orgs.push(orgBase)
|
||||||
@@ -164,6 +170,7 @@ async function load() {
|
|||||||
if (!window.location.hash || window.location.hash === '#overview') {
|
if (!window.location.hash || window.location.hash === '#overview') {
|
||||||
currentOrgId.value = orgs.value[0].uuid
|
currentOrgId.value = orgs.value[0].uuid
|
||||||
window.location.hash = `#org/${currentOrgId.value}`
|
window.location.hash = `#org/${currentOrgId.value}`
|
||||||
|
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
|
||||||
} else {
|
} else {
|
||||||
parseHash()
|
parseHash()
|
||||||
}
|
}
|
||||||
@@ -178,14 +185,16 @@ async function load() {
|
|||||||
// Org actions
|
// Org actions
|
||||||
function createOrg() { openDialog('org-create', {}) }
|
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) {
|
function deleteOrg(org) {
|
||||||
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
||||||
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
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
|
// Role actions
|
||||||
function createRole(org) { openDialog('role-create', { org }) }
|
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) {
|
function deleteRole(role) {
|
||||||
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
|
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
|
// Permission actions
|
||||||
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
|
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
|
||||||
|
|
||||||
@@ -288,9 +322,9 @@ const selectedUser = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const pageHeading = computed(() => {
|
const pageHeading = computed(() => {
|
||||||
if (selectedUser.value) return 'Organization Admin'
|
if (selectedUser.value) return 'Admin: User'
|
||||||
if (selectedOrg.value) return 'Organization Admin'
|
if (selectedOrg.value) return 'Admin: Org'
|
||||||
return (authStore.settings?.rp_name || 'Passkey') + ' Admin'
|
return (authStore.settings?.rp_name || 'Master') + ' Admin'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Breadcrumb entries for admin app.
|
// 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()
|
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||||
} else if (t === 'role-update') {
|
} 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 { 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 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 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 d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||||
} else if (t === 'user-create') {
|
} 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 { 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 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()
|
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') {
|
} else if (t === 'perm-display') {
|
||||||
const { permission } = dialog.value.data
|
const { permission } = dialog.value.data
|
||||||
const newId = dialog.value.data.id?.trim()
|
const newId = dialog.value.data.id?.trim()
|
||||||
@@ -431,10 +467,10 @@ async function submitDialog() {
|
|||||||
await loadPermissions()
|
await loadPermissions()
|
||||||
} else if (t === 'perm-create') {
|
} else if (t === 'perm-create') {
|
||||||
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
|
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 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: name }) })
|
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)
|
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') {
|
} else if (t === 'confirm') {
|
||||||
const action = dialog.value.data.action; if (action) await action()
|
const action = dialog.value.data.action; if (action) await action()
|
||||||
}
|
}
|
||||||
@@ -454,9 +490,6 @@ async function submitDialog() {
|
|||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<h1>{{ pageHeading }}</h1>
|
<h1>{{ pageHeading }}</h1>
|
||||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||||
<p class="view-lede" v-if="info?.authenticated">
|
|
||||||
Manage organizations, roles, permissions, and passkeys for your relying party.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="section-block admin-section">
|
<section class="section-block admin-section">
|
||||||
@@ -498,6 +531,7 @@ async function submitDialog() {
|
|||||||
@go-overview="goOverview"
|
@go-overview="goOverview"
|
||||||
@open-org="openOrg"
|
@open-org="openOrg"
|
||||||
@on-user-name-saved="onUserNameSaved"
|
@on-user-name-saved="onUserNameSaved"
|
||||||
|
@edit-user-name="editUserName"
|
||||||
@close-reg-modal="showRegModal = false"
|
@close-reg-modal="showRegModal = false"
|
||||||
/>
|
/>
|
||||||
<AdminOrgDetail
|
<AdminOrgDetail
|
||||||
|
|||||||
@@ -1,43 +1,76 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import Modal from '@/components/Modal.vue'
|
||||||
|
import NameEditForm from '@/components/NameEditForm.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
dialog: Object,
|
dialog: Object,
|
||||||
PERMISSION_ID_PATTERN: String
|
PERMISSION_ID_PATTERN: String
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="dialog.type" class="modal-overlay" @keydown.esc.prevent.stop="$emit('closeDialog')" tabindex="-1">
|
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
|
||||||
<div class="modal" role="dialog" aria-modal="true">
|
|
||||||
<h3 class="modal-title">
|
<h3 class="modal-title">
|
||||||
<template v-if="dialog.type==='org-create'">Create Organization</template>
|
<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==='org-update'">Rename Organization</template>
|
||||||
<template v-else-if="dialog.type==='role-create'">Create Role</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==='role-update'">Edit Role</template>
|
||||||
<template v-else-if="dialog.type==='user-create'">Add User To 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==='user-update-name'">Edit User Name</template>
|
||||||
<template v-else-if="dialog.type==='perm-display'">Edit Permission Display</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>
|
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
<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
|
<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>
|
</label>
|
||||||
</template>
|
</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'">
|
<template v-else-if="dialog.type==='role-create'">
|
||||||
<label>Role Name
|
<label>Role Name
|
||||||
<input v-model="dialog.data.name" placeholder="Role name" required />
|
<input v-model="dialog.data.name" placeholder="Role name" required />
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="dialog.type==='role-update'">
|
<template v-else-if="dialog.type==='role-update'">
|
||||||
<label>Role Name
|
<NameEditForm
|
||||||
<input v-model="dialog.data.name" :placeholder="dialog.data.role.display_name" required />
|
label="Role Name"
|
||||||
</label>
|
v-model="dialog.data.name"
|
||||||
<label>Permissions (comma separated)
|
:busy="dialog.busy"
|
||||||
<textarea v-model="dialog.data.perms" rows="2" placeholder="perm:a, perm:b"></textarea>
|
:error="dialog.error"
|
||||||
</label>
|
@cancel="$emit('closeDialog')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="dialog.type==='user-create'">
|
<template v-else-if="dialog.type==='user-create'">
|
||||||
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
|
<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 />
|
<input v-model="dialog.data.name" placeholder="User display name" required />
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="dialog.type==='perm-create'">
|
<template v-else-if="dialog.type==='user-update-name'">
|
||||||
<label>Permission ID
|
<NameEditForm
|
||||||
<input v-model="dialog.data.id" placeholder="permission id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" />
|
label="Display Name"
|
||||||
</label>
|
v-model="dialog.data.name"
|
||||||
<label>Display Name
|
:busy="dialog.busy"
|
||||||
<input v-model="dialog.data.name" placeholder="display name" required />
|
:error="dialog.error"
|
||||||
</label>
|
@cancel="$emit('closeDialog')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="dialog.type==='perm-display'">
|
<template v-else-if="dialog.type==='perm-create' || 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>
|
|
||||||
<label>Display Name
|
<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>
|
||||||
|
<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>
|
||||||
<template v-else-if="dialog.type==='confirm'">
|
<template v-else-if="dialog.type==='confirm'">
|
||||||
<p>{{ dialog.data.message }}</p>
|
<p>{{ dialog.data.message }}</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="dialog.error" class="error small">{{ dialog.error }}</div>
|
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
||||||
<div class="modal-actions">
|
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||||
<button type="submit" :disabled="dialog.busy">{{ dialog.type==='confirm' ? 'OK' : 'Save' }}</button>
|
<button
|
||||||
<button type="button" @click="$emit('closeDialog')" :disabled="dialog.busy">Cancel</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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); }
|
.error { color: var(--color-danger-text); }
|
||||||
.small { font-size: 0.9rem; }
|
.small { font-size: 0.9rem; }
|
||||||
.muted { color: var(--color-text-muted); }
|
.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 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) {
|
function permissionDisplayName(id) {
|
||||||
return props.permissions.find(p => p.id === id)?.display_name || id
|
return props.permissions.find(p => p.id === id)?.display_name || id
|
||||||
}
|
}
|
||||||
@@ -18,22 +29,20 @@ function toggleRolePermission(role, pid, checked) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card surface">
|
<h2 class="org-title" :title="selectedOrg.uuid">
|
||||||
<h2 class="org-title" :title="selectedOrg.uuid">
|
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
||||||
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||||
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
</h2>
|
||||||
</h2>
|
|
||||||
<div class="org-actions"></div>
|
|
||||||
|
|
||||||
<div class="matrix-wrapper">
|
<div class="matrix-wrapper">
|
||||||
<div class="matrix-scroll">
|
<div class="matrix-scroll">
|
||||||
<div
|
<div
|
||||||
class="perm-matrix-grid"
|
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 class="grid-head perm-head">Permission</div>
|
||||||
<div
|
<div
|
||||||
v-for="r in selectedOrg.roles"
|
v-for="r in sortedRoles"
|
||||||
:key="'head-' + r.uuid"
|
:key="'head-' + r.uuid"
|
||||||
class="grid-head role-head"
|
class="grid-head role-head"
|
||||||
:title="r.display_name"
|
:title="r.display_name"
|
||||||
@@ -45,7 +54,7 @@ function toggleRolePermission(role, pid, checked) {
|
|||||||
<template v-for="pid in selectedOrg.permissions" :key="pid">
|
<template v-for="pid in selectedOrg.permissions" :key="pid">
|
||||||
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
|
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
|
||||||
<div
|
<div
|
||||||
v-for="r in selectedOrg.roles"
|
v-for="r in sortedRoles"
|
||||||
:key="r.uuid + '-' + pid"
|
:key="r.uuid + '-' + pid"
|
||||||
class="matrix-cell"
|
class="matrix-cell"
|
||||||
>
|
>
|
||||||
@@ -63,7 +72,7 @@ function toggleRolePermission(role, pid, checked) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="roles-grid">
|
<div class="roles-grid">
|
||||||
<div
|
<div
|
||||||
v-for="r in selectedOrg.roles"
|
v-for="r in sortedRoles"
|
||||||
:key="r.uuid"
|
:key="r.uuid"
|
||||||
class="role-column"
|
class="role-column"
|
||||||
@dragover="$emit('onRoleDragOver', $event)"
|
@dragover="$emit('onRoleDragOver', $event)"
|
||||||
@@ -81,7 +90,14 @@ function toggleRolePermission(role, pid, checked) {
|
|||||||
<template v-if="r.users.length > 0">
|
<template v-if="r.users.length > 0">
|
||||||
<ul class="user-list">
|
<ul class="user-list">
|
||||||
<li
|
<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"
|
:key="u.uuid"
|
||||||
class="user-chip"
|
class="user-chip"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@@ -100,7 +116,6 @@ function toggleRolePermission(role, pid, checked) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -10,17 +10,28 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay'])
|
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)))
|
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.id.localeCompare(b.id)))
|
||||||
|
|
||||||
function permissionDisplayName(id) {
|
function permissionDisplayName(id) {
|
||||||
return props.permissions.find(p => p.id === id)?.display_name || 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="permissions-section">
|
<div class="permissions-section">
|
||||||
<h2>Organizations</h2>
|
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
|
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,14 +45,14 @@ function permissionDisplayName(id) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="o in orgs" :key="o.uuid">
|
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
||||||
<td>
|
<td>
|
||||||
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
|
<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>
|
||||||
<td>{{ o.roles.length }}</td>
|
<td class="role-names">{{ getRoleNames(o) }}</td>
|
||||||
<td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
||||||
<td v-if="info.is_global_admin">
|
<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>
|
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -49,7 +60,7 @@ function permissionDisplayName(id) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="permissions-section">
|
<div v-if="info.is_global_admin" class="permissions-section">
|
||||||
<h2>Permissions</h2>
|
<h2>Permissions</h2>
|
||||||
<div class="matrix-wrapper">
|
<div class="matrix-wrapper">
|
||||||
<div class="matrix-scroll">
|
<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>
|
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<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>
|
</div>
|
||||||
<table class="org-table">
|
<table class="org-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -107,7 +118,6 @@ function permissionDisplayName(id) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="perm-id-info">
|
<div class="perm-id-info">
|
||||||
<span class="id-text">{{ p.id }}</span>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="perm-members center">{{ permissionSummary[p.id]?.userCount || 0 }}</td>
|
<td class="perm-members center">{{ permissionSummary[p.id]?.userCount || 0 }}</td>
|
||||||
@@ -127,6 +137,8 @@ function permissionDisplayName(id) {
|
|||||||
.actions button { width: auto; }
|
.actions button { width: auto; }
|
||||||
.org-table a { text-decoration: none; color: var(--color-link); }
|
.org-table a { text-decoration: none; color: var(--color-link); }
|
||||||
.org-table a:hover { text-decoration: underline; }
|
.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-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
.perm-title { font-weight: 600; color: var(--color-heading); }
|
.perm-title { font-weight: 600; color: var(--color-heading); }
|
||||||
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
|
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
|||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedUser: Object,
|
selectedUser: Object,
|
||||||
@@ -12,17 +13,35 @@ const props = defineProps({
|
|||||||
showRegModal: Boolean
|
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() {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card surface user-detail">
|
<div class="user-detail">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="userDetail && !userDetail.error"
|
v-if="userDetail && !userDetail.error"
|
||||||
:name="userDetail.display_name || selectedUser.display_name"
|
:name="userDetail.display_name || selectedUser.display_name"
|
||||||
@@ -34,15 +53,15 @@ function onLinkCopied() {
|
|||||||
:role-name="userDetail.role"
|
:role-name="userDetail.role"
|
||||||
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
||||||
@saved="$emit('onUserNameSaved')"
|
@saved="$emit('onUserNameSaved')"
|
||||||
|
@edit-name="handleEditName"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||||
<template v-if="userDetail && !userDetail.error">
|
<template v-if="userDetail && !userDetail.error">
|
||||||
<h3 class="cred-title">Registered Passkeys</h3>
|
<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>
|
</template>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button @click="$emit('generateUserRegistrationLink', selectedUser)">Generate Registration Token</button>
|
<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>
|
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card.surface { padding: var(--space-lg); }
|
|
||||||
.user-detail { display: flex; flex-direction: column; gap: 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); }
|
.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; }
|
.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"
|
:loading="authStore.isLoading"
|
||||||
update-endpoint="/auth/api/user/display-name"
|
update-endpoint="/auth/api/user/display-name"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
|
@edit-name="openNameDialog"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -51,20 +52,44 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.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 { useAuthStore } from '@/stores/auth'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
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(() => {
|
onMounted(() => {
|
||||||
updateInterval.value = setInterval(() => {
|
updateInterval.value = setInterval(() => {
|
||||||
@@ -112,7 +137,37 @@ const logout = async () => {
|
|||||||
await authStore.logout()
|
await authStore.logout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openNameDialog = () => {
|
||||||
|
newName.value = authStore.userInfo?.user?.user_name || ''
|
||||||
|
showNameDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
|
const isAdmin = computed(() => !!(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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -2,21 +2,9 @@
|
|||||||
<div v-if="userLoaded" class="user-info">
|
<div v-if="userLoaded" class="user-info">
|
||||||
<h3 class="user-name-heading">
|
<h3 class="user-name-heading">
|
||||||
<span class="icon">👤</span>
|
<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>
|
<span class="display-name" :title="name">{{ name }}</span>
|
||||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="startEdit" title="Edit name">✏️</button>
|
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" 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>
|
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
||||||
@@ -49,34 +37,10 @@ const props = defineProps({
|
|||||||
roleName: { type: String, default: '' }
|
roleName: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['saved'])
|
const emit = defineEmits(['saved', 'editName'])
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const editingName = ref(false)
|
|
||||||
const newName = ref('')
|
|
||||||
const busy = ref(false)
|
|
||||||
const userLoaded = computed(() => !!props.name)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
delete,
|
delete,
|
||||||
event,
|
event,
|
||||||
|
insert,
|
||||||
select,
|
select,
|
||||||
update,
|
update,
|
||||||
)
|
)
|
||||||
@@ -971,8 +972,10 @@ class DB(DatabaseInterface):
|
|||||||
)
|
)
|
||||||
if role.permissions:
|
if role.permissions:
|
||||||
for perm_id in set(role.permissions):
|
for perm_id in set(role.permissions):
|
||||||
session.add(
|
await session.execute(
|
||||||
RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id)
|
insert(RolePermission).values(
|
||||||
|
role_uuid=role.uuid.bytes, permission_id=perm_id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def delete_role(self, role_uuid: UUID) -> None:
|
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)
|
org_perm_result = await session.execute(org_perm_stmt)
|
||||||
organization.permissions = [row[0] for row in org_perm_result.fetchall()]
|
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(
|
return SessionContext(
|
||||||
session=session_obj,
|
session=session_obj,
|
||||||
user=user_obj,
|
user=user_obj,
|
||||||
org=organization,
|
org=organization,
|
||||||
role=role,
|
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)):
|
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
await authz.verify(auth, ["auth:admin"])
|
await authz.verify(auth, ["auth:admin"])
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
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()
|
org_uuid = uuid4()
|
||||||
display_name = payload.get("display_name") or "New Organization"
|
display_name = payload.get("display_name") or "New Organization"
|
||||||
permissions = payload.get("permissions") or []
|
permissions = payload.get("permissions") or []
|
||||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||||
await db.instance.create_organization(org)
|
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)}
|
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(
|
async def admin_update_org(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
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
|
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||||
)
|
)
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
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))
|
current = await db.instance.get_organization(str(org_uuid))
|
||||||
display_name = payload.get("display_name") or current.display_name
|
display_name = payload.get("display_name") or current.display_name
|
||||||
permissions = payload.get("permissions") or current.permissions or []
|
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)
|
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||||
await db.instance.update_organization(org)
|
await db.instance.update_organization(org)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -110,6 +136,21 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
|||||||
)
|
)
|
||||||
if ctx.org.uuid == org_uuid:
|
if ctx.org.uuid == org_uuid:
|
||||||
raise ValueError("Cannot delete the organization you belong to")
|
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)
|
await db.instance.delete_organization(org_uuid)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -139,7 +180,9 @@ async def admin_remove_org_permission(
|
|||||||
async def admin_create_role(
|
async def admin_create_role(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
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
|
from ..db import Role as RoleDC
|
||||||
|
|
||||||
role_uuid = uuid4()
|
role_uuid = uuid4()
|
||||||
@@ -166,7 +209,7 @@ async def admin_update_role(
|
|||||||
org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
):
|
):
|
||||||
# Verify caller is global admin or admin of provided org
|
# 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
|
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||||
)
|
)
|
||||||
role = await db.instance.get_role(role_uuid)
|
role = await db.instance.get_role(role_uuid)
|
||||||
@@ -175,13 +218,25 @@ async def admin_update_role(
|
|||||||
from ..db import Role as RoleDC
|
from ..db import Role as RoleDC
|
||||||
|
|
||||||
display_name = payload.get("display_name") or role.display_name
|
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))
|
org = await db.instance.get_organization(str(org_uuid))
|
||||||
grantable = set(org.permissions or [])
|
grantable = set(org.permissions or [])
|
||||||
|
existing_permissions = set(role.permissions)
|
||||||
for pid in permissions:
|
for pid in permissions:
|
||||||
await db.instance.get_permission(pid)
|
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}")
|
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(
|
updated = RoleDC(
|
||||||
uuid=role_uuid,
|
uuid=role_uuid,
|
||||||
org_uuid=org_uuid,
|
org_uuid=org_uuid,
|
||||||
@@ -194,12 +249,17 @@ async def admin_update_role(
|
|||||||
|
|
||||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||||
async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
|
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
|
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||||
)
|
)
|
||||||
role = await db.instance.get_role(role_uuid)
|
role = await db.instance.get_role(role_uuid)
|
||||||
if role.org_uuid != org_uuid:
|
if role.org_uuid != org_uuid:
|
||||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
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)
|
await db.instance.delete_role(role_uuid)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -240,7 +300,7 @@ async def admin_create_user(
|
|||||||
async def admin_update_user_role(
|
async def admin_update_user_role(
|
||||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
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
|
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||||
)
|
)
|
||||||
new_role = payload.get("role")
|
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))
|
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||||
if not any(r.display_name == new_role for r in roles):
|
if not any(r.display_name == new_role for r in roles):
|
||||||
raise ValueError("Role not found in organization")
|
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)
|
await db.instance.update_user_role_in_organization(user_uuid, new_role)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -370,14 +444,44 @@ async def admin_update_user_display_name(
|
|||||||
return {"status": "ok"}
|
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) --------------------
|
# -------------------- Permissions (global) --------------------
|
||||||
|
|
||||||
|
|
||||||
@app.get("/permissions")
|
@app.get("/permissions")
|
||||||
async def admin_list_permissions(auth=Cookie(None)):
|
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()
|
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")
|
@app.post("/permissions")
|
||||||
@@ -418,6 +522,11 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
|||||||
display_name = payload.get("display_name")
|
display_name = payload.get("display_name")
|
||||||
if not old_id or not new_id:
|
if not old_id or not new_id:
|
||||||
raise ValueError("old_id and new_id required")
|
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(old_id, field="old_id")
|
||||||
querysafe.assert_safe(new_id, field="new_id")
|
querysafe.assert_safe(new_id, field="new_id")
|
||||||
if display_name is None:
|
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)):
|
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
||||||
await authz.verify(auth, ["auth:admin"])
|
await authz.verify(auth, ["auth:admin"])
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
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)
|
await db.instance.delete_permission(permission_id)
|
||||||
return {"status": "ok"}
|
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 [])]
|
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
||||||
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
|
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
|
||||||
if org_info:
|
is_org_admin = any(
|
||||||
is_org_admin = f"auth:org:{org_info['uuid']}" in (
|
p.startswith("auth:org:") for p in (role_info["permissions"] or [])
|
||||||
role_info["permissions"] or []
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
|
|||||||
Reference in New Issue
Block a user