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
	 Leo Vasanko
					Leo Vasanko