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:
172
dashboard/src/routes/login/+page.svelte
Normal file
172
dashboard/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { getToken } from '$lib/auth';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let pending = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
// Already signed in? Skip the form.
|
||||
if (!getToken()) return;
|
||||
try {
|
||||
await api.auth.me();
|
||||
await goto(`${base}/`);
|
||||
} catch {
|
||||
// stale token; let the form render
|
||||
}
|
||||
});
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
error = null;
|
||||
pending = true;
|
||||
try {
|
||||
await api.auth.login(username, password);
|
||||
await goto(`${base}/`);
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Login failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-shell">
|
||||
<form class="card" onsubmit={submit}>
|
||||
<h1>PiCloud</h1>
|
||||
<p class="sub">Admin sign-in</p>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={username}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Signing in…' : 'Sign in →'}
|
||||
</button>
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
<p class="hint">
|
||||
Lost access? Run <code>picloud admin reset-password <username></code> on the host.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-shell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: #0b1220;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
min-width: 22rem;
|
||||
max-width: 26rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #38bdf8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
input {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #450a0a;
|
||||
border: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #1e293b;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user