feat: add org admin SPA for models, roles and provider

Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
This commit is contained in:
2026-07-11 12:33:56 +08:00
parent 461d2e89b0
commit 552c1c353e
50 changed files with 6986 additions and 151 deletions
+3
View File
@@ -4,3 +4,6 @@ dist/
.env
.env.*
!.env.example
admin-web/node_modules/
admin-web/build/
admin-web/.svelte-kit/
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.2 create --template minimal --types ts --install npm D:/Projects/curriculum-project-hub/hub/admin-web
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+2594
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "admin-web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"bits-ui": "^2.18.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN" data-theme="hamlindigo">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta name="description" content="Curriculum Project Hub — 组织管理后台" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>CPH Admin</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+291
View File
@@ -0,0 +1,291 @@
/**
* Thin API client for the org admin backend. Same-origin cookie auth.
*/
export class ApiError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status: number) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
}
}
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
const init: RequestInit = {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined
};
const res = await fetch(url, init);
const text = await res.text();
let data: unknown = null;
if (text !== '') {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
}
return data;
}
const get = (u: string) => request('GET', u);
const post = (u: string, b?: unknown) => request('POST', u, b);
const put = (u: string, b?: unknown) => request('PUT', u, b);
const patch = (u: string, b?: unknown) => request('PATCH', u, b);
const del = (u: string) => request('DELETE', u);
const orgBase = (slug: string) => `/api/org/${encodeURIComponent(slug)}`;
// --- Types ---
export interface OrgMembership {
id: string;
slug: string;
name: string;
status: string;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
}
export interface MeResponse {
user: {
id: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
};
organizations: OrgMembership[];
}
export interface OrgMember {
userId: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
createdAt: string;
}
export interface TeamRow {
id: string;
slug: string;
name: string;
description: string | null;
memberCount: number;
createdAt: string;
}
export interface TeamMemberRow {
userId: string;
feishuOpenId: string;
displayName: string;
createdAt: string;
}
export interface ExplorerFolder {
id: string;
name: string;
parentId: string | null;
sortKey: string;
projectCount: number;
childFolderCount: number;
}
export interface ExplorerProject {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string; createdAt: string } | null;
}
export interface ExplorerData {
folders: ExplorerFolder[];
projects: ExplorerProject[];
}
export interface ProjectDetail {
id: string;
name: string;
folderId: string | null;
folder: { id: string; name: string } | null;
workspaceDir: string;
createdAt: string;
archivedAt: string | null;
createdBy: { id: string; displayName: string; feishuOpenId: string } | null;
binding: { chatId: string; createdAt: string } | null;
}
export interface TeamAccessEntry {
grantId: string;
projectId: string;
organizationId: string;
teamId: string;
teamSlug: string;
teamName: string;
role: 'READ' | 'EDIT' | 'MANAGE';
}
export interface SessionSummary {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
runCount: number;
createdAt: string;
updatedAt: string;
}
export interface OrgModelRow {
id: string;
modelId: string;
label: string;
toolCapable: boolean;
sortKey: string;
createdAt: string;
updatedAt: string;
}
export interface OrgRoleRow {
id: string;
roleId: string;
label: string;
defaultModelId: string | null;
systemPrompt: string | null;
tools: string[] | null;
createdAt: string;
updatedAt: string;
}
export interface ProviderConnection {
organizationId: string;
providerId: string;
mode: 'BYOK' | 'PLATFORM_MANAGED';
baseUrl: string | null;
hasAuthToken: boolean;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals {
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
}
export interface ProjectUsageRow extends UsageTotals {
projectId: string;
projectName: string;
folderId: string | null;
}
export interface UsageReport {
from: string | null;
to: string | null;
projects: ProjectUsageRow[];
totals: UsageTotals;
}
// --- API ---
export const api = {
me: () => get('/api/me') as Promise<MeResponse>,
logout: () => post('/auth/logout'),
org: (slug: string) => get(orgBase(slug)) as Promise<{ organization: { id: string; slug: string; name: string; status: string }; actorRole: string }>,
settings: (slug: string) => get(`${orgBase(slug)}/settings`) as Promise<{ membersCanCreateProjects: boolean }>,
setSettings: (slug: string, body: { membersCanCreateProjects: boolean }) =>
patch(`${orgBase(slug)}/settings`, body) as Promise<{ membersCanCreateProjects: boolean }>,
members: (slug: string) => get(`${orgBase(slug)}/members`) as Promise<{ members: OrgMember[] }>,
addMember: (slug: string, body: { feishuOpenId: string; displayName?: string; role: string }) =>
post(`${orgBase(slug)}/members`, body) as Promise<OrgMember>,
setMemberRole: (slug: string, userId: string, role: string) =>
patch(`${orgBase(slug)}/members/${userId}`, { role }),
revokeMember: (slug: string, userId: string) =>
post(`${orgBase(slug)}/members/${userId}/revoke`),
teams: (slug: string) => get(`${orgBase(slug)}/teams`) as Promise<{ teams: TeamRow[] }>,
createTeam: (slug: string, body: { slug: string; name: string; description?: string }) =>
post(`${orgBase(slug)}/teams`, body) as Promise<TeamRow>,
updateTeam: (slug: string, teamId: string, body: { name?: string; description?: string | null }) =>
patch(`${orgBase(slug)}/teams/${teamId}`, body) as Promise<TeamRow>,
archiveTeam: (slug: string, teamId: string) =>
post(`${orgBase(slug)}/teams/${teamId}/archive`),
teamMembers: (slug: string, teamId: string) =>
get(`${orgBase(slug)}/teams/${teamId}/members`) as Promise<{ members: TeamMemberRow[] }>,
addTeamMember: (slug: string, teamId: string, body: { userId?: string; feishuOpenId?: string }) =>
post(`${orgBase(slug)}/teams/${teamId}/members`, body) as Promise<TeamMemberRow>,
revokeTeamMember: (slug: string, teamId: string, userId: string) =>
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
post(`${orgBase(slug)}/folders`, body) as Promise<{ id: string; name: string; parentId: string | null; sortKey: string }>,
renameFolder: (slug: string, folderId: string, body: { name?: string; sortKey?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/folders/${folderId}`, body) as Promise<{ id: string; name: string; parentId: string | null; sortKey: string }>,
archiveFolder: (slug: string, folderId: string) =>
post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>,
createProject: (slug: string, body: { name: string; folderId?: string }) =>
post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
project: (slug: string, projectId: string) =>
get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
renameProject: (slug: string, projectId: string, name: string) =>
patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
moveProject: (slug: string, projectId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/projects/${projectId}/folder`, { folderId }),
archiveProject: (slug: string, projectId: string) =>
post(`${orgBase(slug)}/projects/${projectId}/archive`),
archiveBinding: (slug: string, projectId: string) =>
post(`${orgBase(slug)}/projects/${projectId}/binding/archive`),
teamAccess: (slug: string, projectId: string) =>
get(`${orgBase(slug)}/projects/${projectId}/team-access`) as Promise<{ access: TeamAccessEntry[] }>,
grantTeamAccess: (slug: string, projectId: string, body: { teamId?: string; teamSlug?: string; role: string }) =>
put(`${orgBase(slug)}/projects/${projectId}/team-access`, body) as Promise<TeamAccessEntry>,
revokeTeamAccess: (slug: string, projectId: string, teamId: string) =>
del(`${orgBase(slug)}/projects/${projectId}/team-access/${teamId}`),
sessions: (slug: string, projectId: string, limit?: number) =>
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{ sessions: SessionSummary[] }>,
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
if (params?.folderId) q.set('folderId', params.folderId);
const qs = q.toString();
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
},
models: (slug: string) => get(`${orgBase(slug)}/models`) as Promise<{ models: OrgModelRow[] }>,
createModel: (slug: string, body: { modelId: string; label: string; toolCapable?: boolean; sortKey?: string }) =>
post(`${orgBase(slug)}/models`, body) as Promise<OrgModelRow>,
updateModel: (slug: string, id: string, body: { label?: string; toolCapable?: boolean; sortKey?: string; modelId?: string }) =>
patch(`${orgBase(slug)}/models/${id}`, body) as Promise<OrgModelRow>,
deleteModel: (slug: string, id: string) =>
del(`${orgBase(slug)}/models/${id}`),
roles: (slug: string) => get(`${orgBase(slug)}/roles`) as Promise<{ roles: OrgRoleRow[] }>,
createRole: (slug: string, body: { roleId: string; label: string; defaultModelId?: string | null; systemPrompt?: string | null; tools?: string[] | null }) =>
post(`${orgBase(slug)}/roles`, body) as Promise<OrgRoleRow>,
updateRole: (slug: string, id: string, body: { label?: string; defaultModelId?: string | null; systemPrompt?: string | null; tools?: string[] | null; roleId?: string }) =>
patch(`${orgBase(slug)}/roles/${id}`, body) as Promise<OrgRoleRow>,
deleteRole: (slug: string, id: string) =>
del(`${orgBase(slug)}/roles/${id}`),
provider: (slug: string) => get(`${orgBase(slug)}/provider-connection`) as Promise<ProviderConnection>,
setProvider: (slug: string, body: { mode: string; providerId?: string; baseUrl?: string | null; authToken?: string | null }) =>
put(`${orgBase(slug)}/provider-connection`, body) as Promise<ProviderConnection>
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,30 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title = '暂无数据',
description,
action
}: {
title?: string;
description?: string;
action?: Snippet;
} = $props();
</script>
<div class="saas-empty">
<div class="mb-1 flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-100 text-surface-400">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
</svg>
</div>
<p class="text-sm font-medium text-surface-700">{title}</p>
{#if description}
<p class="max-w-sm text-sm text-surface-400">{description}</p>
{/if}
{#if action}
<div class="mt-2">
{@render action()}
</div>
{/if}
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
let {
message,
onretry
}: {
message: string;
onretry?: () => void;
} = $props();
</script>
<div class="saas-card flex flex-wrap items-start gap-3 border-error-200 bg-error-50 p-4 text-error-700">
<svg class="mt-0.5 h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">请求失败</p>
<p class="mt-0.5 text-sm opacity-90">{message}</p>
</div>
{#if onretry}
<button type="button" class="saas-btn-ghost text-sm" onclick={onretry}>重试</button>
{/if}
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import type { ExplorerFolder } from '$lib/api';
import Icon from './Icon.svelte';
import FolderTree from './FolderTree.svelte';
let {
folder,
folders,
projects,
slug
}: {
folder: ExplorerFolder;
folders: ExplorerFolder[];
projects: {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string } | null;
}[];
slug: string;
} = $props();
let open = $state(true);
</script>
<div>
<button
type="button"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition hover:bg-surface-100"
onclick={() => (open = !open)}
>
<span class="w-3.5 text-center text-xs text-surface-400">{open ? '▾' : '▸'}</span>
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-warning-50 text-warning-700">
<Icon name="folder" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1 truncate font-medium text-surface-800">{folder.name}</span>
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
{#if folder.childFolderCount > 0}
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
{/if}
</button>
{#if open}
<div class="ml-4 border-l border-surface-200 pl-2">
<FolderTree {folders} {projects} parentId={folder.id} {slug} />
</div>
{/if}
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { ExplorerFolder } from '$lib/api';
import { fmtDate } from '$lib/format';
import Icon from './Icon.svelte';
import FolderNode from './FolderNode.svelte';
let {
folders,
projects,
parentId,
slug
}: {
folders: ExplorerFolder[];
projects: {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string } | null;
}[];
parentId: string | null;
slug: string;
} = $props();
let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
let childProjects = $derived(projects.filter((p) => p.folderId === parentId));
</script>
<div class="space-y-0.5">
{#each childProjects as p (p.id)}
<a
href={`/admin/org/${slug}/projects/${p.id}`}
class="flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm transition hover:bg-surface-100"
>
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50 text-primary-600">
<Icon name="file" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1 truncate font-medium text-surface-800">{p.name}</span>
{#if p.binding}
<span class="saas-badge-success">已绑定</span>
{/if}
<span class="hidden text-xs text-surface-400 sm:inline">{fmtDate(p.createdAt)}</span>
</a>
{/each}
{#each childFolders as f (f.id)}
<FolderNode folder={f} {folders} {projects} {slug} />
{/each}
</div>
@@ -0,0 +1,115 @@
<script lang="ts">
/** Inline nav icons for the admin shell. */
let {
name,
class: className = 'h-4 w-4'
}: {
name:
| 'overview'
| 'members'
| 'teams'
| 'projects'
| 'models'
| 'roles'
| 'provider'
| 'menu'
| 'logout'
| 'org'
| 'chevron'
| 'folder'
| 'file'
| 'check';
class?: string;
} = $props();
</script>
{#if name === 'overview'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l9-9 9 9M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9" />
</svg>
{:else if name === 'members'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 7.5a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 19.5a7.5 7.5 0 0115 0"
/>
</svg>
{:else if name === 'teams'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.09 9.09 0 003.74-.72 9 9 0 00-5.07-5.95M15 11a4 4 0 10-8 0 4 4 0 008 0zM4.26 18a9 9 0 0115.48 0"
/>
</svg>
{:else if name === 'projects'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
/>
</svg>
{:else if name === 'models'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12l7.5-7.5L19.5 12 12 19.5 4.5 12z" />
</svg>
{:else if name === 'roles'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
{:else if name === 'provider'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 016.364 6.364l-3.182 3.182a4.5 4.5 0 01-6.364-6.364" />
<path stroke-linecap="round" stroke-linejoin="round" d="M10.81 15.312a4.5 4.5 0 01-6.364-6.364l3.182-3.182a4.5 4.5 0 016.364 6.364" />
</svg>
{:else if name === 'menu'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
{:else if name === 'logout'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6A2.25 2.25 0 005.25 5.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l3 3m0 0l-3 3m3-3H6"
/>
</svg>
{:else if name === 'org'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
/>
</svg>
{:else if name === 'chevron'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{:else if name === 'folder'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
/>
</svg>
{:else if name === 'file'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{:else if name === 'check'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{/if}
@@ -0,0 +1,15 @@
<script lang="ts">
let { label = '加载中…' }: { label?: string } = $props();
</script>
<div class="flex flex-col items-center justify-center gap-3 py-16 text-surface-400">
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">{label}</p>
</div>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
open = $bindable(false),
title,
children,
onclose
}: {
open?: boolean;
title: string;
children: Snippet;
onclose?: () => void;
} = $props();
function close() {
open = false;
onclose?.();
}
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) close();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
</script>
{#if open}
<div class="saas-modal-backdrop" role="presentation" onclick={onBackdrop} onkeydown={onKey}>
<div class="saas-modal" role="dialog" aria-modal="true" aria-label={title} tabindex="-1">
<div class="mb-4 flex items-start justify-between gap-3">
<h3 class="text-lg font-semibold text-surface-900">{title}</h3>
<button type="button" class="saas-btn-ghost !px-2 !py-1 text-surface-400" onclick={close} aria-label="关闭">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description,
actions
}: {
title: string;
description?: string;
actions?: Snippet;
} = $props();
</script>
<div class="saas-toolbar">
<div class="min-w-0">
<h1 class="saas-page-title">{title}</h1>
{#if description}
<p class="saas-muted mt-1">{description}</p>
{/if}
</div>
{#if actions}
<div class="ml-auto flex flex-wrap items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
@@ -0,0 +1,148 @@
<script lang="ts">
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import { toastError, toastSuccess } from '$lib/toast';
let {
r,
models,
slug,
onremoved,
onupdated
}: {
r: OrgRoleRow;
models: OrgModelRow[];
slug: string;
onremoved: () => void;
onupdated: (updated: OrgRoleRow) => void;
} = $props();
// Local edit buffer intentionally seeded once from the row props.
const initial = {
roleId: r.roleId,
label: r.label,
defaultModelId: r.defaultModelId ?? '',
systemPrompt: r.systemPrompt ?? '',
unrestricted: r.tools === null,
tools: r.tools ?? []
};
let roleId = $state(initial.roleId);
let label = $state(initial.label);
let defaultModelId = $state(initial.defaultModelId);
let systemPrompt = $state(initial.systemPrompt);
let unrestricted = $state(initial.unrestricted);
let selectedTools = $state<Set<string>>(new Set(initial.tools));
let saving = $state(false);
function toggleTool(id: string) {
const next = new Set(selectedTools);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedTools = next;
}
async function save() {
saving = true;
const tools = unrestricted ? null : [...selectedTools];
try {
const updated = await api.updateRole(slug, r.id, {
roleId: roleId.trim(),
label: label.trim(),
defaultModelId: defaultModelId === '' ? null : defaultModelId,
systemPrompt: systemPrompt === '' ? null : systemPrompt,
tools
});
onupdated(updated);
toastSuccess('角色已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => {
(acc[t.group] ??= []).push(t);
return acc;
},
{} as Record<string, typeof TOOL_OPTIONS>
);
</script>
<div class="saas-card-pad">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="saas-badge-primary font-mono">/{roleId || r.roleId}</span>
<span class="text-sm text-surface-500">{label || r.label}</span>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="role-id-{r.id}" class="saas-label">roleId</label>
<input id="role-id-{r.id}" class="saas-input font-mono text-sm" bind:value={roleId} />
</div>
<div>
<label for="role-label-{r.id}" class="saas-label">label</label>
<input id="role-label-{r.id}" class="saas-input" bind:value={label} />
</div>
</div>
<div class="mt-4">
<label for="role-model-{r.id}" class="saas-label">默认模型</label>
<select id="role-model-{r.id}" class="saas-select" bind:value={defaultModelId}>
<option value="">(角色默认 → 首模型)</option>
{#each models as m}
<option value={m.modelId}>{m.label} ({m.modelId})</option>
{/each}
</select>
</div>
<div class="mt-4">
<span class="saas-label">工具白名单</span>
<label class="mb-3 flex cursor-pointer items-center gap-2 rounded-lg border border-surface-200 bg-surface-100/40 px-3 py-2">
<input type="checkbox" class="checkbox" bind:checked={unrestricted} />
<span class="text-sm">不限(使用全部注册工具)</span>
</label>
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
{#each Object.entries(groupedTools) as [group, tools]}
<div>
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-400">{group}</p>
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each tools as t}
<label class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-surface-100">
<input
type="checkbox"
class="checkbox"
checked={selectedTools.has(t.id)}
onchange={() => toggleTool(t.id)}
/>
{t.label}
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
<div class="mt-4">
<label for="role-prompt-{r.id}" class="saas-label">系统提示词</label>
<textarea
id="role-prompt-{r.id}"
class="saas-textarea"
rows="4"
placeholder="系统提示词(可选)。会话开始时注入,定义 agent 人格/指令。"
bind:value={systemPrompt}
></textarea>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<span class="text-xs text-surface-400">更新于 {fmtDate(r.updatedAt)}</span>
<div class="flex-1"></div>
<button class="saas-btn-danger" onclick={onremoved}>删除角色</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
let {
label,
value,
hint
}: {
label: string;
value: string;
hint?: string;
} = $props();
</script>
<div class="saas-stat">
<div class="saas-stat-label">{label}</div>
<div class="saas-stat-value">{value}</div>
{#if hint}
<div class="saas-help">{hint}</div>
{/if}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { dismissToast, toasts } from '$lib/toast';
const kindClass: Record<string, string> = {
info: 'border-surface-200 bg-surface-50 text-surface-800',
success: 'border-success-200 bg-success-50 text-success-800',
error: 'border-error-200 bg-error-50 text-error-800'
};
</script>
<div class="pointer-events-none fixed inset-x-0 top-0 z-[100] flex flex-col items-end gap-2 p-4">
{#each $toasts as t (t.id)}
<div
class="pointer-events-auto flex max-w-sm items-start gap-3 rounded-xl border px-4 py-3 text-sm shadow-lg {kindClass[t.kind] ??
kindClass.info}"
role="status"
>
<p class="min-w-0 flex-1">{t.message}</p>
<button type="button" class="opacity-60 hover:opacity-100" onclick={() => dismissToast(t.id)} aria-label="关闭">
×
</button>
</div>
{/each}
</div>
+22
View File
@@ -0,0 +1,22 @@
export interface ToolOption {
id: string;
label: string;
group: string;
}
export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'read_file', label: '读取文件 (Read)', group: '文件' },
{ id: 'write_file', label: '写入文件 (Write)', group: '文件' },
{ id: 'list_files', label: '列目录 (Glob)', group: '文件' },
{ id: 'search_files', label: '搜索 (Grep)', group: '文件' },
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件 (飞书)', group: '飞书 MCP' },
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书 MCP' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书 MCP' },
{ id: 'request_approval', label: '请求审批', group: '飞书 MCP' }
];
export const ORG_ROLES = ['OWNER', 'ADMIN', 'MEMBER'] as const;
export const PERMISSION_ROLES = ['READ', 'EDIT', 'MANAGE'] as const;
+28
View File
@@ -0,0 +1,28 @@
export function fmtDate(iso: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
export function fmtDateOnly(iso: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
}
export function fmtCost(usd: number | null): string {
if (usd === null) return '—';
return `$${Number(usd).toFixed(4)}`;
}
export function fmtNum(n: number): string {
return n.toLocaleString();
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+43
View File
@@ -0,0 +1,43 @@
import { writable } from 'svelte/store';
import { api, type MeResponse } from './api';
interface SessionState {
loading: boolean;
me: MeResponse | null;
error: string | null;
}
export const session = writable<SessionState>({
loading: true,
me: null,
error: null
});
export async function loadSession(): Promise<void> {
session.update((s) => ({ ...s, loading: true, error: null }));
try {
const me = await api.me();
session.set({ loading: false, me, error: null });
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 401) {
redirectToLogin();
return;
}
session.set({
loading: false,
me: null,
error: err instanceof Error ? err.message : String(err)
});
}
}
export function redirectToLogin(): void {
const ret = encodeURIComponent(window.location.pathname + window.location.hash);
window.location.href = `/auth/feishu?returnTo=${ret}`;
}
export async function logout(): Promise<void> {
await api.logout();
redirectToLogin();
}
+34
View File
@@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
export type ToastKind = 'info' | 'success' | 'error';
export interface ToastItem {
id: number;
message: string;
kind: ToastKind;
}
let seq = 0;
export const toasts = writable<ToastItem[]>([]);
export function pushToast(message: string, kind: ToastKind = 'info', ms = 3200): void {
const id = ++seq;
toasts.update((list) => [...list, { id, message, kind }]);
if (ms > 0) {
setTimeout(() => {
toasts.update((list) => list.filter((t) => t.id !== id));
}, ms);
}
}
export function dismissToast(id: number): void {
toasts.update((list) => list.filter((t) => t.id !== id));
}
export function toastSuccess(message: string): void {
pushToast(message, 'success');
}
export function toastError(message: string): void {
pushToast(message, 'error', 5000);
}
+321
View File
@@ -0,0 +1,321 @@
<script lang="ts">
import '../routes/app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
let { children } = $props();
let mobileNavOpen = $state(false);
let redirecting = $state(false);
onMount(() => {
loadSession();
});
const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'models', label: '模型', icon: 'models' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'provider', label: 'Provider', icon: 'provider' as const }
];
function isAdmin(org: OrgMembership): boolean {
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
function orgSlugFromPath(): string | null {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
return decodeURIComponent(parts[2]);
}
return null;
}
function memberships(): OrgMembership[] {
return $session.me?.organizations ?? [];
}
function adminOrgs(): OrgMembership[] {
return memberships().filter(isAdmin);
}
/** Org matching the URL slug (if any). */
function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath();
if (!slug) return null;
return memberships().find((o) => o.slug === slug) ?? null;
}
function pickHomeOrg(): OrgMembership | null {
const admin = adminOrgs()[0];
if (admin) return admin;
return memberships()[0] ?? null;
}
function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return '';
return parts[3] ?? 'overview';
}
function navHref(key: string): string {
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
if (!slug) return '/';
if (key === 'overview') return `/admin/org/${slug}`;
return `/admin/org/${slug}/${key}`;
}
function pageTitle(): string {
const key = activeKey();
if (key === 'overview' || key === '') return '概览';
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function onOrgSwitch(e: Event) {
const sel = e.target as HTMLSelectElement;
void goto(`/admin/org/${sel.value}`);
}
function handleLogout(e: Event) {
e.preventDefault();
logout();
}
// Close mobile nav on route change.
$effect(() => {
page.url.pathname;
mobileNavOpen = false;
});
// If the user has admin rights but the URL is not under a valid org slug,
// send them to their home org. Fixes false "未加入组织" on / or wrong paths.
$effect(() => {
if ($session.loading || !$session.me) return;
const admins = adminOrgs();
if (admins.length === 0) return;
const slug = orgSlugFromPath();
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
if (matched && isAdmin(matched)) {
redirecting = false;
return;
}
// Only auto-redirect when URL is not already a matched *member* org
// (member orgs keep their own denied screen). Missing / unknown slug → home.
if (!matched) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
}
});
</script>
<ToastHost />
{#if $session.loading || redirecting}
<div class="saas-status-panel">
<div class="flex flex-col items-center gap-3 text-surface-400">
<svg class="h-8 w-8 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p>
</div>
</div>
{:else if $session.error}
<div class="saas-status-panel">
<div class="saas-status-card">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-error-100 text-error-600">!</div>
<h2 class="mb-1 text-lg font-semibold">无法连接到后端</h2>
<p class="mb-5 text-sm text-surface-500">{$session.error}</p>
<button class="saas-btn-primary" onclick={() => loadSession()}>重试</button>
</div>
</div>
{:else if !$session.me}
<div class="saas-status-panel">
<div class="saas-status-card">
<div class="mx-auto mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary-600 text-white font-bold">
CPH
</div>
<h1 class="text-xl font-semibold tracking-tight">Curriculum Project Hub</h1>
<p class="mt-2 mb-6 text-sm text-surface-500">登录以管理组织、项目、团队与模型供应。</p>
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div>
</div>
{:else if currentOrg() && isAdmin(currentOrg()!)}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
{#if mobileNavOpen}
<button
type="button"
class="fixed inset-0 z-40 bg-surface-950/40 md:hidden"
aria-label="关闭导航"
onclick={() => (mobileNavOpen = false)}
></button>
{/if}
<aside
class="saas-sidebar fixed inset-y-0 left-0 z-50 transition-transform md:static md:translate-x-0
{mobileNavOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}"
>
<div class="flex items-center gap-2.5 px-4 py-4">
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-600 text-xs font-bold tracking-wide text-white">
CPH
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">Org Admin</div>
<div class="truncate text-xs text-surface-400">Curriculum Hub</div>
</div>
</div>
<div class="px-3 pb-3">
<label for="org-sel" class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-500">
<Icon name="org" class="h-3.5 w-3.5" />
组织
</label>
<select id="org-sel" class="saas-select text-sm" value={org.slug} onchange={onOrgSwitch}>
{#each me.organizations as o}
<option value={o.slug}>{o.name} · {o.role}</option>
{/each}
</select>
</div>
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400">工作台</p>
{#each navItems as item}
{@const active = activeKey() === item.key || (item.key === 'overview' && (activeKey() === '' || activeKey() === 'overview'))}
<a href={navHref(item.key)} class="saas-nav-item" data-active={active ? 'true' : 'false'}>
<Icon name={item.icon} class="h-4 w-4 shrink-0 opacity-80" />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="border-t border-surface-200 p-3">
<div class="flex items-center gap-2.5 rounded-xl bg-surface-100/80 px-2.5 py-2">
{#if me.user.avatarUrl}
<img src={me.user.avatarUrl} alt="" class="h-8 w-8 rounded-full object-cover" />
{:else}
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700">
{me.user.displayName.slice(0, 1)}
</div>
{/if}
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-surface-800">{me.user.displayName}</div>
<div class="truncate text-[11px] text-surface-400">{org.role}</div>
</div>
<button
type="button"
class="rounded-lg p-1.5 text-surface-400 transition hover:bg-surface-200 hover:text-error-600"
title="退出登录"
onclick={handleLogout}
>
<Icon name="logout" class="h-4 w-4" />
</button>
</div>
</div>
</aside>
<div class="saas-main">
<header class="saas-topbar">
<button
type="button"
class="saas-btn-ghost !px-2 md:hidden"
onclick={() => (mobileNavOpen = !mobileNavOpen)}
aria-label="打开导航"
>
<Icon name="menu" class="h-5 w-5" />
</button>
<div class="min-w-0">
<div class="flex items-center gap-1.5 text-xs text-surface-400">
<span class="truncate">{org.name}</span>
<span>/</span>
<span class="truncate text-surface-600">{pageTitle()}</span>
</div>
</div>
<div class="ml-auto hidden items-center gap-2 sm:flex">
<span class="saas-badge-primary">{org.status}</span>
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
</div>
</header>
<main class="saas-content">
<div class="saas-content-inner">
{@render children()}
</div>
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!)}
{@const denied = currentOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
<p class="mb-3 text-sm text-surface-500">
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{denied.role}</span>
需要 <strong>OWNER</strong><strong>ADMIN</strong>
</p>
{#if memberships().length > 1}
<label for="org-switch" class="saas-label text-left">切换到其他组织</label>
<select id="org-switch" class="saas-select mb-4" onchange={onOrgSwitch}>
{#each memberships() as o}
<option value={o.slug} selected={o.slug === denied.slug}>{o.name} · {o.role}</option>
{/each}
</select>
{/if}
<button class="saas-btn-ghost" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else if memberships().length > 0}
{@const denied = pickHomeOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
{#if adminOrgs().length === 0}
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
<p class="mb-5 text-sm text-surface-500">
你加入了 <strong>{denied.name}</strong>,角色是
<span class="saas-badge-neutral mx-1">{denied.role}</span>。仅 OWNER/ADMIN 可进入后台。
</p>
{:else}
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-500">
即将进入 <strong>{adminOrgs()[0].name}</strong>
/{adminOrgs()[0].slug})。
</p>
<a class="saas-btn-primary" href={`/admin/org/${adminOrgs()[0].slug}`}>立即进入</a>
{/if}
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">未加入组织</h2>
<p class="mb-3 text-sm text-surface-500">
飞书账号已登录,但 <code class="font-mono">/api/me</code>
<code class="font-mono">organizations</code> 为空。
</p>
<p class="mb-5 text-left text-xs text-surface-400">
open_id
<code class="break-all font-mono text-surface-600">{$session.me!.user.feishuOpenId}</code>
</p>
<button class="saas-btn-primary" onclick={handleLogout}>退出登录</button>
</div>
</div>
{/if}
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { session, redirectToLogin } from '$lib/session';
function homeForSession(): string | null {
const me = $session.me;
if (!me) return null;
const admin = me.organizations.find((o) => {
const r = String(o.role ?? '').toUpperCase();
return r === 'OWNER' || r === 'ADMIN';
});
const target = admin ?? me.organizations[0];
return target ? `/admin/org/${target.slug}` : null;
}
onMount(() => {
const unsub = session.subscribe((s) => {
if (s.loading) return;
if (!s.me) {
redirectToLogin();
return;
}
const dest = homeForSession();
if (dest) void goto(dest, { replaceState: true });
});
return unsub;
});
</script>
<div class="saas-status-panel">
<div class="flex flex-col items-center gap-3 text-surface-400">
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">正在进入工作台…</p>
</div>
</div>
@@ -0,0 +1,158 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { fmtCost, fmtNum } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let orgSlug = $derived(page.params.slug ?? '');
let org = $derived(
$session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined
);
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
let usage = $state<Awaited<ReturnType<typeof api.usage>> | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
[settings, usage] = await Promise.all([api.settings(orgSlug), api.usage(orgSlug)]);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function toggleCreate() {
if (!settings) return;
saving = true;
const prev = settings.membersCanCreateProjects;
settings.membersCanCreateProjects = !prev;
try {
await api.setSettings(orgSlug, { membersCanCreateProjects: settings.membersCanCreateProjects });
toastSuccess(settings.membersCanCreateProjects ? '已允许成员自助建项' : '已关闭成员自助建项');
} catch (err) {
settings.membersCanCreateProjects = prev;
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (orgSlug) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if org && settings && usage}
<PageHeader title={org.name} description="组织健康度、用量与生产策略一览。" />
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="saas-card-pad sm:col-span-2 lg:col-span-1">
<p class="saas-section-title mb-3">组织信息</p>
<dl class="space-y-2.5 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-500">Slug</dt>
<dd class="font-mono text-xs text-surface-800">/{org.slug}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-500">状态</dt>
<dd><span class="saas-badge-success">{org.status}</span></dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-500">你的角色</dt>
<dd><span class="saas-badge-primary">{org.role}</span></dd>
</div>
</dl>
</div>
<div class="saas-card-pad sm:col-span-2">
<p class="saas-section-title mb-1">项目自助创建策略</p>
<p class="saas-muted mb-4">
开启后,普通老师可在飞书群自助创建项目;关闭后仅 OWNER / ADMIN 可建。
</p>
<label class="flex cursor-pointer items-center gap-3 rounded-xl border border-surface-200 bg-surface-100/50 px-4 py-3">
<input
type="checkbox"
class="checkbox"
checked={settings.membersCanCreateProjects}
disabled={saving}
onchange={toggleCreate}
/>
<div>
<div class="text-sm font-medium text-surface-800">允许成员自助创建项目</div>
<div class="text-xs text-surface-400">membersCanCreateProjects</div>
</div>
</label>
</div>
</div>
<div class="mb-4 flex items-end justify-between gap-3">
<div>
<h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">全组织 Agent 运行汇总</p>
</div>
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</div>
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
</div>
{#if usage.projects.length === 0}
<div class="saas-empty">
<p class="text-sm text-surface-400">暂无项目用量数据</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>运行</th>
<th>in / out tokens</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">{p.projectName}</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-600"
>{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td
>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{:else}
<div class="saas-empty">
<p class="text-sm text-surface-400">组织数据不可用</p>
</div>
{/if}
@@ -0,0 +1,168 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api';
import { fmtDate } from '$lib/format';
import { ORG_ROLES } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const roles = [...ORG_ROLES];
let members = $state<OrgMember[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newOpenId = $state('');
let newName = $state('');
let newRole = $state<string>('MEMBER');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.members(slug);
members = res.members;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function addMember() {
if (!newOpenId.trim()) return;
adding = true;
try {
const m = await api.addMember(slug, {
feishuOpenId: newOpenId.trim(),
role: newRole,
...(newName.trim() ? { displayName: newName.trim() } : {})
});
members = [...members, m].sort(
(a, b) => a.role.localeCompare(b.role) || a.createdAt.localeCompare(b.createdAt)
);
newOpenId = '';
newName = '';
toastSuccess('成员已添加');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function changeRole(m: OrgMember, role: string) {
try {
await api.setMemberRole(slug, m.userId, role);
await load();
toastSuccess('角色已更新');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revoke(m: OrgMember) {
if (!confirm(`移除成员 ${m.displayName} 出本组织?`)) return;
try {
await api.revokeMember(slug, m.userId);
await load();
toastSuccess('成员已移除');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader title="成员与权限" description="管理组织角色:OWNER / ADMIN 可访问本后台,MEMBER 不可。" />
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">添加成员</h2>
<div class="grid gap-3 md:grid-cols-[1.2fr_1fr_auto_auto]">
<input class="saas-input" placeholder="Feishu open_id" bind:value={newOpenId} />
<input class="saas-input" placeholder="显示名(可选)" bind:value={newName} />
<select class="saas-select" bind:value={newRole}>
{#each roles as r}<option value={r}>{r}</option>{/each}
</select>
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
{adding ? '添加中…' : '添加成员'}
</button>
</div>
</div>
<div class="saas-card overflow-hidden">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
<h2 class="text-sm font-semibold">成员列表</h2>
<span class="saas-badge-neutral">{members.length}</span>
</div>
{#if members.length === 0}
<EmptyState title="暂无成员" description="使用上方表单按飞书 open_id 添加成员。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>用户</th>
<th>open_id</th>
<th>组织角色</th>
<th>加入时间</th>
<th></th>
</tr>
</thead>
<tbody>
{#each members as m}
<tr>
<td>
<div class="flex items-center gap-2.5">
{#if m.avatarUrl}
<img src={m.avatarUrl} alt="" class="h-7 w-7 rounded-full object-cover" />
{:else}
<div
class="flex h-7 w-7 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700"
>
{m.displayName.slice(0, 1)}
</div>
{/if}
<span class="font-medium">{m.displayName}</span>
</div>
</td>
<td class="font-mono text-xs text-surface-500">{m.feishuOpenId}</td>
<td>
<select
class="saas-select max-w-[9rem] py-1.5 text-sm"
value={m.role}
onchange={(e) => changeRole(m, (e.target as HTMLSelectElement).value)}
>
{#each roles as r}
<option value={r} selected={r === m.role}>{r}</option>
{/each}
</select>
</td>
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
<td class="text-right">
<button class="saas-btn-danger !py-1 text-sm" onclick={() => revoke(m)}>移除</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<p class="border-t border-surface-100 px-5 py-3 text-xs text-surface-400">
组织角色控制后台访问;项目级权限由「项目」页团队授权(READ / EDIT / MANAGE)决定。
</p>
</div>
{/if}
@@ -0,0 +1,170 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgModelRow } from '$lib/api';
import { fmtDateOnly } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let models = $state<OrgModelRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newModelId = $state('');
let newLabel = $state('');
let newSortKey = $state('');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.models(slug);
models = res.models;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function add() {
if (!newModelId.trim() || !newLabel.trim()) return;
adding = true;
try {
const m = await api.createModel(slug, {
modelId: newModelId.trim(),
label: newLabel.trim(),
...(newSortKey.trim() ? { sortKey: newSortKey.trim() } : {})
});
models = [...models, m];
newModelId = '';
newLabel = '';
newSortKey = '';
toastSuccess('模型已添加');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function save(m: OrgModelRow, patch: Partial<OrgModelRow>) {
try {
const updated = await api.updateModel(slug, m.id, patch);
models = models.map((x) => (x.id === m.id ? updated : x));
toastSuccess('已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
await load();
}
}
async function remove(m: OrgModelRow) {
if (!confirm(`删除模型 ${m.label}?`)) return;
try {
await api.deleteModel(slug, m.id);
models = models.filter((x) => x.id !== m.id);
toastSuccess('模型已删除');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="模型"
description="Org-scoped 模型清单(ADR-0017)。modelId 面向 providerlabel 面向教师。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">添加模型</h2>
<div class="grid gap-3 md:grid-cols-[1.4fr_1fr_auto_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="modelId(如 anthropic/claude-sonnet-5"
bind:value={newModelId}
/>
<input class="saas-input" placeholder="label(如 Claude Sonnet 5" bind:value={newLabel} />
<input class="saas-input w-28" placeholder="排序" bind:value={newSortKey} />
<button class="saas-btn-primary" onclick={add} disabled={adding}>添加</button>
</div>
</div>
<div class="saas-card overflow-hidden">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
<h2 class="text-sm font-semibold">已启用模型</h2>
<span class="saas-badge-neutral">{models.length}</span>
</div>
{#if models.length === 0}
<EmptyState title="暂无已启用模型" description="空清单时会回退到环境默认模型。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>label</th>
<th>modelId</th>
<th>工具能力</th>
<th>排序</th>
<th>创建</th>
<th></th>
</tr>
</thead>
<tbody>
{#each models as m (m.id)}
<tr>
<td>
<input
class="saas-input py-1.5"
value={m.label}
onchange={(e) => save(m, { label: (e.target as HTMLInputElement).value })}
/>
</td>
<td>
<input
class="saas-input py-1.5 font-mono text-xs"
value={m.modelId}
onchange={(e) => save(m, { modelId: (e.target as HTMLInputElement).value })}
/>
</td>
<td>
<input
type="checkbox"
class="checkbox"
checked={m.toolCapable}
onchange={(e) => save(m, { toolCapable: (e.target as HTMLInputElement).checked })}
/>
</td>
<td>
<input
class="saas-input w-20 py-1.5"
value={m.sortKey}
onchange={(e) => save(m, { sortKey: (e.target as HTMLInputElement).value })}
/>
</td>
<td class="text-xs text-surface-400">{fmtDateOnly(m.createdAt)}</td>
<td class="text-right">
<button class="saas-btn-danger !py-1 text-xs" onclick={() => remove(m)}>删除</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,158 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder } from '$lib/api';
import FolderTree from '$lib/components/FolderTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Modal from '$lib/components/Modal.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let data = $state<ExplorerData | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let showFolderModal = $state(false);
let folderName = $state('');
let folderParent = $state('');
let showProjectModal = $state(false);
let projectName = $state('');
let projectFolder = $state('');
async function load() {
loading = true;
error = null;
try {
data = await api.explorer(slug);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function createFolder() {
if (!folderName.trim()) return;
try {
await api.createFolder(slug, {
name: folderName.trim(),
...(folderParent ? { parentId: folderParent } : {})
});
folderName = '';
folderParent = '';
showFolderModal = false;
await load();
toastSuccess('文件夹已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function createProject() {
if (!projectName.trim()) return;
try {
const res = await api.createProject(slug, {
name: projectName.trim(),
...(projectFolder ? { folderId: projectFolder } : {})
});
projectName = '';
projectFolder = '';
showProjectModal = false;
window.location.href = `/admin/org/${slug}/projects/${res.id}`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
function folderPath(f: ExplorerFolder): string {
if (!data) return f.name;
const parts: string[] = [f.name];
let cur: ExplorerFolder | undefined = f;
while (cur?.parentId) {
const parent = data.folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
{#snippet actions()}
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button>
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button>
{/snippet}
</PageHeader>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if data}
<div class="saas-card p-2 sm:p-3">
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
{:else}
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
{/if}
</div>
{/if}
<Modal bind:open={showFolderModal} title="新建文件夹">
<label class="saas-label" for="folder-name">名称</label>
<input
id="folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') createFolder();
}}
/>
{#if data && data.folders.length > 0}
<label class="saas-label" for="folder-parent">父文件夹(可选)</label>
<select id="folder-parent" class="saas-select mb-4" bind:value={folderParent}>
<option value="">(根)</option>
{#each data.folders as f}
<option value={f.id}>{folderPath(f)}</option>
{/each}
</select>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={createFolder}>创建</button>
</div>
</Modal>
<Modal bind:open={showProjectModal} title="新建项目">
<label class="saas-label" for="project-name">项目名</label>
<input
id="project-name"
class="saas-input mb-4"
bind:value={projectName}
onkeydown={(e) => {
if (e.key === 'Enter') createProject();
}}
/>
{#if data && data.folders.length > 0}
<label class="saas-label" for="project-folder">文件夹(可选)</label>
<select id="project-folder" class="saas-select mb-4" bind:value={projectFolder}>
<option value="">(根)</option>
{#each data.folders as f}
<option value={f.id}>{folderPath(f)}</option>
{/each}
</select>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={createProject}>创建</button>
</div>
</Modal>
@@ -0,0 +1,274 @@
<script lang="ts">
import { page } from '$app/state';
import {
api,
type ProjectDetail,
type TeamAccessEntry,
type TeamRow,
type SessionSummary,
type ExplorerData
} from '$lib/api';
import { fmtDate } from '$lib/format';
import { PERMISSION_ROLES } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const projectId = $derived(page.params.projectId ?? '');
let proj = $state<ProjectDetail | null>(null);
let access = $state<TeamAccessEntry[]>([]);
let sessions = $state<SessionSummary[]>([]);
let teams = $state<TeamRow[]>([]);
let explorer = $state<ExplorerData | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let grantTeam = $state('');
let grantRole = $state<string>('EDIT');
let moveFolder = $state('');
async function load() {
loading = true;
error = null;
try {
const [p, a, s, t, e] = await Promise.all([
api.project(slug, projectId),
api.teamAccess(slug, projectId),
api.sessions(slug, projectId),
api.teams(slug),
api.explorer(slug)
]);
proj = p;
access = a.access;
sessions = s.sessions;
teams = t.teams;
explorer = e;
moveFolder = p.folderId ?? '';
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function rename() {
if (!proj) return;
const name = prompt('新名称', proj.name);
if (!name) return;
try {
await api.renameProject(slug, projectId, name);
await load();
toastSuccess('已重命名');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function archiveBinding() {
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发 agent。')) return;
try {
await api.archiveBinding(slug, projectId);
await load();
toastSuccess('已解绑飞书群');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function archiveProject() {
if (!confirm(`归档项目 ${proj?.name}?`)) return;
try {
await api.archiveProject(slug, projectId);
window.location.href = `/admin/org/${slug}/projects`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function move() {
try {
await api.moveProject(slug, projectId, moveFolder || null);
await load();
toastSuccess('已移动');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function grant() {
if (!grantTeam) return;
try {
await api.grantTeamAccess(slug, projectId, { teamId: grantTeam, role: grantRole });
grantTeam = '';
await load();
toastSuccess('已授权');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revoke(t: TeamAccessEntry) {
if (!confirm(`撤销 ${t.teamName} 对此项目的授权?`)) return;
try {
await api.revokeTeamAccess(slug, projectId, t.teamId);
await load();
toastSuccess('已撤销授权');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug && projectId) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if proj}
<div class="mb-2">
<a href={`/admin/org/${slug}/projects`} class="inline-flex items-center gap-1 text-sm text-surface-500 hover:text-primary-600">
← 返回项目列表
</a>
</div>
{@const detail = proj}
<PageHeader title={detail.name} description="项目是权限边界;通过团队授予 READ EDIT MANAGE。">
{#snippet actions()}
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={rename}>重命名</button>
{#if detail.binding}
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={archiveBinding}>解绑飞书群</button>
{/if}
<button class="saas-btn-danger !py-1.5 text-sm" onclick={archiveProject}>归档</button>
{/snippet}
</PageHeader>
<div class="saas-card-pad mb-6">
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">Workspace</dt>
<dd class="mt-0.5 break-all font-mono text-xs text-surface-700">{proj.workspaceDir}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">创建者</dt>
<dd class="mt-0.5 text-surface-800">
{proj.createdBy ? `${proj.createdBy.displayName} (${proj.createdBy.feishuOpenId})` : '—'}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">文件夹</dt>
<dd class="mt-0.5 text-surface-800">{proj.folder ? proj.folder.name : '(根)'}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">飞书群</dt>
<dd class="mt-0.5 text-surface-800">
{#if proj.binding}
<span class="saas-badge-success mr-1">已绑定</span>
<span class="font-mono text-xs">chat {proj.binding.chatId}</span>
<span class="text-surface-400"> · {fmtDate(proj.binding.createdAt)}</span>
{:else}
<span class="saas-badge-neutral">未绑定</span>
{/if}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">创建时间</dt>
<dd class="mt-0.5 text-surface-800">{fmtDate(proj.createdAt)}</dd>
</div>
</dl>
{#if explorer}
<div class="mt-5 flex flex-wrap items-center gap-2 border-t border-surface-100 pt-4">
<span class="text-sm font-medium text-surface-700">移动到文件夹</span>
<select class="saas-select max-w-xs" bind:value={moveFolder}>
<option value="">(根)</option>
{#each explorer.folders as f}
<option value={f.id}>{f.name}</option>
{/each}
</select>
<button class="saas-btn-secondary" onclick={move}>移动</button>
</div>
{/if}
</div>
<div class="saas-card-pad mb-6">
<h3 class="saas-section-title mb-1">团队授权</h3>
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_auto_auto]">
<select class="saas-select" bind:value={grantTeam}>
<option value="">选择团队…</option>
{#each teams as t}
<option value={t.id}>{t.name} ({t.slug})</option>
{/each}
</select>
<select class="saas-select" bind:value={grantRole}>
{#each [...PERMISSION_ROLES] as r}<option value={r}>{r}</option>{/each}
</select>
<button class="saas-btn-primary" onclick={grant}>授权</button>
</div>
{#if access.length === 0}
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>团队</th>
<th>slug</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each access as g}
<tr>
<td class="font-medium">{g.teamName}</td>
<td class="font-mono text-xs">/{g.teamSlug}</td>
<td><span class="saas-badge-primary">{g.role}</span></td>
<td class="text-right">
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revoke(g)}>撤销</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">Agent 会话</h3>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发 agent 后会显示在此。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>provider / role</th>
<th>model</th>
<th>运行</th>
<th>更新</th>
</tr>
</thead>
<tbody>
{#each sessions as s}
<tr>
<td class="font-mono text-xs">{s.provider} / {s.roleId}</td>
<td class="font-mono text-xs">{s.model}</td>
<td class="tabular-nums">{s.runCount}</td>
<td class="text-surface-500">{fmtDate(s.updatedAt)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
@@ -0,0 +1,145 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ProviderConnection } from '$lib/api';
import { fmtDate } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let conn = $state<ProviderConnection | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let mode = $state<'BYOK' | 'PLATFORM_MANAGED'>('PLATFORM_MANAGED');
let providerId = $state('openrouter');
let baseUrl = $state('');
let authToken = $state('');
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
conn = await api.provider(slug);
mode = conn.mode;
providerId = conn.providerId;
baseUrl = conn.baseUrl ?? '';
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function save() {
saving = true;
const body: { mode: string; providerId: string; baseUrl?: string; authToken?: string } = {
mode,
providerId: providerId.trim()
};
if (mode === 'BYOK') {
if (baseUrl.trim()) body.baseUrl = baseUrl.trim();
if (authToken !== '') body.authToken = authToken;
}
try {
conn = await api.setProvider(slug, body);
mode = conn.mode;
providerId = conn.providerId;
baseUrl = conn.baseUrl ?? '';
authToken = '';
toastSuccess('Provider 配置已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="Provider 连接"
description="ADR-0021:连接归属且仅归属本 org。BYOK 自带密钥;PLATFORM_MANAGED 平台托管。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if conn}
<div class="saas-card-pad mb-6">
<div class="grid gap-5">
<div>
<label class="saas-label" for="mode">凭据模式</label>
<select id="mode" class="saas-select" bind:value={mode}>
<option value="PLATFORM_MANAGED">PLATFORM_MANAGED(平台托管)</option>
<option value="BYOK">BYOK(自带密钥)</option>
</select>
</div>
<div>
<label class="saas-label" for="provider-id">providerId</label>
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} />
</div>
{#if mode === 'BYOK'}
<div>
<label class="saas-label" for="base-url">Base URL</label>
<input
id="base-url"
class="saas-input"
placeholder="https://openrouter.ai/api"
bind:value={baseUrl}
/>
</div>
<div>
<label class="saas-label" for="auth-token">Auth Token</label>
<input
id="auth-token"
class="saas-input"
type="password"
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : 'auth token'}
bind:value={authToken}
/>
{#if conn.hasAuthToken}
<p class="saas-help">已存储密钥;输入新值替换,留空不变。</p>
{/if}
</div>
{/if}
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<span class="text-xs text-surface-400"
>{conn.updatedAt ? '更新于 ' + fmtDate(conn.updatedAt) : '尚未配置'}</span
>
<div class="flex-1"></div>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
<div class="saas-card-pad border-dashed">
<h3 class="saas-section-title mb-2">运行时解析</h3>
<ul class="space-y-2 text-sm text-surface-500">
<li class="flex gap-2"><span class="text-primary-500"></span><span>BYOKagent 使用本连接 baseUrl + authToken。</span></li>
<li class="flex gap-2">
<span class="text-primary-500"></span>
<span>
PLATFORM_MANAGED / 未配置:回退进程环境变量(ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN)。真正 per-org
平台托管凭据是后续 secret-control-planeOPEN)。
</span>
</li>
<li class="flex gap-2">
<span class="text-primary-500"></span>
<span>模型与角色在对应页面管理;空时回退环境默认。</span>
</li>
</ul>
</div>
{/if}
@@ -0,0 +1,106 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let roles = $state<OrgRoleRow[]>([]);
let models = $state<OrgModelRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, m] = await Promise.all([api.roles(slug), api.models(slug)]);
roles = r.roles;
models = m.models;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function add() {
if (!newRoleId.trim() || !newLabel.trim()) return;
adding = true;
try {
const r = await api.createRole(slug, { roleId: newRoleId.trim(), label: newLabel.trim() });
roles = [...roles, r];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function remove(r: OrgRoleRow) {
if (!confirm(`删除角色 ${r.label} (${r.roleId})?`)) return;
try {
await api.deleteRole(slug, r.id);
roles = roles.filter((x) => x.id !== r.id);
toastSuccess('角色已删除');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="角色"
description="ADR-0017:角色是数据。roleId 即斜杠命令(/draft…),绑定默认模型、工具白名单与系统提示词。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input class="saas-input font-mono text-sm" placeholder="roleId(如 draft" bind:value={newRoleId} />
<input class="saas-input" placeholder="label(如 草稿)" bind:value={newLabel} />
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="空时回退环境默认(draft / review)。" />
</div>
{:else}
<div class="space-y-4">
{#each roles as r (r.id)}
<RoleCard
{r}
{models}
{slug}
onremoved={() => remove(r)}
onupdated={(updated: OrgRoleRow) => {
roles = roles.map((x) => (x.id === r.id ? updated : x));
}}
/>
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,215 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
import { fmtDate } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let teams = $state<TeamRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newSlug = $state('');
let newName = $state('');
let newDesc = $state('');
let adding = $state(false);
let expandedId = $state<string | null>(null);
let teamMembers = $state<TeamMemberRow[]>([]);
let memberInput = $state('');
let loadingMembers = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.teams(slug);
teams = res.teams;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function createTeam() {
if (!newSlug.trim() || !newName.trim()) return;
adding = true;
try {
await api.createTeam(slug, {
slug: newSlug.trim(),
name: newName.trim(),
...(newDesc.trim() ? { description: newDesc.trim() } : {})
});
newSlug = '';
newName = '';
newDesc = '';
await load();
toastSuccess('团队已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function archiveTeam(t: TeamRow) {
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
try {
await api.archiveTeam(slug, t.id);
await load();
toastSuccess('团队已归档');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function toggleMembers(t: TeamRow) {
if (expandedId === t.id) {
expandedId = null;
return;
}
expandedId = t.id;
loadingMembers = true;
try {
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
loadingMembers = false;
}
}
async function addMember(t: TeamRow) {
if (!memberInput.trim()) return;
const v = memberInput.trim();
try {
await api.addTeamMember(slug, t.id, v.startsWith('ou') ? { feishuOpenId: v } : { userId: v });
memberInput = '';
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
await load();
toastSuccess('已加入团队');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revokeMember(t: TeamRow, m: TeamMemberRow) {
if (!confirm(`将 ${m.displayName} 移出团队 ${t.name}?`)) return;
try {
await api.revokeTeamMember(slug, t.id, m.userId);
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
await load();
toastSuccess('已移出团队');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader title="团队" description="团队是项目授权的 principal,可被授予 READ / EDIT / MANAGE。" />
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建团队</h2>
<div class="grid gap-3 md:grid-cols-[1fr_1fr_1.2fr_auto]">
<input class="saas-input" placeholder="slug(小写字母数字)" bind:value={newSlug} />
<input class="saas-input" placeholder="名称" bind:value={newName} />
<input class="saas-input" placeholder="描述(可选)" bind:value={newDesc} />
<button class="saas-btn-primary" onclick={createTeam} disabled={adding}>新建</button>
</div>
</div>
{#if teams.length === 0}
<div class="saas-card">
<EmptyState title="暂无团队" description="创建团队后,可在项目页授权项目访问。" />
</div>
{:else}
<div class="space-y-3">
{#each teams as t (t.id)}
<div class="saas-card p-5">
<div class="flex flex-wrap items-center gap-2.5">
<span class="text-base font-semibold text-surface-900">{t.name}</span>
<span class="font-mono text-xs text-surface-400">/{t.slug}</span>
<span class="saas-badge-neutral">{t.memberCount} 成员</span>
<div class="ml-auto flex flex-wrap gap-2">
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={() => toggleMembers(t)}>
{expandedId === t.id ? '收起' : '管理成员'}
</button>
<button class="saas-btn-danger !py-1.5 text-sm" onclick={() => archiveTeam(t)}>归档</button>
</div>
</div>
{#if t.description}
<p class="mt-1.5 text-sm text-surface-500">{t.description}</p>
{/if}
<p class="mt-1 text-xs text-surface-400">创建于 {fmtDate(t.createdAt)}</p>
{#if expandedId === t.id}
<div class="mt-4 border-t border-surface-200 pt-4">
{#if loadingMembers}
<p class="text-sm text-surface-400">加载中…</p>
{:else}
<div class="mb-4 flex gap-2">
<input
class="saas-input"
placeholder="Feishu open_id 或用户 id"
bind:value={memberInput}
onkeydown={(e) => {
if (e.key === 'Enter') addMember(t);
}}
/>
<button class="saas-btn-secondary shrink-0" onclick={() => addMember(t)}>加入</button>
</div>
{#if teamMembers.length === 0}
<p class="py-3 text-center text-sm text-surface-400">团队暂无成员</p>
{:else}
<table class="data-table">
<thead>
<tr>
<th>成员</th>
<th>open_id</th>
<th>加入时间</th>
<th></th>
</tr>
</thead>
<tbody>
{#each teamMembers as m}
<tr>
<td class="font-medium">{m.displayName}</td>
<td class="font-mono text-xs">{m.feishuOpenId}</td>
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
<td class="text-right">
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revokeMember(t, m)}
>移除</button
>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
<p class="mt-4 text-xs text-surface-400">归档团队会同步撤销其活跃的项目授权。</p>
{/if}
+431
View File
@@ -0,0 +1,431 @@
@import 'tailwindcss';
@import '@skeletonlabs/skeleton';
@import '@skeletonlabs/skeleton/themes/hamlindigo';
@source './**/*.{html,js,svelte,ts}';
@source '../lib/**/*.{html,js,svelte,ts}';
@theme {
--font-sans: 'Inter', 'Noto Sans SC', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
@layer base {
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
min-height: 100%;
font-family: var(--font-sans);
background: var(--color-surface-50);
color: var(--color-surface-900);
}
::selection {
background: color-mix(in oklab, var(--color-primary-500) 28%, transparent);
}
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
table.data-table thead th {
padding: 0.625rem 0.75rem;
text-align: left;
font-weight: 500;
color: var(--color-surface-500);
border-bottom: 1px solid var(--color-surface-200);
white-space: nowrap;
}
table.data-table tbody td {
padding: 0.75rem;
border-bottom: 1px solid var(--color-surface-100);
vertical-align: middle;
}
table.data-table tbody tr:hover td {
background: color-mix(in oklab, var(--color-surface-100) 70%, transparent);
}
table.data-table tbody tr:last-child td {
border-bottom: none;
}
}
@layer components {
.saas-card {
border-radius: 0.75rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
}
.saas-card-pad {
border-radius: 0.75rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
padding: 1.25rem;
}
.saas-page-title {
font-size: 1.5rem;
line-height: 2rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--color-surface-900);
}
.saas-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-surface-900);
}
.saas-muted {
font-size: 0.875rem;
color: var(--color-surface-500);
}
.saas-label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-surface-700);
}
.saas-help {
margin-top: 0.375rem;
font-size: 0.75rem;
color: var(--color-surface-400);
}
.saas-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.saas-stat {
border-radius: 0.75rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
padding: 1rem;
}
.saas-stat-label {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-surface-500);
}
.saas-stat-value {
margin-top: 0.25rem;
font-size: 1.5rem;
line-height: 2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
color: var(--color-surface-900);
}
.saas-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 1rem;
text-align: center;
}
.saas-shell {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--color-surface-100);
}
.saas-sidebar {
display: flex;
width: 16rem;
flex-shrink: 0;
flex-direction: column;
border-right: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
}
.saas-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.saas-topbar {
display: flex;
height: 3.5rem;
flex-shrink: 0;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--color-surface-200);
background: color-mix(in oklab, var(--color-surface-50) 90%, transparent);
padding: 0 1.5rem;
backdrop-filter: blur(8px);
}
.saas-content {
flex: 1;
overflow-y: auto;
}
.saas-content-inner {
margin-inline: auto;
width: 100%;
max-width: 72rem;
padding: 1.5rem;
}
@media (min-width: 768px) {
.saas-content-inner {
padding: 2rem;
}
}
.saas-nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-surface-600);
transition: color 0.15s, background-color 0.15s;
}
.saas-nav-item:hover {
background: var(--color-surface-100);
color: var(--color-surface-900);
}
.saas-nav-item[data-active='true'] {
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.saas-badge {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
}
.saas-badge-neutral {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
background: var(--color-surface-200);
color: var(--color-surface-700);
}
.saas-badge-primary {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.saas-badge-success {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
background: var(--color-success-100);
color: var(--color-success-700);
}
.saas-badge-warning {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
background: var(--color-warning-100);
color: var(--color-warning-800);
}
.saas-badge-error {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
background: var(--color-error-100);
color: var(--color-error-700);
}
.saas-input,
.saas-select,
.saas-textarea {
width: 100%;
border-radius: 0.5rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
color: var(--color-surface-900);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.saas-input:focus,
.saas-select:focus,
.saas-textarea:focus {
border-color: var(--color-primary-400);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary-500) 20%, transparent);
}
.saas-textarea {
font-family: var(--font-mono);
resize: vertical;
}
.saas-btn-primary,
.saas-btn-secondary,
.saas-btn-ghost,
.saas-btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
border-radius: 0.5rem;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
border: 1px solid transparent;
cursor: pointer;
transition: background-color 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
}
.saas-btn-primary:disabled,
.saas-btn-secondary:disabled,
.saas-btn-ghost:disabled,
.saas-btn-danger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.saas-btn-primary {
background: var(--color-primary-500);
color: var(--color-primary-contrast-500, white);
}
.saas-btn-primary:hover:not(:disabled) {
background: var(--color-primary-600);
}
.saas-btn-secondary {
background: var(--color-secondary-100);
color: var(--color-secondary-800);
}
.saas-btn-secondary:hover:not(:disabled) {
background: var(--color-secondary-200);
}
.saas-btn-ghost {
background: var(--color-surface-100);
color: var(--color-surface-700);
}
.saas-btn-ghost:hover:not(:disabled) {
background: var(--color-surface-200);
}
.saas-btn-danger {
background: var(--color-error-100);
color: var(--color-error-700);
}
.saas-btn-danger:hover:not(:disabled) {
background: var(--color-error-200);
}
.saas-modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgb(2 6 23 / 0.4);
padding: 1rem;
backdrop-filter: blur(2px);
}
.saas-modal {
width: 100%;
max-width: 28rem;
border-radius: 1rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
padding: 1.5rem;
box-shadow: 0 20px 40px rgb(15 23 42 / 0.16);
}
.saas-status-panel {
display: flex;
min-height: 100vh;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--color-surface-100);
padding: 1rem;
}
.saas-status-card {
width: 100%;
max-width: 28rem;
border-radius: 1rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
padding: 2rem;
text-align: center;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
<path d="M8 10.5h7.5a3.5 3.5 0 0 1 0 7H11v4H8v-11Zm3 4.5h4.5a1.5 1.5 0 0 0 0-3H11v3Z" fill="white"/>
<path d="M20.5 21.5c1.93 0 3.5-1.34 3.5-3s-1.57-3-3.5-3S17 16.84 17 18.5s1.57 3 3.5 3Z" fill="white" opacity=".9"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+18
View File
@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false
})
}
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+13
View File
@@ -0,0 +1,13 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8788',
'/auth': 'http://127.0.0.1:8788'
}
}
});
+18 -151
View File
@@ -131,9 +131,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -147,9 +144,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -163,9 +157,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -179,9 +170,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -241,34 +229,10 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -858,7 +822,6 @@
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.14.1"
},
@@ -1199,9 +1162,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1219,9 +1179,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1239,9 +1196,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1259,9 +1213,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1279,9 +1230,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1299,9 +1247,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1392,8 +1337,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
@@ -1585,7 +1529,6 @@
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
@@ -1599,7 +1542,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -1609,7 +1551,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -1745,7 +1686,6 @@
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
@@ -1770,7 +1710,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1784,7 +1723,6 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -1930,7 +1868,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1944,7 +1881,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -1974,7 +1910,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.6.0"
}
@@ -1984,7 +1919,6 @@
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"object-assign": "^4",
"vary": "^1"
@@ -2002,7 +1936,6 @@
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"peer": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -2060,7 +1993,6 @@
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2121,8 +2053,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/effect": {
"version": "3.21.0",
@@ -2150,7 +2081,6 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2253,8 +2183,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "3.0.3",
@@ -2271,7 +2200,6 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2281,7 +2209,6 @@
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"peer": true,
"dependencies": {
"eventsource-parser": "^3.0.1"
},
@@ -2313,7 +2240,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -2357,7 +2283,6 @@
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"peer": true,
"dependencies": {
"ip-address": "^10.2.0"
},
@@ -2376,7 +2301,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2386,7 +2310,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2396,7 +2319,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -2487,8 +2409,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense",
"peer": true
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.3",
@@ -2587,7 +2508,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
@@ -2659,7 +2579,6 @@
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2669,7 +2588,6 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2819,7 +2737,6 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
@@ -2853,7 +2770,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -2869,15 +2785,13 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 12"
}
@@ -2895,15 +2809,13 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
@@ -2920,7 +2832,6 @@
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@@ -2955,7 +2866,6 @@
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
@@ -2974,8 +2884,7 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/light-my-request": {
"version": "6.6.0",
@@ -3157,9 +3066,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3181,9 +3087,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3205,9 +3108,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3229,9 +3129,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3335,7 +3232,6 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -3345,7 +3241,6 @@
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -3404,7 +3299,6 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -3446,7 +3340,6 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3498,7 +3391,6 @@
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"peer": true,
"dependencies": {
"ee-first": "1.1.1"
},
@@ -3511,7 +3403,6 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"peer": true,
"dependencies": {
"wrappy": "1"
}
@@ -3521,7 +3412,6 @@
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -3531,7 +3421,6 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3541,7 +3430,6 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
@@ -3574,6 +3462,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3623,7 +3512,6 @@
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.20.0"
}
@@ -3676,6 +3564,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.19.3",
"@prisma/engines": "6.19.3"
@@ -3739,7 +3628,6 @@
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -3753,7 +3641,6 @@
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.10"
}
@@ -3811,7 +3698,6 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
},
@@ -3825,7 +3711,6 @@
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
@@ -3943,7 +3828,6 @@
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
@@ -3990,8 +3874,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
@@ -4026,7 +3909,6 @@
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
@@ -4053,7 +3935,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -4063,7 +3944,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -4080,7 +3960,6 @@
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
@@ -4105,15 +3984,13 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"peer": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -4126,7 +4003,6 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -4250,7 +4126,6 @@
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
@@ -4261,7 +4136,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -4349,7 +4223,6 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.6"
}
@@ -4358,8 +4231,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
@@ -4375,6 +4247,7 @@
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
@@ -4393,7 +4266,6 @@
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"peer": true,
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
@@ -4412,7 +4284,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -4426,7 +4297,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -4436,7 +4306,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -4454,6 +4323,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4473,7 +4343,6 @@
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -4483,7 +4352,6 @@
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -4494,6 +4362,7 @@
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -4661,7 +4530,6 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"peer": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -4693,8 +4561,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.0",
@@ -4722,6 +4589,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -4731,7 +4599,6 @@
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peer": true,
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
@@ -0,0 +1,62 @@
-- ADR-0021 / ADR-0017: org-scoped model provider connection, enabled models,
-- and role presets. Aligns to Spec.System.Organization (ProviderCredentialMode,
-- OrganizationProviderConnection) and the "roles are data" seam in agent/models.
CREATE TYPE "ProviderCredentialMode" AS ENUM ('BYOK', 'PLATFORM_MANAGED');
CREATE TABLE "OrganizationProviderConnection" (
"organizationId" TEXT NOT NULL,
"providerId" TEXT NOT NULL DEFAULT 'openrouter',
"mode" "ProviderCredentialMode" NOT NULL DEFAULT 'PLATFORM_MANAGED',
"baseUrl" TEXT,
"authToken" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrganizationProviderConnection_pkey" PRIMARY KEY ("organizationId")
);
ALTER TABLE "OrganizationProviderConnection"
ADD CONSTRAINT "OrganizationProviderConnection_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "OrgModel" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"modelId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"toolCapable" BOOLEAN NOT NULL DEFAULT true,
"sortKey" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrgModel_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrgModel_organizationId_modelId_key" ON "OrgModel"("organizationId", "modelId");
CREATE INDEX "OrgModel_organizationId_sortKey_idx" ON "OrgModel"("organizationId", "sortKey");
ALTER TABLE "OrgModel"
ADD CONSTRAINT "OrgModel_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "OrgRole" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"defaultModelId" TEXT,
"systemPrompt" TEXT,
"tools" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrgRole_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrgRole_organizationId_roleId_key" ON "OrgRole"("organizationId", "roleId");
CREATE INDEX "OrgRole_organizationId_idx" ON "OrgRole"("organizationId");
ALTER TABLE "OrgRole"
ADD CONSTRAINT "OrgRole_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+252
View File
@@ -0,0 +1,252 @@
/**
* Org-admin routes for agent config: provider connection, enabled models, and
* role presets (ADR-0017 / ADR-0021). Mounted under `/api/org/:orgSlug`.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
createOrgModel,
createOrgRole,
deleteOrgModel,
deleteOrgRole,
getProviderConnection,
listOrgModels,
listOrgRoles,
updateOrgModel,
updateOrgRole,
upsertProviderConnection,
} from "../../org/agentConfig.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
const PROVIDER_MODES = ["BYOK", "PLATFORM_MANAGED"] as const;
type ProviderMode = (typeof PROVIDER_MODES)[number];
export async function registerModelRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
// --- Provider connection ---------------------------------------------
app.get("/api/org/:orgSlug/provider-connection", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await getProviderConnection(config.prisma, auth.organization.id);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/provider-connection", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
providerId?: unknown;
mode?: unknown;
baseUrl?: unknown;
authToken?: unknown;
};
const mode = parseProviderMode(body.mode);
if (mode === null) {
return reply.status(400).send({
error: { code: "bad_request", message: "mode must be BYOK or PLATFORM_MANAGED" },
});
}
const connection = await upsertProviderConnection(config.prisma, {
organizationId: auth.organization.id,
mode,
...(typeof body.providerId === "string" ? { providerId: body.providerId } : {}),
...(body.baseUrl === null || typeof body.baseUrl === "string"
? { baseUrl: body.baseUrl as string | null }
: {}),
...(body.authToken === null || typeof body.authToken === "string"
? { authToken: body.authToken as string | null }
: {}),
});
return connection;
} catch (err) {
return handleRouteError(reply, err);
}
});
// --- Models ----------------------------------------------------------
app.get("/api/org/:orgSlug/models", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { models: await listOrgModels(config.prisma, auth.organization.id) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/models", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
modelId?: unknown;
label?: unknown;
toolCapable?: unknown;
sortKey?: unknown;
};
if (typeof body.modelId !== "string" || typeof body.label !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "modelId and label are required" },
});
}
const model = await createOrgModel(config.prisma, {
organizationId: auth.organization.id,
modelId: body.modelId,
label: body.label,
...(typeof body.toolCapable === "boolean" ? { toolCapable: body.toolCapable } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
});
return reply.status(201).send(model);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/models/:modelId", async (request, reply) => {
try {
const { orgSlug, modelId } = request.params as { orgSlug: string; modelId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
label?: unknown;
toolCapable?: unknown;
sortKey?: unknown;
modelId?: unknown;
};
return await updateOrgModel(config.prisma, {
organizationId: auth.organization.id,
id: modelId,
...(typeof body.label === "string" ? { label: body.label } : {}),
...(typeof body.toolCapable === "boolean" ? { toolCapable: body.toolCapable } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
...(typeof body.modelId === "string" ? { modelId: body.modelId } : {}),
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/models/:modelId", async (request, reply) => {
try {
const { orgSlug, modelId } = request.params as { orgSlug: string; modelId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await deleteOrgModel(config.prisma, {
organizationId: auth.organization.id,
id: modelId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
// --- Roles -----------------------------------------------------------
app.get("/api/org/:orgSlug/roles", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { roles: await listOrgRoles(config.prisma, auth.organization.id) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/roles", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
roleId?: unknown;
label?: unknown;
defaultModelId?: unknown;
systemPrompt?: unknown;
tools?: unknown;
};
if (typeof body.roleId !== "string" || typeof body.label !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "roleId and label are required" },
});
}
const role = await createOrgRole(config.prisma, {
organizationId: auth.organization.id,
roleId: body.roleId,
label: body.label,
...(typeof body.defaultModelId === "string" ? { defaultModelId: body.defaultModelId } : {}),
...(body.defaultModelId === null ? { defaultModelId: null } : {}),
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
...(body.systemPrompt === null ? { systemPrompt: null } : {}),
...(Array.isArray(body.tools) ? { tools: body.tools as readonly string[] } : {}),
...(body.tools === null ? { tools: null } : {}),
});
return reply.status(201).send(role);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/roles/:roleId", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
label?: unknown;
defaultModelId?: unknown;
systemPrompt?: unknown;
tools?: unknown;
roleId?: unknown;
};
return await updateOrgRole(config.prisma, {
organizationId: auth.organization.id,
id: roleId,
...(typeof body.label === "string" ? { label: body.label } : {}),
...(typeof body.defaultModelId === "string" ? { defaultModelId: body.defaultModelId } : {}),
...(body.defaultModelId === null ? { defaultModelId: null } : {}),
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
...(body.systemPrompt === null ? { systemPrompt: null } : {}),
...(Array.isArray(body.tools) ? { tools: body.tools as readonly string[] } : {}),
...(body.tools === null ? { tools: null } : {}),
...(typeof body.roleId === "string" ? { roleId: body.roleId } : {}),
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/roles/:roleId", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await deleteOrgRole(config.prisma, {
organizationId: auth.organization.id,
id: roleId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}
function parseProviderMode(value: unknown): ProviderMode | null {
if (typeof value !== "string") return null;
return (PROVIDER_MODES as readonly string[]).includes(value) ? (value as ProviderMode) : null;
}
+5
View File
@@ -12,6 +12,7 @@ import {
import { registerAccessRoutes } from "./accessRoutes.js";
import { registerExplorerRoutes } from "./explorerRoutes.js";
import { registerMembersRoutes } from "./membersRoutes.js";
import { registerModelRoutes } from "./modelRoutes.js";
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
import { registerTeamsRoutes } from "./teamsRoutes.js";
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
@@ -105,6 +106,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerModelRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerSessionsAndUsageRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
+82
View File
@@ -0,0 +1,82 @@
/**
* Serves the org-admin SPA (built by SvelteKit via `admin-web/build/`) and
* the SPA index fallback for client-side routes under `/admin/*`.
*
* The SvelteKit project lives in `hub/admin-web/`. Run `npm run build` there
* to produce the static output in `admin-web/build/`. In development, use
* `npm run dev` in `admin-web/` which proxies `/api` and `/auth` to the Hub.
*
* Override the UI directory with `CPH_ADMIN_UI_DIR` env if needed.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, extname, join, resolve as resolvePath } from "node:path";
import type { FastifyInstance } from "fastify";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".json": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
};
function resolveUiDir(): string {
const override = process.env["CPH_ADMIN_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "admin-web", "build"));
}
export async function registerStaticSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn(
{ uiDir },
"admin-web/build not found; SPA shell disabled. Run `npm run build` in admin-web/ to enable. Org admin APIs remain fully functional.",
);
return;
}
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// SvelteKit static assets (_app/*, favicon, etc.)
app.get("/_app/*", async (request, reply) => {
const rel = (request.params as { "*": string })["*"];
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
try {
const buf = await readFile(join(uiDir, "_app", safe));
const mime = MIME[extname(safe)] ?? "application/octet-stream";
return reply.type(mime).send(buf);
} catch {
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
}
});
// Other top-level static assets (favicon.svg, robots.txt, etc.)
app.get("/favicon.svg", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "favicon.svg"));
return reply.type("image/svg+xml").send(buf);
} catch {
return reply.status(404).send();
}
});
// SPA client-side route fallback. `/admin/login` is registered earlier as a
// static route and takes precedence; everything else under /admin/* serves
// the index so SvelteKit's client-side router can resolve the view.
app.get("/admin", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
app.get("/admin/*", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
}
+386
View File
@@ -0,0 +1,386 @@
/**
* Org-scoped agent config: provider connection, enabled models, and role
* presets (ADR-0017 / ADR-0021).
*
* Roles and models are **data** (ADR-0017): admin/teacher-defined, persisted
* per organization. The runtime registry (`settings/runtime.ts`) reads these
* rows, falling back to env-based defaults when an org has none configured.
*
* Provider credential storage is a pilot placeholder: the spec leaves key/base
* URL encryption, rotation and resolver mechanism OPEN (secret-control-plane).
* BYOK tokens are stored as opaque strings here; platform-managed credentials
* are resolved from process env, not stored in this table.
*/
import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { assertSupportedRoleTools } from "../agent/roleTools.js";
export interface ProviderConnectionRow {
readonly organizationId: string;
readonly providerId: string;
readonly mode: "BYOK" | "PLATFORM_MANAGED";
readonly baseUrl: string | null;
readonly hasAuthToken: boolean;
readonly createdAt: string;
readonly updatedAt: string;
}
export async function getProviderConnection(
prisma: PrismaClient,
organizationId: string,
): Promise<ProviderConnectionRow> {
const row = await prisma.organizationProviderConnection.findUnique({
where: { organizationId },
});
if (row === null) {
return {
organizationId,
providerId: "openrouter",
mode: "PLATFORM_MANAGED",
baseUrl: null,
hasAuthToken: false,
createdAt: "",
updatedAt: "",
};
}
return {
organizationId: row.organizationId,
providerId: row.providerId,
mode: row.mode,
baseUrl: row.baseUrl,
hasAuthToken: row.authToken !== null && row.authToken !== "",
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function upsertProviderConnection(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly providerId?: string | undefined;
readonly mode: "BYOK" | "PLATFORM_MANAGED";
readonly baseUrl?: string | null | undefined;
readonly authToken?: string | null | undefined;
},
): Promise<ProviderConnectionRow> {
const providerId = input.providerId ?? "openrouter";
if (providerId.trim() === "") throw new Error("providerId is required");
const baseUrl =
input.baseUrl === undefined || input.baseUrl === null
? null
: input.baseUrl.trim() === ""
? null
: input.baseUrl.trim();
const authToken =
input.authToken === undefined || input.authToken === null
? undefined
: input.authToken === ""
? null
: input.authToken;
const row = await prisma.organizationProviderConnection.upsert({
where: { organizationId: input.organizationId },
create: {
organizationId: input.organizationId,
providerId,
mode: input.mode,
baseUrl,
...(authToken !== undefined ? { authToken } : {}),
},
update: {
providerId,
mode: input.mode,
baseUrl,
...(authToken !== undefined ? { authToken } : {}),
},
});
return {
organizationId: row.organizationId,
providerId: row.providerId,
mode: row.mode,
baseUrl: row.baseUrl,
hasAuthToken: row.authToken !== null && row.authToken !== "",
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export interface OrgModelRow {
readonly id: string;
readonly modelId: string;
readonly label: string;
readonly toolCapable: boolean;
readonly sortKey: string;
readonly createdAt: string;
readonly updatedAt: string;
}
export async function listOrgModels(
prisma: PrismaClient,
organizationId: string,
): Promise<readonly OrgModelRow[]> {
const rows = await prisma.orgModel.findMany({
where: { organizationId },
orderBy: [{ sortKey: "asc" }, { createdAt: "asc" }],
});
return rows.map(toModelRow);
}
export async function createOrgModel(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly modelId: string;
readonly label: string;
readonly toolCapable?: boolean | undefined;
readonly sortKey?: string | undefined;
},
): Promise<OrgModelRow> {
const modelId = requireNonEmpty(input.modelId, "modelId");
const label = requireNonEmpty(input.label, "label");
const existing = await prisma.orgModel.findUnique({
where: { organizationId_modelId: { organizationId: input.organizationId, modelId } },
select: { id: true },
});
if (existing !== null) {
throw new Error(`model already exists: ${modelId}`);
}
const row = await prisma.orgModel.create({
data: {
organizationId: input.organizationId,
modelId,
label,
...(input.toolCapable !== undefined ? { toolCapable: input.toolCapable } : {}),
...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}),
},
});
return toModelRow(row);
}
export async function updateOrgModel(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly id: string;
readonly label?: string | undefined;
readonly toolCapable?: boolean | undefined;
readonly sortKey?: string | undefined;
readonly modelId?: string | undefined;
},
): Promise<OrgModelRow> {
const row = await prisma.orgModel.findUnique({ where: { id: input.id } });
if (row === null || row.organizationId !== input.organizationId) {
throw new Error(`model not found: ${input.id}`);
}
const updated = await prisma.orgModel.update({
where: { id: row.id },
data: {
...(input.label !== undefined ? { label: requireNonEmpty(input.label, "label") } : {}),
...(input.toolCapable !== undefined ? { toolCapable: input.toolCapable } : {}),
...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}),
...(input.modelId !== undefined
? { modelId: requireNonEmpty(input.modelId, "modelId") }
: {}),
},
});
return toModelRow(updated);
}
export async function deleteOrgModel(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly id: string },
): Promise<{ readonly deleted: true; readonly id: string }> {
const row = await prisma.orgModel.findUnique({ where: { id: input.id } });
if (row === null || row.organizationId !== input.organizationId) {
throw new Error(`model not found: ${input.id}`);
}
await prisma.orgModel.delete({ where: { id: row.id } });
return { deleted: true as const, id: row.id };
}
export interface OrgRoleRow {
readonly id: string;
readonly roleId: string;
readonly label: string;
readonly defaultModelId: string | null;
readonly systemPrompt: string | null;
readonly tools: readonly string[] | null;
readonly createdAt: string;
readonly updatedAt: string;
}
export async function listOrgRoles(
prisma: PrismaClient,
organizationId: string,
): Promise<readonly OrgRoleRow[]> {
const rows = await prisma.orgRole.findMany({
where: { organizationId },
orderBy: [{ roleId: "asc" }],
});
return rows.map(toRoleRow);
}
export async function createOrgRole(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly roleId: string;
readonly label: string;
readonly defaultModelId?: string | null | undefined;
readonly systemPrompt?: string | null | undefined;
readonly tools?: readonly string[] | null | undefined;
},
): Promise<OrgRoleRow> {
const roleId = requireNonEmpty(input.roleId, "roleId");
const label = requireNonEmpty(input.label, "label");
const existing = await prisma.orgRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId } },
select: { id: true },
});
if (existing !== null) {
throw new Error(`role already exists: ${roleId}`);
}
const tools = normalizeTools(input.tools);
const row = await prisma.orgRole.create({
data: {
organizationId: input.organizationId,
roleId,
label,
...(input.defaultModelId !== undefined && input.defaultModelId !== null
? { defaultModelId: input.defaultModelId }
: {}),
...(input.defaultModelId === null ? { defaultModelId: null } : {}),
...(input.systemPrompt !== undefined && input.systemPrompt !== null
? { systemPrompt: input.systemPrompt }
: {}),
...(input.systemPrompt === null ? { systemPrompt: null } : {}),
...(tools !== undefined ? { tools: tools ?? Prisma.DbNull } : {}),
},
});
return toRoleRow(row);
}
export async function updateOrgRole(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly id: string;
readonly label?: string | undefined;
readonly defaultModelId?: string | null | undefined;
readonly systemPrompt?: string | null | undefined;
readonly tools?: readonly string[] | null | undefined;
readonly roleId?: string | undefined;
},
): Promise<OrgRoleRow> {
const row = await prisma.orgRole.findUnique({ where: { id: input.id } });
if (row === null || row.organizationId !== input.organizationId) {
throw new Error(`role not found: ${input.id}`);
}
const tools = normalizeTools(input.tools);
const updated = await prisma.orgRole.update({
where: { id: row.id },
data: {
...(input.label !== undefined ? { label: requireNonEmpty(input.label, "label") } : {}),
...(input.defaultModelId !== undefined && input.defaultModelId !== null
? { defaultModelId: input.defaultModelId }
: {}),
...(input.defaultModelId === null ? { defaultModelId: null } : {}),
...(input.systemPrompt !== undefined && input.systemPrompt !== null
? { systemPrompt: input.systemPrompt }
: {}),
...(input.systemPrompt === null ? { systemPrompt: null } : {}),
...(tools !== undefined ? { tools: tools ?? Prisma.DbNull } : {}),
...(input.roleId !== undefined ? { roleId: requireNonEmpty(input.roleId, "roleId") } : {}),
},
});
return toRoleRow(updated);
}
export async function deleteOrgRole(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly id: string },
): Promise<{ readonly deleted: true; readonly id: string }> {
const row = await prisma.orgRole.findUnique({ where: { id: input.id } });
if (row === null || row.organizationId !== input.organizationId) {
throw new Error(`role not found: ${input.id}`);
}
await prisma.orgRole.delete({ where: { id: row.id } });
return { deleted: true as const, id: row.id };
}
function toModelRow(
row: {
readonly id: string;
readonly modelId: string;
readonly label: string;
readonly toolCapable: boolean;
readonly sortKey: string;
readonly createdAt: Date;
readonly updatedAt: Date;
},
): OrgModelRow {
return {
id: row.id,
modelId: row.modelId,
label: row.label,
toolCapable: row.toolCapable,
sortKey: row.sortKey,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function toRoleRow(
row: {
readonly id: string;
readonly roleId: string;
readonly label: string;
readonly defaultModelId: string | null;
readonly systemPrompt: string | null;
readonly tools: unknown;
readonly createdAt: Date;
readonly updatedAt: Date;
},
): OrgRoleRow {
return {
id: row.id,
roleId: row.roleId,
label: row.label,
defaultModelId: row.defaultModelId,
systemPrompt: row.systemPrompt,
tools: parseTools(row.tools),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* Normalize a tools input into a value safe for prisma `tools` (Json?) column.
* - `undefined` ⇒ omit (leave unchanged): returns `undefined`.
* - `null` ⇒ unrestricted role (SQL NULL): returns `null`.
* - array ⇒ validated against the supported role tool set (fails fast).
*/
function normalizeTools(
tools: readonly string[] | null | undefined,
): readonly string[] | null | undefined {
if (tools === undefined) return undefined;
if (tools === null) return null;
const list = [...tools];
assertSupportedRoleTools(list);
return list;
}
function parseTools(tools: unknown): readonly string[] | null {
if (tools === null) return null;
if (!Array.isArray(tools)) return null;
return tools.filter((t): t is string => typeof t === "string");
}
function requireNonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} is required`);
return trimmed;
}
@@ -0,0 +1,239 @@
/**
* Org agent-config API tests: provider connection, models, roles (ADR-0017 /
* ADR-0021). Also covers the DB-backed runtime registry wiring.
*/
import Fastify from "fastify";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { registerAdminPlugin } from "../../src/admin/plugin.js";
import {
mintSessionToken,
sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { createDbRuntimeSettings } from "../../src/settings/runtime.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const SESSION_SECRET = "integration-test-session-secret";
async function buildApp() {
const app = Fastify({ logger: false });
await registerAdminPlugin(app, {
prisma,
sessionSecret: SESSION_SECRET,
publicBaseUrl: "http://127.0.0.1:8788",
feishuAppId: "cli_test",
feishuAppSecret: "secret_test",
projectWorkspaceRoot: "/tmp/cph-test-ws",
cookieSecure: false,
});
await app.ready();
return app;
}
async function seedOwner(): Promise<string> {
await prisma.user.create({
data: { id: "u-owner", feishuOpenId: "ou_owner", displayName: "Owner" },
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u-owner", role: "OWNER" },
});
return mintSessionToken({ userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET);
}
describe("admin agent-config API (provider/models/roles)", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("upserts a BYOK provider connection and reads it back without exposing the token", async () => {
const token = await seedOwner();
const app = await buildApp();
try {
const cookie = sessionCookieHeader(token);
const put = await app.inject({
method: "PUT",
url: "/api/org/test-default/provider-connection",
headers: { cookie, "content-type": "application/json" },
payload: { mode: "BYOK", baseUrl: "https://gw.test/api", authToken: "secret-token-123" },
});
expect(put.statusCode).toBe(200);
const conn = put.json() as { mode: string; baseUrl: string; hasAuthToken: boolean };
expect(conn.mode).toBe("BYOK");
expect(conn.baseUrl).toBe("https://gw.test/api");
expect(conn.hasAuthToken).toBe(true);
const get = await app.inject({
method: "GET",
url: "/api/org/test-default/provider-connection",
headers: { cookie },
});
const got = get.json() as { mode: string; hasAuthToken: boolean };
expect(got.mode).toBe("BYOK");
expect(got.hasAuthToken).toBe(true);
// Token value must never be returned by GET.
expect(get.body).not.toContain("secret-token-123");
} finally {
await app.close();
}
});
it("creates, updates and deletes an enabled model", async () => {
const token = await seedOwner();
const app = await buildApp();
try {
const cookie = sessionCookieHeader(token);
const create = await app.inject({
method: "POST",
url: "/api/org/test-default/models",
headers: { cookie, "content-type": "application/json" },
payload: { modelId: "anthropic/claude-sonnet-5", label: "Sonnet 5" },
});
expect(create.statusCode).toBe(201);
const model = create.json() as { id: string; modelId: string };
expect(model.modelId).toBe("anthropic/claude-sonnet-5");
// Duplicate modelId is rejected.
const dup = await app.inject({
method: "POST",
url: "/api/org/test-default/models",
headers: { cookie, "content-type": "application/json" },
payload: { modelId: "anthropic/claude-sonnet-5", label: "dup" },
});
expect(dup.statusCode).toBe(400);
const upd = await app.inject({
method: "PATCH",
url: `/api/org/test-default/models/${model.id}`,
headers: { cookie, "content-type": "application/json" },
payload: { label: "Sonnet 5 (tool)", toolCapable: true, sortKey: "10" },
});
expect(upd.statusCode).toBe(200);
expect((upd.json() as { label: string }).label).toBe("Sonnet 5 (tool)");
const list = await app.inject({
method: "GET",
url: "/api/org/test-default/models",
headers: { cookie },
});
expect((list.json() as { models: unknown[] }).models).toHaveLength(1);
const del = await app.inject({
method: "DELETE",
url: `/api/org/test-default/models/${model.id}`,
headers: { cookie },
});
expect(del.statusCode).toBe(200);
const after = await app.inject({
method: "GET",
url: "/api/org/test-default/models",
headers: { cookie },
});
expect((after.json() as { models: unknown[] }).models).toHaveLength(0);
} finally {
await app.close();
}
});
it("creates a role with tools whitelist and rejects unknown tool ids", async () => {
const token = await seedOwner();
const app = await buildApp();
try {
const cookie = sessionCookieHeader(token);
const create = await app.inject({
method: "POST",
url: "/api/org/test-default/roles",
headers: { cookie, "content-type": "application/json" },
payload: {
roleId: "draft",
label: "草稿",
systemPrompt: "You are a teaching drafter.",
tools: ["read_file", "write_file", "cph_check"],
},
});
expect(create.statusCode).toBe(201);
const role = create.json() as { id: string; roleId: string; tools: string[] };
expect(role.roleId).toBe("draft");
expect(role.tools).toEqual(["read_file", "write_file", "cph_check"]);
// Unknown tool id is rejected.
const bad = await app.inject({
method: "POST",
url: "/api/org/test-default/roles",
headers: { cookie, "content-type": "application/json" },
payload: { roleId: "bad", label: "bad", tools: ["not_a_real_tool"] },
});
expect(bad.statusCode).toBe(500);
// null tools ⇒ unrestricted.
const unrestricted = await app.inject({
method: "PATCH",
url: `/api/org/test-default/roles/${role.id}`,
headers: { cookie, "content-type": "application/json" },
payload: { tools: null },
});
expect(unrestricted.statusCode).toBe(200);
expect((unrestricted.json() as { tools: string[] | null }).tools).toBeNull();
} finally {
await app.close();
}
});
it("DbRuntimeSettings reads org-scoped models/roles and falls back to env", async () => {
const token = await seedOwner();
const app = await buildApp();
try {
const cookie = sessionCookieHeader(token);
await app.inject({
method: "POST",
url: "/api/org/test-default/models",
headers: { cookie, "content-type": "application/json" },
payload: { modelId: "z-ai/glm-4.7", label: "GLM", toolCapable: true },
});
await app.inject({
method: "POST",
url: "/api/org/test-default/roles",
headers: { cookie, "content-type": "application/json" },
payload: { roleId: "draft", label: "草稿", defaultModelId: "z-ai/glm-4.7" },
});
await prisma.project.create({
data: { id: "p-cfg", organizationId: DEFAULT_ORG_ID, name: "CfgProj", workspaceDir: "/tmp/p-cfg" },
});
const settings = createDbRuntimeSettings(prisma, {
ANTHROPIC_AUTH_TOKEN: "env-token",
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5",
});
const registry = await settings.modelRegistry({ projectId: "p-cfg" });
const models = registry.listModels();
expect(models.map((m) => m.id)).toContain("z-ai/glm-4.7");
const draft = registry.role("draft");
expect(draft?.label).toBe("草稿");
expect(registry.resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
// Unknown project ⇒ env fallback registry.
const fallback = await settings.modelRegistry({ projectId: "no-such-project" });
expect(fallback.listModels().map((m) => m.id)).toContain("anthropic/claude-sonnet-5");
// BYOK provider connection resolves org credentials.
await app.inject({
method: "PUT",
url: "/api/org/test-default/provider-connection",
headers: { cookie, "content-type": "application/json" },
payload: { mode: "BYOK", baseUrl: "https://gw.test/api", authToken: "byok-token" },
});
const provider = await settings.provider("openrouter", { projectId: "p-cfg" });
expect(provider.baseUrl).toBe("https://gw.test/api");
expect(provider.authToken).toBe("byok-token");
} finally {
await app.close();
}
});
});