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:
@@ -5,6 +5,11 @@
|
||||
// the same Caddy upstream so the "Test invoke" panel can hit it
|
||||
// without any cross-origin gymnastics.
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { browser } from '$app/environment';
|
||||
import { clearSession, getToken, setSession, type AdminUser } from './auth';
|
||||
|
||||
export interface ScriptSandbox {
|
||||
max_operations?: number;
|
||||
max_string_size?: number;
|
||||
@@ -134,12 +139,26 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
...((init?.headers as Record<string, string>) ?? {})
|
||||
};
|
||||
const tok = getToken();
|
||||
if (tok && !headers['authorization']) {
|
||||
headers['authorization'] = `Bearer ${tok}`;
|
||||
}
|
||||
const res = await fetch(path, { ...init, headers });
|
||||
const text = await res.text();
|
||||
const parsed: unknown = text ? safeJson(text) : null;
|
||||
if (res.status === 401) {
|
||||
// Token gone stale or never present. Drop any cached session
|
||||
// and bounce to login — unless we're already on it, in which
|
||||
// case throw and let the login form render the error.
|
||||
clearSession();
|
||||
if (browser && !window.location.pathname.endsWith('/login')) {
|
||||
void goto(`${base}/login`);
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(parsed && typeof parsed === 'object' && 'error' in parsed
|
||||
@@ -158,11 +177,76 @@ function safeJson(text: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminUserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAdminInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PatchAdminInput {
|
||||
username?: string;
|
||||
password?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
user: AdminUser;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => fetch('/healthz').then((r) => r.text()),
|
||||
|
||||
version: () => adminRequest<VersionInfo>('/version'),
|
||||
|
||||
auth: {
|
||||
login: async (username: string, password: string): Promise<AdminUser> => {
|
||||
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setSession(r.user, r.token);
|
||||
return r.user;
|
||||
},
|
||||
logout: async (): Promise<void> => {
|
||||
try {
|
||||
await adminRequest<null>('/api/v1/admin/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
// Always clear locally — logout is idempotent server-side
|
||||
// and we don't want a network blip to strand the SPA in
|
||||
// a "logged out on server, still logged in client-side"
|
||||
// state.
|
||||
clearSession();
|
||||
}
|
||||
},
|
||||
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
|
||||
},
|
||||
|
||||
admins: {
|
||||
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
|
||||
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
|
||||
create: (input: CreateAdminInput) =>
|
||||
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
update: (id: string, input: PatchAdminInput) =>
|
||||
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
remove: (id: string) =>
|
||||
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
routes: {
|
||||
listForScript: (scriptId: string) =>
|
||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||
|
||||
60
dashboard/src/lib/auth.ts
Normal file
60
dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Session state for the dashboard. Backed by a pair of Svelte stores
|
||||
// plus a tiny localStorage echo so a page reload doesn't sign you out.
|
||||
//
|
||||
// The bearer token doubles as the cookie value on the server side, so
|
||||
// in browsers that honor the Set-Cookie response the cookie path "just
|
||||
// works"; the token-in-localStorage path covers the rest (HTTP dev, API
|
||||
// clients impersonating the dashboard) by being injected into the
|
||||
// Authorization header in api.ts.
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'picloud.admin.token';
|
||||
|
||||
function readStoredToken(): string | null {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredToken(value: string | null) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(TOKEN_KEY);
|
||||
else localStorage.setItem(TOKEN_KEY, value);
|
||||
} catch {
|
||||
// Non-fatal: localStorage can be disabled. The session will
|
||||
// just not survive page reloads, but the in-memory store still
|
||||
// works for the current SPA lifetime.
|
||||
}
|
||||
}
|
||||
|
||||
export const token = writable<string | null>(readStoredToken());
|
||||
export const currentUser = writable<AdminUser | null>(null);
|
||||
|
||||
token.subscribe((value) => writeStoredToken(value));
|
||||
|
||||
/** Snapshot of the current token without subscribing — used by the
|
||||
* fetch wrapper. Returns null when no admin is logged in. */
|
||||
export function getToken(): string | null {
|
||||
return get(token);
|
||||
}
|
||||
|
||||
export function setSession(user: AdminUser, raw_token: string) {
|
||||
currentUser.set(user);
|
||||
token.set(raw_token);
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
currentUser.set(null);
|
||||
token.set(null);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
687
dashboard/src/routes/admins/+page.svelte
Normal file
687
dashboard/src/routes/admins/+page.svelte
Normal file
@@ -0,0 +1,687 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, ApiError, type AdminUserRecord } from '$lib/api';
|
||||
import { currentUser } from '$lib/auth';
|
||||
|
||||
let admins = $state<AdminUserRecord[]>([]);
|
||||
let loadError = $state<string | null>(null);
|
||||
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||
|
||||
const me = $derived($currentUser);
|
||||
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({ username: '', password: '', confirm: '' });
|
||||
let createPending = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
let passwordTarget = $state<AdminUserRecord | null>(null);
|
||||
let passwordForm = $state({ password: '', confirm: '' });
|
||||
let passwordPending = $state(false);
|
||||
let passwordError = $state<string | null>(null);
|
||||
|
||||
let deleteTarget = $state<AdminUserRecord | null>(null);
|
||||
let deletePending = $state(false);
|
||||
|
||||
let actionsOpenFor = $state<string | null>(null);
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function refresh() {
|
||||
loadError = null;
|
||||
try {
|
||||
admins = await api.admins.list();
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'failed to load admins';
|
||||
}
|
||||
}
|
||||
|
||||
function flash(kind: 'error' | 'info', message: string) {
|
||||
banner = { kind, message };
|
||||
setTimeout(() => {
|
||||
if (banner?.message === message) banner = null;
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm = { username: '', password: '', confirm: '' };
|
||||
createError = null;
|
||||
createOpen = true;
|
||||
}
|
||||
|
||||
async function submitCreate(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
createError = null;
|
||||
if (createForm.password !== createForm.confirm) {
|
||||
createError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
createPending = true;
|
||||
try {
|
||||
await api.admins.create({
|
||||
username: createForm.username.trim(),
|
||||
password: createForm.password
|
||||
});
|
||||
createOpen = false;
|
||||
await refresh();
|
||||
flash('info', `Created admin "${createForm.username.trim()}".`);
|
||||
} catch (e) {
|
||||
createError = e instanceof ApiError ? e.message : 'failed to create admin';
|
||||
} finally {
|
||||
createPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPassword(row: AdminUserRecord) {
|
||||
passwordTarget = row;
|
||||
passwordForm = { password: '', confirm: '' };
|
||||
passwordError = null;
|
||||
actionsOpenFor = null;
|
||||
}
|
||||
|
||||
async function submitPassword(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!passwordTarget) return;
|
||||
passwordError = null;
|
||||
if (passwordForm.password !== passwordForm.confirm) {
|
||||
passwordError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
passwordPending = true;
|
||||
try {
|
||||
await api.admins.update(passwordTarget.id, { password: passwordForm.password });
|
||||
const name = passwordTarget.username;
|
||||
passwordTarget = null;
|
||||
flash('info', `Password updated for "${name}".`);
|
||||
} catch (e) {
|
||||
passwordError = e instanceof ApiError ? e.message : 'failed to update password';
|
||||
} finally {
|
||||
passwordPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(row: AdminUserRecord) {
|
||||
actionsOpenFor = null;
|
||||
try {
|
||||
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
||||
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||
flash('info', `${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`);
|
||||
} catch (e) {
|
||||
flash('error', e instanceof ApiError ? e.message : 'failed to update admin');
|
||||
}
|
||||
}
|
||||
|
||||
function openDelete(row: AdminUserRecord) {
|
||||
deleteTarget = row;
|
||||
actionsOpenFor = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
deletePending = true;
|
||||
const target = deleteTarget;
|
||||
try {
|
||||
await api.admins.remove(target.id);
|
||||
deleteTarget = null;
|
||||
if (me && me.id === target.id) {
|
||||
// Just deleted ourselves — sign out and bounce.
|
||||
await api.auth.logout();
|
||||
await goto(`${base}/login`);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
flash('info', `Deleted "${target.username}".`);
|
||||
} catch (e) {
|
||||
flash('error', e instanceof ApiError ? e.message : 'failed to delete admin');
|
||||
} finally {
|
||||
deletePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleActions(id: string) {
|
||||
actionsOpenFor = actionsOpenFor === id ? null : id;
|
||||
}
|
||||
|
||||
function relative(iso: string | null): string {
|
||||
if (!iso) return 'Never';
|
||||
const then = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
const sec = Math.round((now - then) / 1000);
|
||||
if (sec < 60) return `${sec} second${sec === 1 ? '' : 's'} ago`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
if (day === 1) return 'Yesterday';
|
||||
if (day < 7) return `${day} days ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function absolute(iso: string | null): string {
|
||||
return iso ? new Date(iso).toISOString() : '';
|
||||
}
|
||||
|
||||
function shortDate(iso: string): string {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="head">
|
||||
<h1>Admin Users</h1>
|
||||
<button type="button" class="primary" onclick={openCreate}>+ New admin user</button>
|
||||
</header>
|
||||
|
||||
{#if banner}
|
||||
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||
{/if}
|
||||
|
||||
{#if loadError}
|
||||
<div class="error">
|
||||
{loadError}
|
||||
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
||||
</div>
|
||||
{:else if admins.length === 0}
|
||||
<p class="empty">No admin users yet. Add one to get started.</p>
|
||||
{:else}
|
||||
<div class="table">
|
||||
<div class="row head-row">
|
||||
<div>Username</div>
|
||||
<div>Status</div>
|
||||
<div>Created</div>
|
||||
<div>Last login</div>
|
||||
<div class="actions-col"></div>
|
||||
</div>
|
||||
{#each admins as row (row.id)}
|
||||
<div class="row">
|
||||
<div class="username-cell">
|
||||
<span class="name">{row.username}</span>
|
||||
{#if me && me.id === row.id}
|
||||
<span class="you-tag">(you)</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if row.is_active}
|
||||
<span class="status status-active">● Active</span>
|
||||
{:else}
|
||||
<span class="status status-inactive">○ Inactive</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>{shortDate(row.created_at)}</div>
|
||||
<div title={absolute(row.last_login_at)}>{relative(row.last_login_at)}</div>
|
||||
<div class="actions-col">
|
||||
<button
|
||||
type="button"
|
||||
class="kebab"
|
||||
aria-label="Actions for {row.username}"
|
||||
onclick={() => toggleActions(row.id)}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{#if actionsOpenFor === row.id}
|
||||
<div class="menu">
|
||||
<button type="button" onclick={() => openPassword(row)}>Change password</button>
|
||||
<button type="button" onclick={() => toggleActive(row)}>
|
||||
{row.is_active ? 'Deactivate' : 'Reactivate'}
|
||||
</button>
|
||||
<button type="button" class="danger" onclick={() => openDelete(row)}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- New admin modal -->
|
||||
{#if createOpen}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) createOpen = false;
|
||||
}}
|
||||
>
|
||||
<form class="modal" onsubmit={submitCreate}>
|
||||
<div class="modal-head">
|
||||
<h2>New admin user</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="x"
|
||||
aria-label="Close"
|
||||
onclick={() => (createOpen = false)}>✕</button
|
||||
>
|
||||
</div>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
bind:value={createForm.username}
|
||||
required
|
||||
/>
|
||||
<small>Lowercase letters, digits, . _ -</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={createForm.password}
|
||||
required
|
||||
/>
|
||||
<small>Minimum 8 characters</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={createForm.confirm}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{#if createError}
|
||||
<div class="error">{createError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
|
||||
<button type="submit" class="primary" disabled={createPending}>
|
||||
{createPending ? 'Creating…' : 'Create user'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Change password modal -->
|
||||
{#if passwordTarget}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) passwordTarget = null;
|
||||
}}
|
||||
>
|
||||
<form class="modal" onsubmit={submitPassword}>
|
||||
<div class="modal-head">
|
||||
<h2>Change password — {passwordTarget.username}</h2>
|
||||
<button type="button" class="x" aria-label="Close" onclick={() => (passwordTarget = null)}
|
||||
>✕</button
|
||||
>
|
||||
</div>
|
||||
<label>
|
||||
<span>New password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={passwordForm.password}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={passwordForm.confirm}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{#if passwordError}
|
||||
<div class="error">{passwordError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (passwordTarget = null)}>Cancel</button>
|
||||
<button type="submit" class="primary" disabled={passwordPending}>
|
||||
{passwordPending ? 'Updating…' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
{#if deleteTarget}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) deleteTarget = null;
|
||||
}}
|
||||
>
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h2>Delete {deleteTarget.username}?</h2>
|
||||
<button type="button" class="x" aria-label="Close" onclick={() => (deleteTarget = null)}
|
||||
>✕</button
|
||||
>
|
||||
</div>
|
||||
{#if me && me.id === deleteTarget.id}
|
||||
<p>
|
||||
You are about to delete <strong>your own</strong> account. You will be signed out immediately
|
||||
and will not be able to sign back in with these credentials.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
This permanently removes <strong>{deleteTarget.username}</strong> and all their sessions.
|
||||
This cannot be undone.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (deleteTarget = null)}>Cancel</button>
|
||||
<button type="button" class="danger" disabled={deletePending} onclick={confirmDelete}>
|
||||
{deletePending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.banner-error {
|
||||
background: #450a0a;
|
||||
border: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
}
|
||||
.banner-info {
|
||||
background: #0c2a36;
|
||||
border: 1px solid #155e75;
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
border: 1px dashed #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
overflow: visible;
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 0.9fr 1fr 1.2fr 3rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.head-row {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.username-cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.you-tag {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.status-active {
|
||||
color: #34d399;
|
||||
}
|
||||
.status-inactive {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.kebab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.kebab:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: #0b1220;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 12rem;
|
||||
z-index: 10;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.menu button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.menu button:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.menu button.danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.menu button.danger:hover {
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #450a0a;
|
||||
border: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.retry {
|
||||
background: transparent;
|
||||
border: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
button.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
button.ghost:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #b91c1c;
|
||||
color: #fef2f2;
|
||||
border: none;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
button.danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #0b1220;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
min-width: 24rem;
|
||||
max-width: 28rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.x {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.x:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.modal label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.modal label small {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.modal input {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal input:focus {
|
||||
outline: none;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
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