From df691038d7b4255c1db871da618e4095fe84a7fb Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 27 May 2026 08:00:06 +0200 Subject: [PATCH] feat(dashboard): add MeDto, AdminDto, apiKeys + role/password helpers Extends api.ts with the Phase 3.5 wire types (InstanceRole, Scope, MeDto, AdminDto, ApiKeyDto, MintApiKey*) and the matching apiKeys namespace. AdminUser in auth.ts now carries instance_role and email, so layout/store consumers see the role without a separate fetch. Adds two tiny lib helpers used by the upcoming profile/users pages: RoleChip.svelte for the colored owner/admin/member pill, and password-gen.ts for crypto.getRandomValues-backed temporary passwords used in user-invite + reset-password reveals. AdminUserRecord stays as a deprecated alias until /admins is retired in a follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard/src/lib/RoleChip.svelte | 45 +++++++++++++++ dashboard/src/lib/api.ts | 92 ++++++++++++++++++++++++++++--- dashboard/src/lib/auth.ts | 4 ++ dashboard/src/lib/password-gen.ts | 25 +++++++++ 4 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 dashboard/src/lib/RoleChip.svelte create mode 100644 dashboard/src/lib/password-gen.ts diff --git a/dashboard/src/lib/RoleChip.svelte b/dashboard/src/lib/RoleChip.svelte new file mode 100644 index 0000000..606e08b --- /dev/null +++ b/dashboard/src/lib/RoleChip.svelte @@ -0,0 +1,45 @@ + + +{role} + + diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index f719269..eda739b 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -8,7 +8,9 @@ import { goto } from '$app/navigation'; import { base } from '$app/paths'; import { browser } from '$app/environment'; -import { clearSession, getToken, setSession, type AdminUser } from './auth'; +import { clearSession, getToken, setSession, type InstanceRole } from './auth'; + +export type { InstanceRole }; export interface ScriptSandbox { max_operations?: number; @@ -232,27 +234,88 @@ function safeJson(text: string): unknown { } } -export interface AdminUserRecord { +export type Scope = + | 'script:read' + | 'script:write' + | 'route:write' + | 'domain:manage' + | 'log:read' + | 'app:admin' + | 'instance:admin'; + +export const ALL_SCOPES: readonly Scope[] = [ + 'script:read', + 'script:write', + 'route:write', + 'domain:manage', + 'log:read', + 'app:admin', + 'instance:admin' +] as const; + +export function isInstanceScope(s: Scope): boolean { + return s.startsWith('instance:'); +} + +export interface MeDto { + id: string; + username: string; + instance_role: InstanceRole; + email: string | null; +} + +export interface AdminDto { id: string; username: string; is_active: boolean; + instance_role: InstanceRole; + email: string | null; created_at: string; last_login_at: string | null; } +/** @deprecated use AdminDto. Kept until the /admins route is retired. */ +export type AdminUserRecord = AdminDto; + export interface CreateAdminInput { username: string; password: string; + instance_role?: InstanceRole; + email?: string | null; } export interface PatchAdminInput { username?: string; password?: string; is_active?: boolean; + instance_role?: InstanceRole; + email?: string | null; +} + +export interface ApiKeyDto { + id: string; + prefix: string; + name: string; + scopes: Scope[]; + app_id: string | null; + expires_at: string | null; + last_used_at: string | null; + created_at: string; +} + +export interface MintApiKeyInput { + name: string; + scopes: Scope[]; + app_id?: string | null; + expires_at?: string | null; +} + +export interface MintApiKeyResponse extends ApiKeyDto { + raw_token: string; } interface LoginResponse { - user: AdminUser; + user: MeDto; token: string; expires_at: string; } @@ -263,7 +326,7 @@ export const api = { version: () => adminRequest('/version'), auth: { - login: async (username: string, password: string): Promise => { + login: async (username: string, password: string): Promise => { const r = await adminRequest('/api/v1/admin/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) @@ -282,19 +345,19 @@ export const api = { clearSession(); } }, - me: () => adminRequest('/api/v1/admin/auth/me') + me: () => adminRequest('/api/v1/admin/auth/me') }, admins: { - list: () => adminRequest('/api/v1/admin/admins'), - get: (id: string) => adminRequest(`/api/v1/admin/admins/${id}`), + list: () => adminRequest('/api/v1/admin/admins'), + get: (id: string) => adminRequest(`/api/v1/admin/admins/${id}`), create: (input: CreateAdminInput) => - adminRequest('/api/v1/admin/admins', { + adminRequest('/api/v1/admin/admins', { method: 'POST', body: JSON.stringify(input) }), update: (id: string, input: PatchAdminInput) => - adminRequest(`/api/v1/admin/admins/${id}`, { + adminRequest(`/api/v1/admin/admins/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), @@ -302,6 +365,17 @@ export const api = { adminRequest(`/api/v1/admin/admins/${id}`, { method: 'DELETE' }) }, + apiKeys: { + list: () => adminRequest('/api/v1/admin/api-keys'), + mint: (input: MintApiKeyInput) => + adminRequest('/api/v1/admin/api-keys', { + method: 'POST', + body: JSON.stringify(input) + }), + revoke: (id: string) => + adminRequest(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' }) + }, + routes: { listForScript: (scriptId: string) => adminRequest(`/api/v1/admin/scripts/${scriptId}/routes`), diff --git a/dashboard/src/lib/auth.ts b/dashboard/src/lib/auth.ts index 02e2a2d..e51ad12 100644 --- a/dashboard/src/lib/auth.ts +++ b/dashboard/src/lib/auth.ts @@ -10,9 +10,13 @@ import { writable, get } from 'svelte/store'; import { browser } from '$app/environment'; +export type InstanceRole = 'owner' | 'admin' | 'member'; + export interface AdminUser { id: string; username: string; + instance_role: InstanceRole; + email: string | null; } const TOKEN_KEY = 'picloud.admin.token'; diff --git a/dashboard/src/lib/password-gen.ts b/dashboard/src/lib/password-gen.ts new file mode 100644 index 0000000..106231d --- /dev/null +++ b/dashboard/src/lib/password-gen.ts @@ -0,0 +1,25 @@ +// Cryptographically random password generator for the user-create +// and reset-password flows. PiCloud has no email yet, so the admin +// invites a user by generating a password locally, posting it to the +// backend, and copying the cleartext out of the one-time reveal panel +// to share through whatever channel they trust. +// +// Charset is alphanumeric plus a small printable symbol set — enough +// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes, +// avoidant of characters that ship awkwardly through chat clients +// (no quotes, slashes, or backticks). + +const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@'; + +export function generatePassword(length = 16): string { + if (length < 8) { + throw new Error('password length must be at least 8'); + } + const buf = new Uint32Array(length); + crypto.getRandomValues(buf); + let out = ''; + for (let i = 0; i < length; i++) { + out += CHARSET[buf[i] % CHARSET.length]; + } + return out; +}