forked from bai/curriculum-project-hub
cdeb29ccf2
迁移时整个授权 tab 连同四个端点一起漏掉了 —— 后端一直可用,前端零调用: GET/PUT/DELETE /nodes/:id/grants PUT /projects/:id/independent-permission 权限编辑是这个后台的核心用途,而它此前在界面上完全不可达。 tab 组装也修正为与旧 libraryBrowser 一致:概览恒有、文件仅 PROJECT、 授权仅 MANAGE。注意文件夹也有授权 tab —— 它虽是透明组织节点,授权仍 挂在节点上(ADR-0021);此前文件夹一个 tab 都没有。 GrantsPanel 的语义按契约 8.1:创建者授权不给收回入口;MANAGE 仅创建者 可授,前端不拦,后端 fail closed 的报错原样呈现;GROUP 主体走 /groups/search 下拉选,不手敲 id。 /database/api/me 加 displayName 与 avatarUrl:侧栏此前显示原始 userId。 旧页面是服务端渲染,handler 里查 Prisma 就有名字;页面不再服务端渲染后 (ADR-0029),模板闭包过的数据也是被迁移的契约的一部分,不是旧实现的 无关细节。 概览面板同时补回丢失的「类型」「更新时间」两行、导出 target 下拉、 节点标题旁的角色 tag,以及整块缺失的独立权限开关。
192 lines
6.6 KiB
Svelte
192 lines
6.6 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* 节点授权面板。迁自已删除的 routes/libraryBrowser.ts `renderGrantsTab`(ADR-0029)。
|
|
*
|
|
* 迁移时整个「授权」tab 连同这四个端点一起漏掉了 —— 后端一直可用,只是前端没入口。
|
|
*
|
|
* 语义(契约 8.1 / ADR-0021):
|
|
* - 创建者授权(isCreatorGrant)不可收回、不可改;
|
|
* - MANAGE 仅创建者可授,这里不做前端拦截 —— 后端 fail closed,报错原样呈现;
|
|
* - GROUP 主体走 in-hub MemberGroup(ADR-0028),用 /groups/search 选,不手敲 id。
|
|
*/
|
|
import { api } from "./api.js";
|
|
import { toastOk, toastErr } from "./stores.js";
|
|
import { currentNode } from "./browser.js";
|
|
import type { Grant, MemberGroupSearchResult, NodeDetail, Role } from "./types.js";
|
|
import Icon from "./Icon.svelte";
|
|
|
|
let { node }: { node: NodeDetail } = $props();
|
|
|
|
const ROLES: readonly Role[] = ["VIEW", "EDIT", "MANAGE"];
|
|
|
|
let grants = $state<Grant[] | null>(null);
|
|
let error = $state<string | null>(null);
|
|
|
|
let principalType = $state<"USER" | "GROUP">("USER");
|
|
let userIdInput = $state("");
|
|
let groupId = $state("");
|
|
let groupOptions = $state<MemberGroupSearchResult[] | null>(null);
|
|
let role = $state<Role>("VIEW");
|
|
let saving = $state(false);
|
|
|
|
const canManage = $derived(node.role === "MANAGE");
|
|
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
|
|
$effect(() => {
|
|
void node.id;
|
|
void load();
|
|
});
|
|
|
|
async function load(): Promise<void> {
|
|
grants = null;
|
|
error = null;
|
|
try {
|
|
const r = await api<{ grants: Grant[] }>(`/database/api/nodes/${node.id}/grants`);
|
|
grants = r.grants;
|
|
} catch (e) {
|
|
error = errText(e);
|
|
}
|
|
}
|
|
|
|
/** 切到 GROUP 时懒加载候选组(活跃组 + breadcrumb)。 */
|
|
async function onTypeChange(): Promise<void> {
|
|
if (principalType !== "GROUP" || groupOptions !== null) return;
|
|
try {
|
|
const r = await api<{ groups: MemberGroupSearchResult[] }>("/database/api/groups/search?q=");
|
|
groupOptions = r.groups;
|
|
if (r.groups.length > 0 && groupId === "") groupId = r.groups[0]!.id;
|
|
} catch (e) {
|
|
toastErr(errText(e));
|
|
}
|
|
}
|
|
|
|
async function addGrant(): Promise<void> {
|
|
const principalId = principalType === "GROUP" ? groupId : userIdInput.trim();
|
|
if (principalId === "") {
|
|
toastErr("请填写主体");
|
|
return;
|
|
}
|
|
saving = true;
|
|
try {
|
|
// PUT /grants 是增量语义(putGrants),不是整表替换。
|
|
await api(`/database/api/nodes/${node.id}/grants`, {
|
|
method: "PUT",
|
|
body: { grants: [{ principalType, principalId, role }] },
|
|
});
|
|
toastOk("已授予");
|
|
userIdInput = "";
|
|
await load();
|
|
} catch (e) {
|
|
toastErr(errText(e));
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
async function revoke(g: Grant): Promise<void> {
|
|
if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return;
|
|
try {
|
|
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
|
|
method: "DELETE",
|
|
});
|
|
toastOk("已收回");
|
|
await load();
|
|
} catch (e) {
|
|
toastErr(errText(e));
|
|
}
|
|
}
|
|
|
|
/** 独立权限开关(仅 PROJECT;关闭时只继承父级,创建者除外)。 */
|
|
async function toggleIndependent(): Promise<void> {
|
|
try {
|
|
await api(`/database/api/projects/${node.id}/independent-permission`, {
|
|
method: "PUT",
|
|
body: { enabled: !node.independentPermission },
|
|
});
|
|
toastOk("已切换");
|
|
currentNode.update((n) =>
|
|
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
|
|
);
|
|
await load();
|
|
} catch (e) {
|
|
toastErr(errText(e));
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="panel">
|
|
{#if error !== null}
|
|
<div class="py-2 text-[12.5px] text-danger">{error}</div>
|
|
{:else if grants === null}
|
|
<div class="quiet py-[18px] text-center">加载中…</div>
|
|
{:else}
|
|
<table class="list">
|
|
<thead>
|
|
<tr><th>主体</th><th>级别</th><th></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{#if grants.length === 0}
|
|
<tr><td colspan="3" class="quiet !py-[18px] text-center">暂无显式授权</td></tr>
|
|
{:else}
|
|
{#each grants as g (g.id)}
|
|
<tr>
|
|
<td>
|
|
<span class="inline-flex items-center gap-2">
|
|
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
|
|
<span class="font-mono text-[12px]">{g.principalId}</span>
|
|
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
|
|
</span>
|
|
</td>
|
|
<td class="file-meta">{g.role}</td>
|
|
<td class="text-right">
|
|
<!-- 创建者授权不可动(契约 8.1);非 MANAGE 也不给收回入口。 -->
|
|
{#if !g.isCreatorGrant && canManage}
|
|
<button class="link-danger inline-flex items-center gap-1" onclick={() => void revoke(g)}>
|
|
<Icon name="minus" size={12} /> 收回
|
|
</button>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{/if}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
|
|
{#if canManage}
|
|
<div class="my-3.5 border-t border-line-soft"></div>
|
|
<div class="section-title mb-2.5">新增授权</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<select class="select !w-[110px]" bind:value={principalType} onchange={onTypeChange}>
|
|
<option value="USER">用户</option>
|
|
<option value="GROUP">Group</option>
|
|
</select>
|
|
|
|
{#if principalType === "USER"}
|
|
<input class="input min-w-0 flex-1" placeholder="用户 id" bind:value={userIdInput} />
|
|
{:else if groupOptions === null}
|
|
<span class="quiet flex-1">加载 Group 列表…</span>
|
|
{:else if groupOptions.length === 0}
|
|
<span class="quiet flex-1">暂无可选 Group · 先到「Group 管理」建一个</span>
|
|
{:else}
|
|
<select class="select min-w-0 flex-1" bind:value={groupId}>
|
|
{#each groupOptions as g (g.id)}
|
|
<option value={g.id}>{g.breadcrumb}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
|
|
<select class="select !w-[110px]" bind:value={role}>
|
|
{#each ROLES as r (r)}
|
|
<option value={r}>{r}</option>
|
|
{/each}
|
|
</select>
|
|
<button class="btn btn-primary disabled:opacity-50" onclick={addGrant} disabled={saving}>
|
|
{saving ? "授予中…" : "授予"}
|
|
</button>
|
|
</div>
|
|
<div class="section-note mt-1.5">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>
|
|
{/if}
|
|
|
|
</div>
|