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 { 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`),
|
||||
|
||||
@@ -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';
|
||||
|
||||
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