feat(manager-core): admin auth gate (Phase 3a)
Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.
Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).
require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.
Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.
Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.
Blueprint §11.4 updated to reflect what actually shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import { currentUser, getToken } from '$lib/auth';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let booting = $state(true);
|
||||
const user = $derived($currentUser);
|
||||
|
||||
const isLoginRoute = $derived(page.url.pathname.endsWith('/login'));
|
||||
|
||||
onMount(async () => {
|
||||
// Hydrate the session: if there's a token, ask the server who we
|
||||
// are. On 401 the fetch wrapper already redirects to /login and
|
||||
// clears state; on success we land in the SPA fully signed in.
|
||||
const tok = getToken();
|
||||
if (!tok) {
|
||||
if (!isLoginRoute) {
|
||||
await goto(`${base}/login`);
|
||||
}
|
||||
booting = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await api.auth.me();
|
||||
currentUser.set(me);
|
||||
} catch {
|
||||
// adminRequest handles 401 redirects. For other errors fall
|
||||
// through — the page will surface its own error state.
|
||||
}
|
||||
booting = false;
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await api.auth.logout();
|
||||
await goto(`${base}/login`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
@@ -8,10 +46,22 @@
|
||||
<a href={base + '/'} class="brand">PiCloud</a>
|
||||
<nav>
|
||||
<a href={base + '/'}>Scripts</a>
|
||||
<a href={base + '/admins'}>Admins</a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
{#if user}
|
||||
<div class="usermenu">
|
||||
<span class="username">{user.username}</span>
|
||||
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<main>
|
||||
{@render children?.()}
|
||||
{#if booting}
|
||||
<p class="boot">Loading…</p>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +95,11 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
@@ -55,6 +110,36 @@
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.usermenu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
@@ -63,4 +148,8 @@
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.boot {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user