feat(admin): gate project surfaces behind permission grants for members

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.
This commit is contained in:
2026-07-14 21:25:18 +08:00
parent ab9dfad53a
commit 080efa70c5
9 changed files with 445 additions and 135 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder } from '$lib/api';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import FolderTree from '$lib/components/FolderTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -9,11 +10,17 @@
import { Label } from 'bits-ui';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(
($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null,
);
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null);
let myProjects = $state<ExplorerProject[] | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
@@ -29,7 +36,13 @@
loading = true;
error = null;
try {
data = await api.explorer(slug);
if (isAdmin) {
data = await api.explorer(slug);
myProjects = null;
} else {
myProjects = (await api.myProjects(slug)).projects;
data = null;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
@@ -89,22 +102,22 @@
}
$effect(() => {
if (slug) load();
if (slug && org) 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}
{:else if isAdmin && data}
<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>
<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="新建文件夹或项目,开始组织你的教研资产。" />
@@ -112,48 +125,81 @@
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
{/if}
</div>
<Modal bind:open={showFolderModal} title="新建文件夹">
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
<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}
<p class="saas-label">父文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={folderParent} />
</div>
{/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.Root class="saas-label" for="project-name">项目名</Label.Root>
<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}
<p class="saas-label">文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={projectFolder} />
</div>
{/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>
{:else if myProjects !== null}
<PageHeader title="我的项目" description="你拥有访问授权的项目。">
{#snippet actions()}
<span class="saas-badge-neutral">仅显示已授权项目</span>
{/snippet}
</PageHeader>
<div class="saas-card overflow-hidden">
{#if myProjects.length === 0}
<EmptyState title="暂无可访问项目" description="当团队被授予项目访问权限时,项目会出现在这里。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>飞书群</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{#each myProjects as p}
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}>
<td class="font-medium">{p.name}</td>
<td class="font-mono text-xs">{p.binding ? ` ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{:else}
<EmptyState title="项目数据不可用" description="无法加载项目列表。" />
{/if}
<Modal bind:open={showFolderModal} title="新建文件夹">
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
<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}
<p class="saas-label">父文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={folderParent} />
</div>
{/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.Root class="saas-label" for="project-name">项目名</Label.Root>
<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}
<p class="saas-label">文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={projectFolder} />
</div>
{/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>
@@ -34,23 +34,34 @@
let grantRole = $state<string>('EDIT');
let moveFolder = $state('');
const actorIsOrgAdmin = $derived(proj?.actorIsOrgAdmin ?? false);
const actorCanManage = $derived(proj?.actorCanManageProject ?? false);
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),
]);
// Project detail + team-access list are gated to project read/oversight.
const [p, a] = await Promise.all([api.project(slug, projectId), api.teamAccess(slug, projectId)]);
proj = p;
access = a.access;
sessions = s.sessions;
teams = t.teams;
explorer = e;
moveFolder = p.folderId ?? '';
// Org-admin-only oversight sub-resources: sessions/teams/explorer are
// not available to member-level project MANAGE holders.
if (p.actorIsOrgAdmin) {
const [s, t, e] = await Promise.all([
api.sessions(slug, projectId),
api.teams(slug),
api.explorer(slug),
]);
sessions = s.sessions;
teams = t.teams;
explorer = e;
moveFolder = p.folderId ?? '';
} else {
sessions = [];
teams = [];
explorer = null;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
@@ -157,11 +168,13 @@
{@const detail = proj}
<PageHeader title={detail.name} description={`项目是权限边界;通过团队授予 ${roleChain}。`}>
{#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 actorIsOrgAdmin}
<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>
{/if}
<button class="saas-btn-danger py-1.5! text-sm" onclick={archiveProject}>归档</button>
{/snippet}
</PageHeader>
@@ -214,11 +227,15 @@
<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_10rem_auto]">
<SelectField items={teamItems()} bind:value={grantTeam} />
<SelectField items={roleItems} bind:value={grantRole} />
<button class="saas-btn-primary" onclick={grant}>授权</button>
</div>
{#if actorCanManage}
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_10rem_auto]">
<SelectField items={teamItems()} bind:value={grantTeam} />
<SelectField items={roleItems} bind:value={grantRole} />
<button class="saas-btn-primary" onclick={grant}>授权</button>
</div>
{:else}
<p class="mb-4 text-xs text-surface-600">需要项目 MANAGE 授权才能增删团队访问。</p>
{/if}
{#if access.length === 0}
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
@@ -239,7 +256,11 @@
<td class="font-mono text-xs">/{g.teamSlug}</td>
<td><span class="saas-badge-primary">{permissionRoleLabel(g.role)}</span></td>
<td class="text-right">
<button class="saas-btn-danger py-1! text-xs" onclick={() => revoke(g)}>撤销</button>
{#if actorCanManage}
<button class="saas-btn-danger py-1! text-xs" onclick={() => revoke(g)}>撤销</button>
{:else}
<span class="text-xs text-surface-500"></span>
{/if}
</td>
</tr>
{/each}
@@ -248,33 +269,39 @@
{/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">智能体会话</h3>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>供应方 / 角色</th>
<th>模型</th>
<th>运行次数</th>
<th>更新</th>
</tr>
</thead>
<tbody>
{#each sessions as s}
{#if actorIsOrgAdmin}
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">智能体会话</h3>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
{:else}
<table class="data-table">
<thead>
<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-700">{fmtDate(s.updatedAt)}</td>
<th>供应方 / 角色</th>
<th>模型</th>
<th>运行次数</th>
<th>更新</th>
</tr>
{/each}
</tbody>
</table>
{/if}
</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-700">{fmtDate(s.updatedAt)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
{:else}
<div class="saas-empty">
<p class="text-sm text-surface-600">项目数据不可用</p>
</div>
{/if}