forked from EduCraft/curriculum-project-hub
080efa70c5
The org admin SPA was org-admin only: every project route used requireOrgRole, so a plain MEMBER could not reach the projects they held a project grant on, and an org OWNER/ADMIN could mutate any project without holding the project's `manage` grant. That contradicts ADR-0004 (spec `Permission.lean`): org role is not a project authorization root, and the only out-of-role override is platform-admin force-release (`RequiresAdmin`), not org admin. Add `requireProjectPermission` (guards.ts): resolve any org member, bind the project to their org, then check the PermissionGrant authorizer. `allowOrgAdminOversight=true` lets OWNER/ADMIN through for *read* oversight only; mutations pinned to `collaborator.manage` (grant/revoke team-access) pass `allowOrgAdminOversight=false`, so an org admin still needs the project MANAGE grant to mutate access. The project detail GET now also returns `actorIsOrgAdmin` and `actorCanManageProject` so the SPA can render mutation controls only for entitled actors. Add a member-facing project surface: - `GET /api/org/:orgSlug/my-projects` + `listMyProjects` resolve the actor's principals and return the projects with a READ+ grant. - The SPA routes members (non-admin) to the projects page instead of the admin overview, renders a member project shell on project routes, shows a `我的项目` list for members and the full folder explorer for admins. - The project detail page gates rename/archive/bind/sessions behind org admin and the grant/revoke UI behind `actorCanManageProject`. - The denied panel now points members at their authorized projects. Update admin-members-teams integration test: seed the owner with a MANAGE grant on the test project so the org-owner flow still passes the new project-level gate on team-access grant/revoke.
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
/**
|
|
* 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;
|
|
actorIsOrgAdmin?: boolean;
|
|
actorCanManageProject?: boolean;
|
|
}
|
|
|
|
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 ProviderConnectionRow {
|
|
id: string;
|
|
providerId: string;
|
|
mode: 'BYOK' | 'PLATFORM_MANAGED';
|
|
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
|
|
activeVersion: number | null;
|
|
keyId: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface FeishuApplicationConnection {
|
|
id: string;
|
|
appFingerprint: string;
|
|
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
|
|
activeVersion: number | null;
|
|
keyId: string | null;
|
|
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;
|
|
}
|
|
|
|
export type CapacityDimension =
|
|
| 'requestRate'
|
|
| 'requestBodySize'
|
|
| 'agentConcurrency'
|
|
| 'admissionQueueLength'
|
|
| 'admissionQueueWait'
|
|
| 'fileSize'
|
|
| 'attachmentCount'
|
|
| 'archiveExpansion'
|
|
| 'projectStorage'
|
|
| 'organizationStorage'
|
|
| 'memberCount'
|
|
| 'projectCount'
|
|
| 'teamCount'
|
|
| 'folderCount'
|
|
| 'sessionCount'
|
|
| 'runWallTime'
|
|
| 'runTurns'
|
|
| 'runToolCalls'
|
|
| 'toolWallTime'
|
|
| 'runOutputSize'
|
|
| 'processMemory'
|
|
| 'processCpu'
|
|
| 'processCount';
|
|
|
|
export interface CapacityDimensionRow {
|
|
dimension: CapacityDimension;
|
|
platformCeiling: number | null;
|
|
organizationLimit: number | null;
|
|
effective: number | null;
|
|
}
|
|
|
|
export interface CapacityPolicyView {
|
|
dimensions: CapacityDimensionRow[];
|
|
}
|
|
|
|
// --- 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>,
|
|
myProjects: (slug: string) =>
|
|
get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
|
|
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>;
|
|
},
|
|
|
|
providerConnections: (slug: string) =>
|
|
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
|
|
rotateProviderConnection: (
|
|
slug: string,
|
|
providerId: string,
|
|
body: { baseUrl: string; authToken: string; anthropicApiKey?: string },
|
|
) =>
|
|
put(
|
|
`${orgBase(slug)}/provider-connections/${encodeURIComponent(providerId)}`,
|
|
body,
|
|
) as Promise<ProviderConnectionRow>,
|
|
|
|
feishuApplication: (slug: string) =>
|
|
get(`${orgBase(slug)}/feishu-application-connection`) as Promise<{
|
|
connection: FeishuApplicationConnection | null;
|
|
}>,
|
|
rotateFeishuApplication: (
|
|
slug: string,
|
|
body: {
|
|
appId: string;
|
|
appSecret: string;
|
|
botOpenId: string;
|
|
verificationToken?: string;
|
|
encryptKey?: string;
|
|
},
|
|
) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise<FeishuApplicationConnection>,
|
|
disableFeishuApplication: (slug: string) =>
|
|
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
|
|
|
|
capacityPolicy: (slug: string) =>
|
|
get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
|
|
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
|
|
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
|
|
};
|