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