forked from EduCraft/curriculum-project-hub
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:
@@ -4,3 +4,6 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
admin-web/node_modules/
|
||||||
|
admin-web/build/
|
||||||
|
admin-web/.svelte-kit/
|
||||||
|
|||||||
@@ -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-*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Generated
+2594
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+13
@@ -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 {};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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 面向 provider;label 面向教师。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#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>BYOK:agent 使用本连接 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-plane(OPEN)。
|
||||||
|
</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}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+18
-151
@@ -131,9 +131,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -147,9 +144,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -163,9 +157,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -179,9 +170,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -241,34 +229,10 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
},
|
},
|
||||||
@@ -1199,9 +1162,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1219,9 +1179,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1239,9 +1196,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1259,9 +1213,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1279,9 +1230,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1299,9 +1247,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1392,8 +1337,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -1585,7 +1529,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-types": "^3.0.0",
|
"mime-types": "^3.0.0",
|
||||||
"negotiator": "^1.0.0"
|
"negotiator": "^1.0.0"
|
||||||
@@ -1599,7 +1542,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -1609,7 +1551,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
@@ -1745,7 +1686,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^2.0.0",
|
||||||
@@ -1770,7 +1710,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -1784,7 +1723,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -1930,7 +1868,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -1944,7 +1881,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -1974,7 +1910,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
@@ -1984,7 +1919,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"object-assign": "^4",
|
"object-assign": "^4",
|
||||||
"vary": "^1"
|
"vary": "^1"
|
||||||
@@ -2002,7 +1936,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
"shebang-command": "^2.0.0",
|
"shebang-command": "^2.0.0",
|
||||||
@@ -2060,7 +1993,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -2121,8 +2053,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.21.0",
|
"version": "3.21.0",
|
||||||
@@ -2150,7 +2081,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -2253,8 +2183,7 @@
|
|||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
@@ -2271,7 +2200,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -2281,7 +2209,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eventsource-parser": "^3.0.1"
|
"eventsource-parser": "^3.0.1"
|
||||||
},
|
},
|
||||||
@@ -2313,7 +2240,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -2357,7 +2283,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.2.0"
|
"ip-address": "^10.2.0"
|
||||||
},
|
},
|
||||||
@@ -2376,7 +2301,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -2386,7 +2310,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -2396,7 +2319,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
@@ -2487,8 +2409,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
@@ -2587,7 +2508,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
@@ -2659,7 +2579,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -2669,7 +2588,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -2819,7 +2737,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"depd": "~2.0.0",
|
"depd": "~2.0.0",
|
||||||
"inherits": "~2.0.4",
|
"inherits": "~2.0.4",
|
||||||
@@ -2853,7 +2770,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
@@ -2869,15 +2785,13 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
@@ -2895,15 +2809,13 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
@@ -2920,7 +2832,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"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",
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"ts-algebra": "^2.0.0"
|
"ts-algebra": "^2.0.0"
|
||||||
@@ -2974,8 +2884,7 @@
|
|||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/light-my-request": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
@@ -3157,9 +3066,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3181,9 +3087,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3205,9 +3108,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3229,9 +3129,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3335,7 +3232,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -3345,7 +3241,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -3404,7 +3299,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -3446,7 +3340,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3498,7 +3391,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ee-first": "1.1.1"
|
"ee-first": "1.1.1"
|
||||||
},
|
},
|
||||||
@@ -3511,7 +3403,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
@@ -3521,7 +3412,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -3531,7 +3421,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -3541,7 +3430,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
@@ -3574,6 +3462,7 @@
|
|||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3623,7 +3512,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
@@ -3676,6 +3564,7 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/config": "6.19.3",
|
"@prisma/config": "6.19.3",
|
||||||
"@prisma/engines": "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",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"forwarded": "0.2.0",
|
"forwarded": "0.2.0",
|
||||||
"ipaddr.js": "1.9.1"
|
"ipaddr.js": "1.9.1"
|
||||||
@@ -3753,7 +3641,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
@@ -3811,7 +3698,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
},
|
},
|
||||||
@@ -3825,7 +3711,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
@@ -3943,7 +3828,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"depd": "^2.0.0",
|
"depd": "^2.0.0",
|
||||||
@@ -3990,8 +3874,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/secure-json-parse": {
|
"node_modules/secure-json-parse": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
@@ -4026,7 +3909,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
@@ -4053,7 +3935,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -4063,7 +3944,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
@@ -4080,7 +3960,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
@@ -4105,15 +3984,13 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
},
|
},
|
||||||
@@ -4126,7 +4003,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -4250,7 +4126,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stablelib/base64": "^1.0.0",
|
"@stablelib/base64": "^1.0.0",
|
||||||
"fast-sha256": "^1.3.0"
|
"fast-sha256": "^1.3.0"
|
||||||
@@ -4261,7 +4136,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -4349,7 +4223,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
@@ -4358,8 +4231,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
@@ -4375,6 +4247,7 @@
|
|||||||
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
|
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.28.0"
|
"esbuild": "~0.28.0"
|
||||||
},
|
},
|
||||||
@@ -4393,7 +4266,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^2.0.0",
|
||||||
"media-typer": "^1.1.0",
|
"media-typer": "^1.1.0",
|
||||||
@@ -4412,7 +4284,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -4426,7 +4297,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -4436,7 +4306,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
@@ -4454,6 +4323,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4473,7 +4343,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -4483,7 +4352,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -4494,6 +4362,7 @@
|
|||||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@@ -4661,7 +4530,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -4693,8 +4561,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.0",
|
"version": "8.21.0",
|
||||||
@@ -4722,6 +4589,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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",
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.28 || ^4"
|
"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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { registerAccessRoutes } from "./accessRoutes.js";
|
import { registerAccessRoutes } from "./accessRoutes.js";
|
||||||
import { registerExplorerRoutes } from "./explorerRoutes.js";
|
import { registerExplorerRoutes } from "./explorerRoutes.js";
|
||||||
import { registerMembersRoutes } from "./membersRoutes.js";
|
import { registerMembersRoutes } from "./membersRoutes.js";
|
||||||
|
import { registerModelRoutes } from "./modelRoutes.js";
|
||||||
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
||||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||||
@@ -105,6 +106,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
|||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
});
|
});
|
||||||
|
await registerModelRoutes(app, {
|
||||||
|
prisma: config.prisma,
|
||||||
|
sessionSecret: config.sessionSecret,
|
||||||
|
});
|
||||||
await registerSessionsAndUsageRoutes(app, {
|
await registerSessionsAndUsageRoutes(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user