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:
@@ -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>
|
||||
};
|
||||
Reference in New Issue
Block a user