Almost usable admin panel
This commit is contained in:
parent
efdfa77fc9
commit
4db7f2e9a6
@ -1,17 +1,46 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const info = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const orgs = ref([])
|
||||
const permissions = ref([])
|
||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||
const currentUserId = ref(null) // UUID for user detail view
|
||||
const userDetail = ref(null) // cached user detail object
|
||||
const userLink = ref(null) // latest generated registration link
|
||||
const userLinkExpires = ref(null)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function parseHash() {
|
||||
const h = window.location.hash || ''
|
||||
currentOrgId.value = null
|
||||
currentUserId.value = null
|
||||
if (h.startsWith('#org/')) {
|
||||
currentOrgId.value = h.slice(5)
|
||||
} else if (h.startsWith('#user/')) {
|
||||
currentUserId.value = h.slice(6)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
const res = await fetch('/auth/admin/orgs')
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
orgs.value = data
|
||||
// Restructure to attach users to roles instead of flat user list at org level
|
||||
orgs.value = data.map(o => {
|
||||
const roles = o.roles.map(r => ({ ...r, users: [] }))
|
||||
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
||||
for (const u of o.users || []) {
|
||||
if (roleMap[u.role]) roleMap[u.role].users.push(u)
|
||||
}
|
||||
return { ...o, roles }
|
||||
})
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
@ -32,6 +61,15 @@ async function load() {
|
||||
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
|
||||
await Promise.all([loadOrgs(), loadPermissions()])
|
||||
}
|
||||
// After loading orgs decide view if not global admin
|
||||
if (!data.is_global_admin && data.is_org_admin && orgs.value.length === 1) {
|
||||
if (!window.location.hash || window.location.hash === '#overview') {
|
||||
currentOrgId.value = orgs.value[0].uuid
|
||||
window.location.hash = `#org/${currentOrgId.value}`
|
||||
} else {
|
||||
parseHash()
|
||||
}
|
||||
} else parseHash()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
@ -74,6 +112,51 @@ async function deleteOrg(org) {
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
async function createUserInRole(org, role) {
|
||||
const displayName = prompt(`New member display name for role "${role.display_name}":`)
|
||||
if (!displayName) return
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: displayName, role: role.display_name })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
if (user.role === targetRoleDisplayName) return
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ role: targetRoleDisplayName })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
function onUserDragStart(e, user, org_uuid) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org_uuid }))
|
||||
}
|
||||
|
||||
function onRoleDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onRoleDrop(e, org, role) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
||||
if (data.org_uuid !== org.uuid) return // only within same org
|
||||
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
|
||||
if (user) moveUserToRole(org, user, role.display_name)
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function addOrgPermission(org) {
|
||||
const id = prompt('Permission ID to add:', permissions.value[0]?.id || '')
|
||||
if (!id) return
|
||||
@ -94,12 +177,10 @@ async function removeOrgPermission(org, permId) {
|
||||
async function createRole(org) {
|
||||
const name = prompt('New role display name:')
|
||||
if (!name) return
|
||||
const csv = prompt('Permission IDs (comma-separated):', '') || ''
|
||||
const perms = csv.split(',').map(s => s.trim()).filter(Boolean)
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name, permissions: perms })
|
||||
body: JSON.stringify({ display_name: name, permissions: [] })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
@ -166,13 +247,100 @@ async function deletePermission(p) {
|
||||
await loadPermissions()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
window.addEventListener('hashchange', parseHash)
|
||||
load()
|
||||
})
|
||||
|
||||
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||
|
||||
function openOrg(o) {
|
||||
window.location.hash = `#org/${o.uuid}`
|
||||
}
|
||||
|
||||
function goOverview() {
|
||||
window.location.hash = '#overview'
|
||||
}
|
||||
|
||||
function openUser(u) {
|
||||
window.location.hash = `#user/${u.uuid}`
|
||||
}
|
||||
|
||||
const selectedUser = computed(() => {
|
||||
if (!currentUserId.value) return null
|
||||
for (const o of orgs.value) {
|
||||
for (const r of o.roles) {
|
||||
const u = r.users.find(x => x.uuid === currentUserId.value)
|
||||
if (u) return { ...u, org_uuid: o.uuid, role_display_name: r.display_name }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
watch(selectedUser, async (u) => {
|
||||
if (!u) { userDetail.value = null; return }
|
||||
try {
|
||||
const res = await fetch(`/auth/admin/users/${u.uuid}`)
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
userDetail.value = data
|
||||
} catch (e) {
|
||||
userDetail.value = { error: e.message }
|
||||
}
|
||||
})
|
||||
|
||||
const showRegModal = ref(false)
|
||||
function generateUserRegistrationLink(u) {
|
||||
showRegModal.value = true
|
||||
}
|
||||
|
||||
function onLinkCopied() {
|
||||
authStore.showMessage('Link copied to clipboard!')
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text)
|
||||
.catch(()=>{})
|
||||
}
|
||||
|
||||
function permissionDisplayName(id) {
|
||||
return permissions.value.find(p => p.id === id)?.display_name || id
|
||||
}
|
||||
|
||||
async function toggleRolePermission(role, permId, checked) {
|
||||
// Build next permission list
|
||||
const has = role.permissions.includes(permId)
|
||||
if (checked && has) return
|
||||
if (!checked && !has) return
|
||||
const next = checked ? [...role.permissions, permId] : role.permissions.filter(p => p !== permId)
|
||||
// Optimistic update
|
||||
const prev = [...role.permissions]
|
||||
role.permissions = next
|
||||
try {
|
||||
const res = await fetch(`/auth/admin/roles/${role.uuid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: role.display_name, permissions: next })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to update role permission')
|
||||
role.permissions = prev // revert
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Passkey Admin <a href="/auth/" class="back-link" title="Back to User App">User</a></h1>
|
||||
<p class="subtitle">Manage organizations, roles, and permissions</p>
|
||||
<h1 v-if="!selectedUser">
|
||||
<template v-if="!selectedOrg">Passkey Admin</template>
|
||||
<template v-else>Organization Admin</template>
|
||||
<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>
|
||||
@ -185,99 +353,174 @@ onMounted(load)
|
||||
</div>
|
||||
<div v-else>
|
||||
|
||||
<div class="card">
|
||||
<h2>Organization</h2>
|
||||
<div>{{ info.org?.display_name }}</div>
|
||||
<div>Role permissions: {{ info.role?.permissions?.join(', ') }}</div>
|
||||
<div>Org grantable: {{ info.org?.permissions?.join(', ') }}</div>
|
||||
</div>
|
||||
<!-- Removed user-specific info (current org, effective permissions, admin flags) -->
|
||||
|
||||
<div class="card">
|
||||
<h2>Permissions</h2>
|
||||
<div>Effective: {{ info.permissions?.join(', ') }}</div>
|
||||
<div>Global admin: {{ info.is_global_admin ? 'yes' : 'no' }}</div>
|
||||
<div>Org admin: {{ info.is_org_admin ? 'yes' : 'no' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="info.is_global_admin || info.is_org_admin" class="card">
|
||||
<!-- Overview Page -->
|
||||
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
|
||||
<h2>Organizations</h2>
|
||||
<div class="actions">
|
||||
<button @click="createOrg" v-if="info.is_global_admin">+ Create Org</button>
|
||||
</div>
|
||||
<div v-for="o in orgs" :key="o.uuid" class="org">
|
||||
<div class="org-header">
|
||||
<strong>{{ o.display_name }}</strong>
|
||||
</div>
|
||||
<div class="org-actions">
|
||||
<button @click="updateOrg(o)">Edit</button>
|
||||
<button @click="deleteOrg(o)" v-if="info.is_global_admin">Delete</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted">Grantable permissions:</div>
|
||||
<div class="pill-list">
|
||||
<span v-for="p in o.permissions" :key="p" class="pill" :title="p">
|
||||
{{ permissions.find(x => x.id === p)?.display_name || p }}
|
||||
<button class="pill-x" @click="removeOrgPermission(o, p)" :title="'Remove ' + p">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="addOrgPermission(o)" title="Add permission by ID">+ Add permission</button>
|
||||
</div>
|
||||
<div class="roles">
|
||||
<div class="muted">Roles:</div>
|
||||
<div v-for="r in o.roles" :key="r.uuid" class="role-item">
|
||||
<div>
|
||||
<strong>{{ r.display_name }}</strong>
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Members</th>
|
||||
<th v-if="info.is_global_admin">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orgs" :key="o.uuid">
|
||||
<td><a href="#org/{{o.uuid}}" @click.prevent="openOrg(o)">{{ o.display_name }}</a></td>
|
||||
<td>{{ o.roles.length }}</td>
|
||||
<td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
||||
<td v-if="info.is_global_admin">
|
||||
<button @click="updateOrg(o)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
<button @click="deleteOrg(o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- User Detail Page -->
|
||||
<div v-if="selectedUser" class="card user-detail">
|
||||
<h2 class="user-title"><span>{{ userDetail?.display_name || selectedUser.display_name }}</span></h2>
|
||||
<div v-if="userDetail && !userDetail.error" class="user-meta">
|
||||
<p class="small">Organization: {{ userDetail.org.display_name }}</p>
|
||||
<p class="small">Role: {{ userDetail.role }}</p>
|
||||
<p class="small">Visits: {{ userDetail.visits }}</p>
|
||||
<p class="small">Created: {{ userDetail.created_at ? new Date(userDetail.created_at).toLocaleString() : '—' }}</p>
|
||||
<p class="small">Last Seen: {{ userDetail.last_seen ? new Date(userDetail.last_seen).toLocaleString() : '—' }}</p>
|
||||
<h3 class="cred-title">Registered Passkeys</h3>
|
||||
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" />
|
||||
</div>
|
||||
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||
<div class="actions">
|
||||
<button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button>
|
||||
<button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button>
|
||||
<button @click="openOrg(selectedOrg)" v-if="selectedOrg" class="icon-btn" title="Back to Org">↩️</button>
|
||||
</div>
|
||||
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/admin/users/${selectedUser.uuid}/create-link`"
|
||||
:auto-copy="false"
|
||||
@close="showRegModal = false"
|
||||
@copied="onLinkCopied"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Organization Detail Page -->
|
||||
<div v-else-if="selectedOrg" class="card">
|
||||
<h2 class="org-title" :title="selectedOrg.uuid">
|
||||
<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="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(' ') }"
|
||||
>
|
||||
<!-- Headers -->
|
||||
<div class="grid-head perm-head">Permission</div>
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
:key="'head-' + r.uuid"
|
||||
class="grid-head role-head"
|
||||
:title="r.display_name"
|
||||
>
|
||||
<span>{{ r.display_name }}</span>
|
||||
</div>
|
||||
<strong :title="r.uuid">{{ r.display_name }}</strong>
|
||||
|
||||
<!-- Data Rows -->
|
||||
<template v-for="pid in selectedOrg.permissions" :key="pid">
|
||||
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
:key="r.uuid + '-' + pid"
|
||||
class="matrix-cell"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="r.permissions.includes(pid)"
|
||||
@change="e => toggleRolePermission(r, pid, e.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
|
||||
</div>
|
||||
<div class="roles-grid">
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
:key="r.uuid"
|
||||
class="role-column"
|
||||
@dragover="onRoleDragOver"
|
||||
@drop="e => onRoleDrop(e, selectedOrg, r)"
|
||||
>
|
||||
<div class="role-header">
|
||||
<strong class="role-name" :title="r.uuid">
|
||||
<span>{{ r.display_name }}</span>
|
||||
<button @click="updateRole(r)" class="icon-btn" aria-label="Rename role" title="Rename role">✏️</button>
|
||||
</strong>
|
||||
<div class="role-actions">
|
||||
<button @click="updateRole(r)">Edit</button>
|
||||
<button @click="deleteRole(r)">Delete</button>
|
||||
<button @click="createUserInRole(selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="createRole(o)">+ Create role</button>
|
||||
</div>
|
||||
<div class="users" v-if="o.users?.length">
|
||||
<div class="muted">Users:</div>
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Visits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in o.users" :key="u.uuid" :title="u.uuid">
|
||||
<td>{{ u.display_name }}</td>
|
||||
<td>{{ u.role }}</td>
|
||||
<td>{{ u.last_seen ? new Date(u.last_seen).toLocaleString() : '—' }}</td>
|
||||
<td>{{ u.visits }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-if="r.users.length > 0">
|
||||
<ul class="user-list">
|
||||
<li
|
||||
v-for="u in r.users"
|
||||
:key="u.uuid"
|
||||
class="user-chip"
|
||||
draggable="true"
|
||||
@dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)"
|
||||
@click="openUser(u)"
|
||||
:title="u.uuid"
|
||||
>
|
||||
<span class="name">{{ u.display_name }}</span>
|
||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div v-else class="empty-role">
|
||||
<p class="empty-text muted">No members</p>
|
||||
<button @click="deleteRole(r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="info.is_global_admin || info.is_org_admin" class="card">
|
||||
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
|
||||
<h2>All Permissions</h2>
|
||||
<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>
|
||||
{{ p.display_name }}
|
||||
<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>
|
||||
<div class="perm-actions">
|
||||
<button @click="updatePermission(p)">Edit</button>
|
||||
<button @click="deletePermission(p)">Delete</button>
|
||||
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusMessage />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@ -301,6 +544,61 @@ onMounted(load)
|
||||
.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; }
|
||||
.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; }
|
||||
.user-chip:active { cursor: grabbing }
|
||||
.user-chip .name { font-weight: 500 }
|
||||
.user-chip .meta { font-size: .65rem; color: #666 }
|
||||
.role-column.drag-over { outline: 2px dashed #66a; }
|
||||
.org-table { width: 100%; border-collapse: collapse; }
|
||||
.org-table th, .org-table td { padding: .4rem .5rem; border-bottom: 1px solid #eee; text-align: left; }
|
||||
.org-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; }
|
||||
.org-table a { text-decoration: none; color: #0366d6; }
|
||||
.org-table a:hover { text-decoration: underline; }
|
||||
.nav-link { font-size: .6em; margin-left: .5rem; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; text-decoration: none; }
|
||||
.nav-link:hover { background: #ddd; }
|
||||
.back-link { font-size: .5em; margin-left: .75rem; text-decoration: none; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; vertical-align: middle; line-height: 1.2; }
|
||||
.back-link:hover { background: #ddd; }
|
||||
.matrix-wrapper { margin: 1rem 0; text-align: left; }
|
||||
.matrix-scroll { overflow-x: auto; text-align: left; }
|
||||
.perm-matrix-grid { display: inline-grid; gap: 0; align-items: stretch; margin-right: 4rem; }
|
||||
.perm-matrix-grid > * { background: #fff; border: none; padding: .35rem .4rem; font-size: .75rem; }
|
||||
.perm-matrix-grid .grid-head { background: transparent; border: none; font-size: .65rem; letter-spacing: .05em; font-weight: 600; text-transform: uppercase; display: flex; justify-content: center; align-items: flex-end; padding-bottom: .25rem; }
|
||||
.perm-matrix-grid .perm-head { justify-content: flex-start; align-items: flex-end; }
|
||||
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: .6rem; line-height: 1; }
|
||||
.perm-matrix-grid .perm-name { font-weight: 500; white-space: nowrap; text-align: left; }
|
||||
.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; }
|
||||
/* Inline organization title with icon */
|
||||
.org-title { display: flex; align-items: center; gap: .4rem; }
|
||||
.org-title .org-name { flex: 0 1 auto; }
|
||||
/* Plus button for adding users */
|
||||
.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 .empty-text { font-size: .7rem; margin: 0; }
|
||||
.delete-icon { color: #c00; }
|
||||
.delete-icon:hover, .delete-icon:focus { color: #ff0000; }
|
||||
.user-detail .user-link-box { margin-top: .75rem; font-size: .7rem; background: #fff; border: 1px dashed #ccc; padding: .5rem; border-radius: 6px; cursor: pointer; word-break: break-all; }
|
||||
.user-detail .user-link-box:hover { background: #f9f9f9; }
|
||||
.user-detail .user-link-box .expires { font-size: .6rem; margin-top: .25rem; color: #555; }
|
||||
/* Minimal icon button for rename/edit actions */
|
||||
.icon-btn { background: none; border: none; padding: 0 .15rem; margin-left: .15rem; cursor: pointer; font-size: .8rem; line-height: 1; opacity: .55; vertical-align: middle; }
|
||||
.icon-btn:hover, .icon-btn:focus { opacity: .95; outline: none; }
|
||||
.icon-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; }
|
||||
.icon-btn:active { transform: translateY(1px); }
|
||||
.org-title { display: flex; align-items: baseline; gap: .25rem; }
|
||||
.role-name { display: inline-flex; align-items: center; gap: .15rem; font-weight: 600; }
|
||||
.perm-name-line { display: flex; align-items: center; gap: .15rem; }
|
||||
.user-meta { margin-top: .25rem; }
|
||||
.cred-title { margin-top: .75rem; font-size: .85rem; }
|
||||
.cred-list { list-style: none; padding: 0; margin: .25rem 0 .5rem; display: flex; flex-direction: column; gap: .35rem; }
|
||||
.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; }
|
||||
</style>
|
||||
|
@ -1,6 +1,9 @@
|
||||
import '../assets/style.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import AdminApp from './AdminApp.vue'
|
||||
|
||||
createApp(AdminApp).mount('#admin-app')
|
||||
const app = createApp(AdminApp)
|
||||
app.use(createPinia())
|
||||
app.mount('#admin-app')
|
||||
|
84
frontend/src/components/CredentialList.vue
Normal file
84
frontend/src/components/CredentialList.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="credential-list">
|
||||
<div v-if="loading"><p>Loading credentials...</p></div>
|
||||
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="credential in credentials"
|
||||
:key="credential.credential_uuid"
|
||||
:class="['credential-item', { 'current-session': credential.is_current_session }]"
|
||||
>
|
||||
<div class="credential-header">
|
||||
<div class="credential-icon">
|
||||
<img
|
||||
v-if="getCredentialAuthIcon(credential)"
|
||||
:src="getCredentialAuthIcon(credential)"
|
||||
:alt="getCredentialAuthName(credential)"
|
||||
class="auth-icon"
|
||||
width="32"
|
||||
height="32"
|
||||
>
|
||||
<span v-else class="auth-emoji">🔑</span>
|
||||
</div>
|
||||
<div class="credential-info">
|
||||
<h4>{{ getCredentialAuthName(credential) }}</h4>
|
||||
</div>
|
||||
<div class="credential-dates">
|
||||
<span class="date-label">Created:</span>
|
||||
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
||||
<span class="date-label" v-if="credential.last_used">Last used:</span>
|
||||
<span class="date-value" v-if="credential.last_used">{{ formatDate(credential.last_used) }}</span>
|
||||
</div>
|
||||
<div class="credential-actions" v-if="allowDelete">
|
||||
<button
|
||||
@click="$emit('delete', credential)"
|
||||
class="btn-delete-credential"
|
||||
:disabled="credential.is_current_session"
|
||||
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'"
|
||||
>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
credentials: { type: Array, default: () => [] },
|
||||
aaguidInfo: { type: Object, default: () => ({}) },
|
||||
loading: { type: Boolean, default: false },
|
||||
allowDelete: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const getCredentialAuthName = (credential) => {
|
||||
const info = props.aaguidInfo?.[credential.aaguid]
|
||||
return info ? info.name : 'Unknown Authenticator'
|
||||
}
|
||||
|
||||
const getCredentialAuthIcon = (credential) => {
|
||||
const info = props.aaguidInfo?.[credential.aaguid]
|
||||
if (!info) return null
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
||||
return info[iconKey] || null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.credential-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .5rem; }
|
||||
.credential-item { border: 1px solid #ddd; border-radius: 8px; padding: .5rem .75rem; background: #fff; }
|
||||
.credential-header { display: flex; align-items: center; gap: 1rem; }
|
||||
.credential-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
|
||||
.auth-icon { border-radius: 6px; }
|
||||
.credential-info { flex: 1 1 auto; }
|
||||
.credential-info h4 { margin: 0; font-size: .9rem; }
|
||||
.credential-dates { display: grid; grid-auto-flow: column; gap: .4rem; font-size: .65rem; align-items: center; }
|
||||
.date-label { font-weight: 600; }
|
||||
.credential-actions { margin-left: auto; }
|
||||
.btn-delete-credential { background: none; border: none; cursor: pointer; font-size: .9rem; }
|
||||
.btn-delete-credential:disabled { opacity: .3; cursor: not-allowed; }
|
||||
</style>
|
87
frontend/src/components/RegistrationLinkModal.vue
Normal file
87
frontend/src/components/RegistrationLinkModal.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
|
||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
||||
<h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2>
|
||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button>
|
||||
</div>
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
<p>{{ displayUrl }}</p>
|
||||
</a>
|
||||
<div v-else>
|
||||
<em>Generating link...</em>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Scan and visit the URL on another device.</strong><br>
|
||||
<small>⚠️ Expires in 24 hours and one-time use.</small>
|
||||
</p>
|
||||
<div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:10px;">
|
||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
||||
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true }, // POST endpoint returning {url, expires}
|
||||
autoCopy: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close','generated','copied'])
|
||||
|
||||
const url = ref(null)
|
||||
const expires = ref(null)
|
||||
const qrCanvas = ref(null)
|
||||
|
||||
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
|
||||
|
||||
async function fetchLink() {
|
||||
try {
|
||||
const res = await fetch(props.endpoint, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
url.value = data.url
|
||||
expires.value = data.expires
|
||||
emit('generated', { url: data.url, expires: data.expires })
|
||||
await nextTick()
|
||||
drawQR()
|
||||
if (props.autoCopy) copy()
|
||||
} catch (e) {
|
||||
url.value = null
|
||||
expires.value = null
|
||||
console.error('Failed to create link', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function drawQR() {
|
||||
if (!url.value) return
|
||||
await nextTick()
|
||||
if (!qrCanvas.value) return
|
||||
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => { if (err) console.error(err) })
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url.value) return
|
||||
try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(fetchLink)
|
||||
watch(url, () => drawQR(), { flush: 'post' })
|
||||
</script>
|
||||
<style scoped>
|
||||
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
|
||||
.icon-btn:hover { opacity:1; }
|
||||
/* Minimal extra styling; main look comes from global styles */
|
||||
.qr-link { text-decoration:none; color:inherit; }
|
||||
</style>
|
@ -205,6 +205,10 @@ class DatabaseInterface(ABC):
|
||||
async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]:
|
||||
"""Get all users in an organization with their roles."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_roles_by_organization(self, org_id: str) -> list[Role]:
|
||||
"""List roles belonging to an organization."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_role_in_organization(
|
||||
self, user_uuid: UUID, org_id: str
|
||||
|
@ -864,6 +864,25 @@ class DB(DatabaseInterface):
|
||||
r_dc.permissions = [row[0] for row in perms_result.fetchall()]
|
||||
return r_dc
|
||||
|
||||
async def get_roles_by_organization(self, org_id: str) -> list[Role]:
|
||||
async with self.session() as session:
|
||||
org_uuid = UUID(org_id)
|
||||
result = await session.execute(
|
||||
select(RoleModel).where(RoleModel.org_uuid == org_uuid.bytes)
|
||||
)
|
||||
role_models = result.scalars().all()
|
||||
roles: list[Role] = []
|
||||
for rm in role_models:
|
||||
r_dc = rm.as_dataclass()
|
||||
perms_result = await session.execute(
|
||||
select(RolePermission.permission_id).where(
|
||||
RolePermission.role_uuid == rm.uuid
|
||||
)
|
||||
)
|
||||
r_dc.permissions = [row[0] for row in perms_result.fetchall()]
|
||||
roles.append(r_dc)
|
||||
return roles
|
||||
|
||||
async def add_permission_to_organization(
|
||||
self, org_id: str, permission_id: str
|
||||
) -> None:
|
||||
|
@ -16,8 +16,10 @@ from fastapi.security import HTTPBearer
|
||||
from passkey.util import passphrase
|
||||
|
||||
from .. import aaguid
|
||||
from ..authsession import delete_credential, get_reset, get_session
|
||||
from ..authsession import delete_credential, expires, get_reset, get_session
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import tokens
|
||||
from ..util.tokens import session_key
|
||||
from . import session
|
||||
|
||||
@ -321,6 +323,42 @@ def register_api_routes(app: FastAPI):
|
||||
await db.instance.update_role(updated)
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/auth/admin/orgs/{org_uuid}/users")
|
||||
async def admin_create_user(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
"""Create a new user within an organization.
|
||||
|
||||
Body parameters:
|
||||
- display_name: str (required)
|
||||
- role: str (required) display name of existing role in that org
|
||||
"""
|
||||
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")
|
||||
display_name = payload.get("display_name")
|
||||
role_name = payload.get("role")
|
||||
if not display_name or not role_name:
|
||||
raise ValueError("display_name and role are required")
|
||||
# Validate role exists in org
|
||||
from ..db import User as UserDC # local import to avoid cycles
|
||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
||||
if not role_obj:
|
||||
raise ValueError("Role not found in organization")
|
||||
# Create user
|
||||
user_uuid = uuid4()
|
||||
user = UserDC(
|
||||
uuid=user_uuid,
|
||||
display_name=display_name,
|
||||
role_uuid=role_obj.uuid,
|
||||
visits=0,
|
||||
created_at=None,
|
||||
last_seen=None,
|
||||
)
|
||||
await db.instance.create_user(user)
|
||||
return {"uuid": str(user_uuid)}
|
||||
|
||||
@app.delete("/auth/admin/roles/{role_uuid}")
|
||||
async def admin_delete_role(role_uuid: UUID, auth=Cookie(None)):
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
@ -330,6 +368,109 @@ def register_api_routes(app: FastAPI):
|
||||
await db.instance.delete_role(role_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
# -------------------- Admin API: Users (role management) --------------------
|
||||
|
||||
@app.put("/auth/admin/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||
async def admin_update_user_role(
|
||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
"""Change a user's role within their organization.
|
||||
|
||||
Body: {"role": "New Role Display Name"}
|
||||
Only global admins or admins of the organization can perform this.
|
||||
"""
|
||||
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")
|
||||
new_role = payload.get("role")
|
||||
if not new_role:
|
||||
raise ValueError("role is required")
|
||||
# Verify user belongs to this org
|
||||
try:
|
||||
user_org, _current_role = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise ValueError("User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise ValueError("User does not belong to this organization")
|
||||
# Ensure role exists in org and update
|
||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||
if not any(r.display_name == new_role for r in roles):
|
||||
raise ValueError("Role not found in organization")
|
||||
await db.instance.update_user_role_in_organization(user_uuid, new_role)
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/auth/admin/users/{user_uuid}/create-link")
|
||||
async def admin_create_user_registration_link(user_uuid: UUID, auth=Cookie(None)):
|
||||
"""Create a device registration/reset link for a specific user (admin only).
|
||||
|
||||
Returns JSON: {"url": str, "expires": iso8601}
|
||||
"""
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
# Ensure user exists and fetch their org
|
||||
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 not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
# Generate human-readable reset token and store as session with reset key
|
||||
token = passphrase.generate()
|
||||
await db.instance.create_session(
|
||||
user_uuid=user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expires=expires(),
|
||||
info={"type": "device addition", "created_by_admin": True},
|
||||
)
|
||||
origin = global_passkey.instance.origin
|
||||
url = f"{origin}/auth/{token}"
|
||||
return {"url": url, "expires": expires().isoformat()}
|
||||
|
||||
@app.get("/auth/admin/users/{user_uuid}")
|
||||
async def admin_get_user_detail(user_uuid: UUID, auth=Cookie(None)):
|
||||
"""Get detailed information about a user (admin only)."""
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
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 not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||
# Gather credentials
|
||||
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
creds: list[dict] = []
|
||||
aaguids: set[str] = set()
|
||||
for cid in cred_ids:
|
||||
try:
|
||||
c = await db.instance.get_credential_by_id(cid)
|
||||
except ValueError:
|
||||
continue
|
||||
aaguid_str = str(c.aaguid)
|
||||
aaguids.add(aaguid_str)
|
||||
creds.append(
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
||||
"last_verified": c.last_verified.isoformat() if c.last_verified else None,
|
||||
"sign_count": c.sign_count,
|
||||
}
|
||||
)
|
||||
from .. import aaguid as aaguid_mod
|
||||
aaguid_info = aaguid_mod.filter(aaguids)
|
||||
return {
|
||||
"display_name": user.display_name,
|
||||
"org": {"display_name": user_org.display_name},
|
||||
"role": role_name,
|
||||
"visits": user.visits,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
||||
"credentials": creds,
|
||||
"aaguid_info": aaguid_info,
|
||||
}
|
||||
|
||||
# -------------------- Admin API: Permissions (global) --------------------
|
||||
|
||||
@app.get("/auth/admin/permissions")
|
||||
|
Loading…
x
Reference in New Issue
Block a user