Files
paskia/frontend/src/admin/AdminApp.vue
T

307 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted } from 'vue'
const info = ref(null)
const loading = ref(true)
const error = ref(null)
const orgs = ref([])
const permissions = ref([])
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
}
async function loadPermissions() {
const res = await fetch('/auth/admin/permissions')
const data = await res.json()
if (data.detail) throw new Error(data.detail)
permissions.value = data
}
async function load() {
loading.value = true
error.value = null
try {
const res = await fetch('/auth/user-info', { method: 'POST' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
info.value = data
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
await Promise.all([loadOrgs(), loadPermissions()])
}
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
// Org actions
async function createOrg() {
const name = prompt('New organization display name:')
if (!name) return
const res = await fetch('/auth/admin/orgs', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ display_name: name, permissions: [] })
})
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadOrgs()
}
async function updateOrg(org) {
const name = prompt('Organization display name:', org.display_name)
if (!name) return
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ display_name: name, permissions: org.permissions })
})
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadOrgs()
}
async function deleteOrg(org) {
if (!confirm(`Delete organization ${org.display_name}?`)) return
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadOrgs()
}
async function addOrgPermission(org) {
const id = prompt('Permission ID to add:', permissions.value[0]?.id || '')
if (!id) return
const res = await fetch(`/auth/admin/orgs/${org.uuid}/permissions/${encodeURIComponent(id)}`, { method: 'POST' })
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadOrgs()
}
async function removeOrgPermission(org, permId) {
const res = await fetch(`/auth/admin/orgs/${org.uuid}/permissions/${encodeURIComponent(permId)}`, { method: 'DELETE' })
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadOrgs()
}
// Role actions
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 })
})
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadOrgs()
}
async function updateRole(role) {
const name = prompt('Role display name:', role.display_name)
if (!name) return
const csv = prompt('Permission IDs (comma-separated):', role.permissions.join(', ')) || ''
const perms = csv.split(',').map(s => s.trim()).filter(Boolean)
const res = await fetch(`/auth/admin/roles/${role.uuid}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ display_name: name, permissions: perms })
})
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadOrgs()
}
async function deleteRole(role) {
if (!confirm(`Delete role ${role.display_name}?`)) return
const res = await fetch(`/auth/admin/roles/${role.uuid}`, { method: 'DELETE' })
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadOrgs()
}
// Permission actions
async function createPermission() {
const id = prompt('Permission ID (e.g., auth/example):')
if (!id) return
const name = prompt('Permission display name:')
if (!name) return
const res = await fetch('/auth/admin/permissions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, display_name: name })
})
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadPermissions()
}
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 data = await res.json()
if (data.detail) return alert(data.detail)
await loadPermissions()
}
async function deletePermission(p) {
if (!confirm(`Delete permission ${p.id}?`)) return
const res = await fetch(`/auth/admin/permissions/${encodeURIComponent(p.id)}`, { method: 'DELETE' })
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadPermissions()
}
onMounted(load)
</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>
<div v-if="loading">Loading</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
<div v-if="!info?.authenticated">
<p>You must be authenticated.</p>
</div>
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)">
<p>Insufficient permissions.</p>
</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>
<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">
<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>
</div>
<strong :title="r.uuid">{{ r.display_name }}</strong>
<div class="role-actions">
<button @click="updateRole(r)">Edit</button>
<button @click="deleteRole(r)">Delete</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>
</div>
</div>
</div>
<div v-if="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>
<div class="perm-actions">
<button @click="updatePermission(p)">Edit</button>
<button @click="deletePermission(p)">Delete</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.container { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
.subtitle { color: #888 }
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; }
.error { color: #a00 }
.actions { margin-bottom: .5rem }
.org { border-top: 1px dashed #eee; padding: .5rem 0 }
.org-header { display: flex; gap: .5rem; align-items: baseline }
.user-item { display: flex; gap: .5rem; margin: .15rem 0 }
.users-table { width: 100%; border-collapse: collapse; margin-top: .25rem; }
.users-table th, .users-table td { padding: .25rem .4rem; text-align: left; border-bottom: 1px solid #eee; font-weight: normal; }
.users-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; }
.users-table tbody tr:hover { background: #fafafa; }
.org-actions, .role-actions, .perm-actions { display: flex; gap: .5rem; margin: .25rem 0 }
.muted { color: #666 }
.small { font-size: .9em }
.pill-list { display: flex; flex-wrap: wrap; gap: .25rem }
.pill { background: #f3f3f3; border: 1px solid #e2e2e2; border-radius: 999px; padding: .1rem .5rem; display: inline-flex; align-items: center; gap: .25rem }
.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 }
.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; }
</style>