Actually usable admin panel
This commit is contained in:
parent
4db7f2e9a6
commit
f3e3679b6d
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
@ -16,6 +16,85 @@ const userDetail = ref(null) // cached user detail object
|
||||
const userLink = ref(null) // latest generated registration link
|
||||
const userLinkExpires = ref(null)
|
||||
const authStore = useAuthStore()
|
||||
const addingOrgForPermission = ref(null)
|
||||
|
||||
function handleGlobalClick(e) {
|
||||
if (!addingOrgForPermission.value) return
|
||||
const menu = e.target.closest('.org-add-menu')
|
||||
const trigger = e.target.closest('.add-org-btn')
|
||||
if (!menu && !trigger) {
|
||||
addingOrgForPermission.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleGlobalClick)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleGlobalClick)
|
||||
})
|
||||
|
||||
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
|
||||
const permissionSummary = computed(() => {
|
||||
const summary = {}
|
||||
for (const o of orgs.value) {
|
||||
const orgBase = { uuid: o.uuid, display_name: o.display_name }
|
||||
// Org-level permissions (direct)
|
||||
for (const pid of o.permissions || []) {
|
||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||
summary[pid].orgs.push(orgBase)
|
||||
summary[pid].orgSet.add(o.uuid)
|
||||
}
|
||||
}
|
||||
// Role-based permissions (inheritance)
|
||||
for (const r of o.roles) {
|
||||
for (const pid of r.permissions) {
|
||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||
summary[pid].orgs.push(orgBase)
|
||||
summary[pid].orgSet.add(o.uuid)
|
||||
}
|
||||
summary[pid].userCount += r.users.length
|
||||
}
|
||||
}
|
||||
}
|
||||
const display = {}
|
||||
for (const [pid, v] of Object.entries(summary)) {
|
||||
display[pid] = { orgs: v.orgs.sort((a,b)=>a.display_name.localeCompare(b.display_name)), userCount: v.userCount }
|
||||
}
|
||||
return display
|
||||
})
|
||||
|
||||
function availableOrgsForPermission(pid) {
|
||||
return orgs.value.filter(o => !o.permissions.includes(pid))
|
||||
}
|
||||
|
||||
async function attachPermissionToOrg(pid, orgUuid) {
|
||||
if (!orgUuid) return
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: pid })
|
||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to add permission to org')
|
||||
}
|
||||
}
|
||||
|
||||
async function detachPermissionFromOrg(pid, orgUuid) {
|
||||
if (!confirm('Remove permission from this org?')) return
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: pid })
|
||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to remove permission from org')
|
||||
}
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
const h = window.location.hash || ''
|
||||
@ -88,7 +167,7 @@ async function createOrg() {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
await Promise.all([loadOrgs(), loadPermissions()])
|
||||
}
|
||||
|
||||
async function updateOrg(org) {
|
||||
@ -229,11 +308,8 @@ async function createPermission() {
|
||||
async function updatePermission(p) {
|
||||
const name = prompt('Permission display name:', p.display_name)
|
||||
if (!name) return
|
||||
const res = await fetch(`/auth/admin/permissions/${encodeURIComponent(p.id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name })
|
||||
})
|
||||
const params = new URLSearchParams({ permission_id: p.id, display_name: name })
|
||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadPermissions()
|
||||
@ -241,7 +317,8 @@ async function updatePermission(p) {
|
||||
|
||||
async function deletePermission(p) {
|
||||
if (!confirm(`Delete permission ${p.id}?`)) return
|
||||
const res = await fetch(`/auth/admin/permissions/${encodeURIComponent(p.id)}`, { method: 'DELETE' })
|
||||
const params = new URLSearchParams({ permission_id: p.id })
|
||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadPermissions()
|
||||
@ -340,8 +417,6 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
<a href="/auth/" class="back-link" title="Back to User App">User</a>
|
||||
<a v-if="selectedOrg && info?.is_global_admin" @click.prevent="goOverview" href="#overview" class="nav-link" title="Back to overview">Overview</a>
|
||||
</h1>
|
||||
<p class="subtitle" v-if="!selectedUser">Manage organizations, roles, and permissions</p>
|
||||
|
||||
<div v-if="loading">Loading…</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else>
|
||||
@ -418,18 +493,13 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
||||
<button @click="updateOrg(selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
</h2>
|
||||
<div class="org-actions">
|
||||
<button @click="deleteOrg(selectedOrg)" v-if="info.is_global_admin" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||
<button @click="createRole(selectedOrg)">+ Role</button>
|
||||
<button @click="goOverview" v-if="info.is_global_admin">Back</button>
|
||||
</div>
|
||||
<div class="org-actions"></div>
|
||||
|
||||
<div class="matrix-wrapper">
|
||||
<h3>Permissions Matrix</h3>
|
||||
<div class="matrix-scroll">
|
||||
<div
|
||||
class="perm-matrix-grid"
|
||||
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') }"
|
||||
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
|
||||
>
|
||||
<!-- Headers -->
|
||||
<div class="grid-head perm-head">Permission</div>
|
||||
@ -441,6 +511,7 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
>
|
||||
<span>{{ r.display_name }}</span>
|
||||
</div>
|
||||
<div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button">➕</div>
|
||||
|
||||
<!-- Data Rows -->
|
||||
<template v-for="pid in selectedOrg.permissions" :key="pid">
|
||||
@ -456,6 +527,7 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
@change="e => toggleRolePermission(r, pid, e.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
<div class="matrix-cell add-role-cell" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@ -507,14 +579,57 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
<div class="actions">
|
||||
<button @click="createPermission">+ Create Permission</button>
|
||||
</div>
|
||||
<div v-for="p in permissions" :key="p.id" class="perm" :title="p.id">
|
||||
<div class="perm-name-line">
|
||||
<span>{{ p.display_name }}</span>
|
||||
<button @click="updatePermission(p)" class="icon-btn" aria-label="Rename permission" title="Rename permission">✏️</button>
|
||||
<div class="permission-grid">
|
||||
<div class="perm-grid-head">Permission</div>
|
||||
<div class="perm-grid-head">Orgs</div>
|
||||
<div class="perm-grid-head center">Members</div>
|
||||
<div class="perm-grid-head center">Actions</div>
|
||||
<template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id">
|
||||
<div class="perm-cell perm-name" :title="p.id">
|
||||
<span class="perm-title">{{ p.display_name }}</span>
|
||||
<span class="perm-id muted">({{ p.id }})</span>
|
||||
</div>
|
||||
<div class="perm-actions">
|
||||
<div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''">
|
||||
<template v-if="permissionSummary[p.id]">
|
||||
<span class="org-pill" v-for="o in permissionSummary[p.id].orgs" :key="o.uuid">
|
||||
{{ o.display_name }}
|
||||
<button class="pill-x" @click.stop="detachPermissionFromOrg(p.id, o.uuid)" aria-label="Remove">×</button>
|
||||
</span>
|
||||
</template>
|
||||
<span class="org-add-wrapper">
|
||||
<button
|
||||
v-if="availableOrgsForPermission(p.id).length && addingOrgForPermission !== p.id"
|
||||
class="add-org-btn"
|
||||
@click.stop="addingOrgForPermission = p.id"
|
||||
aria-label="Add organization"
|
||||
title="Add organization"
|
||||
>➕</button>
|
||||
<div
|
||||
v-if="addingOrgForPermission === p.id"
|
||||
class="org-add-menu"
|
||||
tabindex="0"
|
||||
@keydown.escape.stop.prevent="addingOrgForPermission = null"
|
||||
>
|
||||
<div class="org-add-list">
|
||||
<button
|
||||
v-for="o in availableOrgsForPermission(p.id)"
|
||||
:key="o.uuid"
|
||||
class="org-add-item"
|
||||
@click.stop="attachPermissionToOrg(p.id, o.uuid); addingOrgForPermission = null"
|
||||
>{{ o.display_name }}</button>
|
||||
</div>
|
||||
<div class="org-add-footer">
|
||||
<button class="org-add-cancel" @click.stop="addingOrgForPermission = null" aria-label="Cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
|
||||
<div class="perm-cell perm-actions center">
|
||||
<button @click="updatePermission(p)" class="icon-btn" aria-label="Rename permission" title="Rename permission">✏️</button>
|
||||
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -544,8 +659,10 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
.pill-x { background: transparent; border: none; color: #900; cursor: pointer }
|
||||
button { padding: .25rem .5rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer }
|
||||
button:hover { background: #f7f7f7 }
|
||||
.roles-grid { display: flex; gap: 1rem; align-items: stretch; overflow-x: auto; padding: .5rem 0 }
|
||||
.role-column { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: .5rem; min-width: 200px; flex: 0 0 240px; display: flex; flex-direction: column; }
|
||||
/* Avoid global button 100% width from frontend main styles */
|
||||
button, .perm-actions button, .org-actions button, .role-actions button { width: auto; }
|
||||
.roles-grid { display: flex; flex-wrap: wrap; gap: 1rem; align-items: stretch; padding: .5rem 0; }
|
||||
.role-column { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: .5rem; min-width: 200px; flex: 1 1 240px; display: flex; flex-direction: column; max-width: 300px; }
|
||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .25rem }
|
||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25rem; flex: 1 1 auto; }
|
||||
.user-chip { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: .25rem .4rem; display: flex; justify-content: space-between; gap: .5rem; cursor: grab; }
|
||||
@ -573,6 +690,10 @@ button:hover { background: #f7f7f7 }
|
||||
.perm-matrix-grid .matrix-cell { display: flex; justify-content: center; align-items: center; }
|
||||
.perm-matrix-grid .matrix-cell input { cursor: pointer; }
|
||||
.matrix-hint { font-size: .7rem; margin-top: .25rem; }
|
||||
/* Add role column styles */
|
||||
.add-role-head { cursor: pointer; color: #2a6; font-size: 1rem; display:flex; justify-content:center; align-items:flex-end; }
|
||||
.add-role-head:hover { color:#1c4; }
|
||||
/* Removed add-role placeholder styles */
|
||||
/* Inline organization title with icon */
|
||||
.org-title { display: flex; align-items: center; gap: .4rem; }
|
||||
.org-title .org-name { flex: 0 1 auto; }
|
||||
@ -580,7 +701,7 @@ button:hover { background: #f7f7f7 }
|
||||
.plus-btn { background: none; border: none; font-size: 1.15rem; line-height: 1; padding: 0 .1rem; cursor: pointer; opacity: .6; }
|
||||
.plus-btn:hover, .plus-btn:focus { opacity: 1; outline: none; }
|
||||
.plus-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; }
|
||||
.empty-role { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; padding: .35rem .25rem; flex: 1 1 auto; width: 100%; }
|
||||
.empty-role { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; padding: .35rem .25rem; /* removed flex grow & width for natural size */ }
|
||||
.empty-role .empty-text { font-size: .7rem; margin: 0; }
|
||||
.delete-icon { color: #c00; }
|
||||
.delete-icon:hover, .delete-icon:focus { color: #ff0000; }
|
||||
@ -601,4 +722,33 @@ button:hover { background: #f7f7f7 }
|
||||
.cred-item { background: #fff; border: 1px solid #eee; border-radius: 6px; padding: .35rem .5rem; font-size: .65rem; }
|
||||
.cred-line { display: flex; flex-direction: column; gap: .15rem; }
|
||||
.cred-line .dates { color: #555; font-size: .6rem; }
|
||||
/* Permission grid */
|
||||
.permission-grid { display: grid; grid-template-columns: minmax(220px,2fr) minmax(160px,3fr) 70px 90px; gap: 2px; margin-top: .5rem; }
|
||||
.permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; }
|
||||
.permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; }
|
||||
.permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; }
|
||||
.permission-grid .perm-title { font-weight: 600; }
|
||||
.permission-grid .perm-id { font-size: .55rem; }
|
||||
.permission-grid .center { justify-content: center; }
|
||||
.permission-grid .perm-actions { gap: .25rem; }
|
||||
.permission-grid .perm-actions .icon-btn { font-size: .9rem; }
|
||||
/* Org pill editing */
|
||||
.perm-orgs { flex-wrap: wrap; gap: .25rem; }
|
||||
.perm-orgs .org-pill { background:#eef4ff; border:1px solid #d0dcf0; padding:2px 6px; border-radius:999px; font-size:.55rem; display:inline-flex; align-items:center; gap:4px; }
|
||||
.perm-orgs .org-pill .pill-x { background:none; border:none; cursor:pointer; font-size:.7rem; line-height:1; padding:0; margin:0; color:#555; }
|
||||
.perm-orgs .org-pill .pill-x:hover { color:#c00; }
|
||||
.add-org-btn { background:none; border:none; cursor:pointer; font-size:.7rem; padding:0 2px; line-height:1; opacity:.55; display:inline; }
|
||||
.add-org-btn:hover, .add-org-btn:focus { opacity:1; }
|
||||
.add-org-btn:focus-visible { outline:2px solid #555; outline-offset:2px; }
|
||||
.org-add-wrapper { position:relative; display:inline-block; }
|
||||
.org-add-menu { position:absolute; top:100%; left:0; z-index:20; margin-top:4px; min-width:160px; background:#fff; border:1px solid #e2e6ea; border-radius:6px; padding:.3rem .35rem; box-shadow:0 4px 10px rgba(0,0,0,.08); display:flex; flex-direction:column; gap:.25rem; font-size:.6rem; }
|
||||
.org-add-menu:before { content:""; position:absolute; top:-5px; left:10px; width:8px; height:8px; background:#fff; border-left:1px solid #e2e6ea; border-top:1px solid #e2e6ea; transform:rotate(45deg); }
|
||||
.org-add-list { display:flex; flex-direction:column; gap:0; max-height:180px; overflow-y:auto; scrollbar-width:thin; }
|
||||
.org-add-item { background:transparent; border:none; padding:.25rem .4rem; font-size:.6rem; border-radius:4px; cursor:pointer; line-height:1.1; text-align:left; width:100%; color:#222; }
|
||||
.org-add-item:hover, .org-add-item:focus { background:#f2f5f9; }
|
||||
.org-add-item:active { background:#e6ebf0; }
|
||||
.org-add-footer { margin-top:.25rem; display:flex; justify-content:flex-end; }
|
||||
.org-add-cancel { background:transparent; border:none; font-size:.55rem; padding:.15rem .35rem; cursor:pointer; color:#666; border-radius:4px; }
|
||||
.org-add-cancel:hover, .org-add-cancel:focus { background:#f2f5f9; color:#222; }
|
||||
.org-add-cancel:active { background:#e6ebf0; }
|
||||
</style>
|
||||
|
@ -241,27 +241,21 @@ def register_api_routes(app: FastAPI):
|
||||
await db.instance.delete_organization(org_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Manage an org's grantable permissions
|
||||
@app.post("/auth/admin/orgs/{org_uuid}/permissions/{permission_id}")
|
||||
async def admin_add_org_permission(
|
||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||
):
|
||||
# Manage an org's grantable permissions (query param for permission_id)
|
||||
@app.post("/auth/admin/orgs/{org_uuid}/permission")
|
||||
async def admin_add_org_permission(org_uuid: UUID, permission_id: str, auth=Cookie(None)):
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||
raise ValueError("Insufficient permissions")
|
||||
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.delete("/auth/admin/orgs/{org_uuid}/permissions/{permission_id}")
|
||||
async def admin_remove_org_permission(
|
||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||
):
|
||||
@app.delete("/auth/admin/orgs/{org_uuid}/permission")
|
||||
async def admin_remove_org_permission(org_uuid: UUID, permission_id: str, auth=Cookie(None)):
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||
raise ValueError("Insufficient permissions")
|
||||
await db.instance.remove_permission_from_organization(
|
||||
str(org_uuid), permission_id
|
||||
)
|
||||
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
# -------------------- Admin API: Roles --------------------
|
||||
@ -471,7 +465,7 @@ def register_api_routes(app: FastAPI):
|
||||
"aaguid_info": aaguid_info,
|
||||
}
|
||||
|
||||
# -------------------- Admin API: Permissions (global) --------------------
|
||||
# Admin API: Permissions (global)
|
||||
|
||||
@app.get("/auth/admin/permissions")
|
||||
async def admin_list_permissions(auth=Cookie(None)):
|
||||
@ -497,24 +491,18 @@ def register_api_routes(app: FastAPI):
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.put("/auth/admin/permissions/{permission_id}")
|
||||
async def admin_update_permission(
|
||||
permission_id: str, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
@app.put("/auth/admin/permission")
|
||||
async def admin_update_permission(permission_id: str, display_name: str, auth=Cookie(None)):
|
||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||
if not is_global_admin:
|
||||
raise ValueError("Global admin required")
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
display_name = payload.get("display_name")
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
await db.instance.update_permission(
|
||||
PermDC(id=permission_id, display_name=display_name)
|
||||
)
|
||||
await db.instance.update_permission(PermDC(id=permission_id, display_name=display_name))
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.delete("/auth/admin/permissions/{permission_id}")
|
||||
@app.delete("/auth/admin/permission")
|
||||
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||
if not is_global_admin:
|
||||
|
Loading…
x
Reference in New Issue
Block a user