Major changes to server startup. Admin page tuning.

This commit is contained in:
2025-08-30 08:41:38 +00:00
parent 6840f319eb
commit 395ecdbd19
12 changed files with 1077 additions and 143 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --clearScreen false",
"build": "vite build",
"preview": "vite preview"
},
+240 -6
View File
@@ -4,6 +4,22 @@ 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
@@ -13,6 +29,9 @@ async function load() {
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 {
@@ -20,6 +39,133 @@ async function load() {
}
}
// 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>
@@ -38,15 +184,10 @@ onMounted(load)
<p>Insufficient permissions.</p>
</div>
<div v-else>
<div class="card">
<h2>User</h2>
<div>{{ info.user.user_name }} ({{ info.user.user_uuid }})</div>
<div>Role: {{ info.role?.display_name }}</div>
</div>
<div class="card">
<h2>Organization</h2>
<div>{{ info.org?.display_name }} ({{ info.org?.uuid }})</div>
<div>{{ info.org?.display_name }}</div>
<div>Role permissions: {{ info.role?.permissions?.join(', ') }}</div>
<div>Org grantable: {{ info.org?.permissions?.join(', ') }}</div>
</div>
@@ -57,6 +198,83 @@ onMounted(load)
<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>
@@ -67,4 +285,20 @@ onMounted(load)
.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 }
</style>
+29 -5
View File
@@ -20,9 +20,35 @@ export default defineConfig(({ command, mode }) => ({
port: 4403,
proxy: {
'/auth/': {
target: 'http://localhost:4401',
target: 'http://localhost:4402',
ws: true,
changeOrigin: false
changeOrigin: false,
// We proxy API + WS under /auth/, but want Vite to serve the SPA entrypoints
// and static assets so that HMR works. Bypass tells http-proxy to skip
// proxying when we return a (possibly rewritten) local path.
bypass(req) {
const url = req.url || ''
// Paths to serve locally (not proxied):
// - /auth/ (root SPA)
// - /auth/assets/* (dev static assets)
// - /auth/admin/* (admin SPA)
// NOTE: Keep /auth/ws/* and all other API endpoints proxied.
if (url === '/auth/' || url === '/auth') {
return '/'
}
if (url.startsWith('/auth/assets')) {
// Map /auth/assets/* -> /assets/*
return url.replace(/^\/auth/, '')
}
if (url === '/auth/admin' || url === '/auth/admin/') {
return '/admin/'
}
if (url.startsWith('/auth/admin/')) {
// Map /auth/admin/* -> /admin/*
return url.replace(/^\/auth\/admin/, '/admin')
}
// Otherwise proxy (API, ws, etc.)
}
}
}
},
@@ -35,9 +61,7 @@ export default defineConfig(({ command, mode }) => ({
index: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin/index.html')
},
output: {
// Ensure HTML files land as /auth/index.html and /auth/admin.html -> we will serve /auth/admin mapping in backend
}
output: {}
}
}
}))