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:
45
dashboard/src/lib/RoleChip.svelte
Normal file
45
dashboard/src/lib/RoleChip.svelte
Normal 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>
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { browser } from '$app/environment';
|
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 {
|
export interface ScriptSandbox {
|
||||||
max_operations?: number;
|
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;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_login_at: string | null;
|
last_login_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated use AdminDto. Kept until the /admins route is retired. */
|
||||||
|
export type AdminUserRecord = AdminDto;
|
||||||
|
|
||||||
export interface CreateAdminInput {
|
export interface CreateAdminInput {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
email?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchAdminInput {
|
export interface PatchAdminInput {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
is_active?: boolean;
|
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 {
|
interface LoginResponse {
|
||||||
user: AdminUser;
|
user: MeDto;
|
||||||
token: string;
|
token: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
}
|
}
|
||||||
@@ -263,7 +326,7 @@ export const api = {
|
|||||||
version: () => adminRequest<VersionInfo>('/version'),
|
version: () => adminRequest<VersionInfo>('/version'),
|
||||||
|
|
||||||
auth: {
|
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', {
|
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password })
|
||||||
@@ -282,19 +345,19 @@ export const api = {
|
|||||||
clearSession();
|
clearSession();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
|
me: () => adminRequest<MeDto>('/api/v1/admin/auth/me')
|
||||||
},
|
},
|
||||||
|
|
||||||
admins: {
|
admins: {
|
||||||
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
|
list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
|
||||||
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
|
get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
|
||||||
create: (input: CreateAdminInput) =>
|
create: (input: CreateAdminInput) =>
|
||||||
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
|
adminRequest<AdminDto>('/api/v1/admin/admins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
}),
|
}),
|
||||||
update: (id: string, input: PatchAdminInput) =>
|
update: (id: string, input: PatchAdminInput) =>
|
||||||
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
|
adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
}),
|
}),
|
||||||
@@ -302,6 +365,17 @@ export const api = {
|
|||||||
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
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: {
|
routes: {
|
||||||
listForScript: (scriptId: string) =>
|
listForScript: (scriptId: string) =>
|
||||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
import { writable, get } from 'svelte/store';
|
import { writable, get } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type InstanceRole = 'owner' | 'admin' | 'member';
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = 'picloud.admin.token';
|
const TOKEN_KEY = 'picloud.admin.token';
|
||||||
|
|||||||
25
dashboard/src/lib/password-gen.ts
Normal file
25
dashboard/src/lib/password-gen.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user