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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 08:00:06 +02:00
parent 3688c26cb4
commit df691038d7
4 changed files with 157 additions and 9 deletions

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import type { InstanceRole } from '$lib/auth';
interface Props {
role: InstanceRole;
size?: 'sm' | 'md';
}
let { role, size = 'md' }: Props = $props();
</script>
<span class="chip chip-{role}" class:sm={size === 'sm'}>{role}</span>
<style>
.chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid transparent;
}
.chip.sm {
font-size: 0.625rem;
padding: 0.1rem 0.45rem;
}
.chip-owner {
background: #78350f;
color: #fbbf24;
border-color: #b45309;
}
.chip-admin {
background: #164e63;
color: #67e8f9;
border-color: #0e7490;
}
.chip-member {
background: #1e293b;
color: #cbd5e1;
border-color: #334155;
}
</style>

View File

@@ -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<VersionInfo>('/version'),
auth: {
login: async (username: string, password: string): Promise<AdminUser> => {
login: async (username: string, password: string): Promise<MeDto> => {
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
@@ -282,19 +345,19 @@ export const api = {
clearSession();
}
},
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
me: () => adminRequest<MeDto>('/api/v1/admin/auth/me')
},
admins: {
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
create: (input: CreateAdminInput) =>
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
adminRequest<AdminDto>('/api/v1/admin/admins', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (id: string, input: PatchAdminInput) =>
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
@@ -302,6 +365,17 @@ export const api = {
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
},
apiKeys: {
list: () => adminRequest<ApiKeyDto[]>('/api/v1/admin/api-keys'),
mint: (input: MintApiKeyInput) =>
adminRequest<MintApiKeyResponse>('/api/v1/admin/api-keys', {
method: 'POST',
body: JSON.stringify(input)
}),
revoke: (id: string) =>
adminRequest<null>(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' })
},
routes: {
listForScript: (scriptId: string) =>
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),

View File

@@ -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';

View File

@@ -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;
}