Skip to content

Commit bf8efbc

Browse files
Merge pull request #1126 from ChronoAIProject/feature/1125-permissions-read-write-tabs
feat(web): two-tab Read/Write permissions editor (#1125)
2 parents 2e558d8 + 858dc22 commit bf8efbc

9 files changed

Lines changed: 624 additions & 1109 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"ornn-api": minor
3+
"ornn-web": minor
4+
---
5+
6+
Manage Permissions: separate Read and Write access into two tabs.
7+
8+
The skill and skillset permissions editor is now a two-tab editor — a **Read** tab (Public toggle, or a restricted set of orgs/users who can read) and a **Write** tab (orgs/users who can edit). This makes every combination the backend already supports expressible, including **public read + organization/user write**, which the previous single-visibility-ladder couldn't reach. Skillsets also gain the read / read-write level support they previously lacked. No API changes — purely a UI redesign over the existing typed `grants` model.
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/**
2+
* PermissionsEditor — two-tab Read / Write access editor (#1125).
3+
*
4+
* Separates the two audiences that the typed `grants` ACL supports
5+
* independently:
6+
* - Read tab — a Public toggle (everyone reads) OR a restricted set of
7+
* orgs/users who can read.
8+
* - Write tab — orgs/users who can edit (read + write). No public option;
9+
* editors implicitly get read.
10+
*
11+
* Composing both tabs lets an owner express any combination the backend
12+
* already supports — notably "public read + org/user write", which the old
13+
* single-ladder modal couldn't reach (turning on Public disabled the grant
14+
* pickers). Shared by the skill + skillset permission modals via the thin
15+
* wrappers in `components/skill` / `components/skillset`.
16+
*
17+
* @module components/permissions/PermissionsEditor
18+
*/
19+
20+
import { useEffect, useMemo, useState } from "react";
21+
import { useTranslation } from "react-i18next";
22+
import { useQuery } from "@tanstack/react-query";
23+
import { Button } from "@/components/ui/Button";
24+
import { useMyOrgs } from "@/hooks/useMe";
25+
import { useToastStore } from "@/stores/toastStore";
26+
import { resolveUsers, fetchOrgSummary } from "@/services/usersApi";
27+
import type { SkillGrant } from "@/types/domain";
28+
import { translateError } from "@/utils/translateError";
29+
import { PrincipalSelector, type OrgOption, type Principal } from "./PrincipalSelector";
30+
31+
interface PermissionsEditorProps {
32+
initialIsPrivate: boolean;
33+
initialGrants: SkillGrant[];
34+
/** Labels (e.g. "skill" / "skillset") for the copy. */
35+
entityKind: "skill" | "skillset";
36+
saving: boolean;
37+
/** Persist. Should reject on error so the editor can surface a toast. */
38+
onSave: (isPrivate: boolean, grants: SkillGrant[]) => Promise<void>;
39+
onCancel: () => void;
40+
}
41+
42+
const key = (p: { type: string; id: string }) => `${p.type}:${p.id}`;
43+
44+
/**
45+
* Compose the canonical grants from the two audiences. When public, read
46+
* grants are redundant (everyone reads) so only write grants are emitted.
47+
* A principal in both audiences resolves to read_write (write wins).
48+
*/
49+
function buildGrants(read: Principal[], write: Principal[], isPublic: boolean): SkillGrant[] {
50+
const map = new Map<string, SkillGrant>();
51+
if (!isPublic) {
52+
for (const p of read) map.set(key(p), { type: p.type, id: p.id, level: "read" });
53+
}
54+
for (const p of write) map.set(key(p), { type: p.type, id: p.id, level: "read_write" });
55+
return [...map.values()];
56+
}
57+
58+
const signature = (grants: SkillGrant[]): string =>
59+
grants.map((g) => `${g.type}:${g.id}:${g.level}`).sort().join("|");
60+
61+
function toPrincipals(grants: SkillGrant[], level: SkillGrant["level"]): Principal[] {
62+
return grants.filter((g) => g.level === level).map((g) => ({ type: g.type, id: g.id, label: g.id }));
63+
}
64+
65+
export function PermissionsEditor({
66+
initialIsPrivate,
67+
initialGrants,
68+
entityKind,
69+
saving,
70+
onSave,
71+
onCancel,
72+
}: PermissionsEditorProps) {
73+
const { t } = useTranslation();
74+
const addToast = useToastStore((s) => s.addToast);
75+
const { data: myOrgs = [] } = useMyOrgs();
76+
77+
const [tab, setTab] = useState<"read" | "write">("read");
78+
const [isPublic, setIsPublic] = useState<boolean>(!initialIsPrivate);
79+
const [readGrantees, setReadGrantees] = useState<Principal[]>(() => toPrincipals(initialGrants, "read"));
80+
const [writeGrantees, setWriteGrantees] = useState<Principal[]>(() =>
81+
toPrincipals(initialGrants, "read_write"),
82+
);
83+
84+
// Resolve user-grant labels once on mount (the typeahead supplies labels
85+
// for freshly-added users; persisted grants arrive as bare ids).
86+
useEffect(() => {
87+
const userIds = initialGrants.filter((g) => g.type === "user").map((g) => g.id);
88+
if (userIds.length === 0) return;
89+
let cancelled = false;
90+
(async () => {
91+
const resolved = await resolveUsers(userIds).catch(() => []);
92+
if (cancelled || resolved.length === 0) return;
93+
const byId = new Map(resolved.map((r) => [r.userId, r]));
94+
const relabel = (ps: Principal[]) =>
95+
ps.map((p) =>
96+
p.type === "user" && byId.has(p.id)
97+
? { ...p, label: byId.get(p.id)!.email || byId.get(p.id)!.displayName || p.id }
98+
: p,
99+
);
100+
setReadGrantees(relabel);
101+
setWriteGrantees(relabel);
102+
})();
103+
return () => {
104+
cancelled = true;
105+
};
106+
// initialGrants is stable per mount (the modal keys the editor on the ACL
107+
// signature, so a reopen / ACL change remounts with fresh initial state).
108+
// eslint-disable-next-line react-hooks/exhaustive-deps
109+
}, []);
110+
111+
// Org options = caller's memberships + any granted org not in them (backfilled).
112+
const grantedOrgIds = useMemo(
113+
() => [...new Set([...readGrantees, ...writeGrantees].filter((p) => p.type === "org").map((p) => p.id))],
114+
[readGrantees, writeGrantees],
115+
);
116+
const unknownOrgIds = useMemo(
117+
() => grantedOrgIds.filter((id) => !myOrgs.some((o) => o.userId === id)),
118+
[grantedOrgIds, myOrgs],
119+
);
120+
const { data: fetchedUnknownOrgs = [] } = useQuery({
121+
queryKey: ["orgs-backfill", unknownOrgIds.slice().sort().join(",")],
122+
queryFn: async () => {
123+
const resolved = await Promise.all(unknownOrgIds.map((id) => fetchOrgSummary(id)));
124+
return resolved.map((entry, i) => {
125+
const orgId = unknownOrgIds[i]!;
126+
return entry
127+
? { ...entry, isUnresolved: false }
128+
: { userId: orgId, displayName: orgId, avatarUrl: null, isUnresolved: true };
129+
});
130+
},
131+
enabled: unknownOrgIds.length > 0,
132+
staleTime: 5 * 60_000,
133+
});
134+
135+
const orgOptions: OrgOption[] = useMemo(() => {
136+
const map = new Map<string, OrgOption>();
137+
for (const o of myOrgs) {
138+
map.set(o.userId, { id: o.userId, label: o.displayName, isMember: true, isUnresolved: false });
139+
}
140+
for (const o of fetchedUnknownOrgs) {
141+
if (!map.has(o.userId)) {
142+
map.set(o.userId, { id: o.userId, label: o.displayName, isMember: false, isUnresolved: o.isUnresolved });
143+
}
144+
}
145+
return [...map.values()];
146+
}, [myOrgs, fetchedUnknownOrgs]);
147+
148+
const handleSave = async () => {
149+
const isPrivate = !isPublic;
150+
const grants = buildGrants(readGrantees, writeGrantees, isPublic);
151+
const initialBuilt = buildGrants(
152+
toPrincipals(initialGrants, "read"),
153+
toPrincipals(initialGrants, "read_write"),
154+
!initialIsPrivate,
155+
);
156+
if (isPrivate === initialIsPrivate && signature(grants) === signature(initialBuilt)) {
157+
addToast({ type: "info", message: t("permissions.noChanges", "No changes to save.") });
158+
onCancel();
159+
return;
160+
}
161+
try {
162+
await onSave(isPrivate, grants);
163+
addToast({ type: "success", message: t("permissions.saveSuccess", "Permissions updated") });
164+
onCancel();
165+
} catch (err) {
166+
addToast({ type: "error", message: translateError(err) });
167+
}
168+
};
169+
170+
const writeCount = writeGrantees.length;
171+
172+
return (
173+
<>
174+
{/* Tab bar */}
175+
<div className="mb-4 flex gap-1 rounded-md border border-subtle bg-elevated/40 p-1">
176+
<TabButton active={tab === "read"} onClick={() => setTab("read")}>
177+
{t("permissions.tabRead", "Read access")}
178+
</TabButton>
179+
<TabButton active={tab === "write"} onClick={() => setTab("write")}>
180+
{t("permissions.tabWrite", "Write access")}
181+
{writeCount > 0 && (
182+
<span className="ml-1.5 rounded-full bg-accent/15 px-1.5 font-mono text-[10px] text-accent">
183+
{writeCount}
184+
</span>
185+
)}
186+
</TabButton>
187+
</div>
188+
189+
{tab === "read" ? (
190+
<div>
191+
<label className="flex cursor-pointer items-start gap-3 rounded border border-subtle bg-elevated/40 p-4">
192+
<input
193+
type="checkbox"
194+
checked={isPublic}
195+
onChange={(e) => setIsPublic(e.target.checked)}
196+
className="mt-1 h-4 w-4 shrink-0 rounded border-accent/40 accent-accent"
197+
/>
198+
<div className="flex-1">
199+
<p className="font-display text-base text-strong">{t("permissions.publicTitle", "Public")}</p>
200+
<p className="mt-0.5 font-text text-sm text-meta">
201+
{entityKind === "skill"
202+
? t("permissions.publicDesc", "Anyone on Ornn can find and read this skill, including unauthenticated visitors.")
203+
: t("skillsetPermissions.publicDesc", "Anyone on Ornn can find and read this skillset, including unauthenticated visitors.")}
204+
</p>
205+
</div>
206+
</label>
207+
208+
<div className="mt-4">
209+
<p className="mb-2 font-text text-sm text-meta">
210+
{isPublic
211+
? t("permissions.readPublicNote", "This is public — everyone can read it. Use the Write tab to grant edit access to specific orgs or users.")
212+
: t("permissions.readRestrictedNote", "Private: only you, platform admins, and the orgs/users below can read it. (Editors from the Write tab can read too.)")}
213+
</p>
214+
<PrincipalSelector
215+
value={readGrantees}
216+
onChange={setReadGrantees}
217+
orgOptions={orgOptions}
218+
disabled={isPublic}
219+
idPrefix="read"
220+
/>
221+
</div>
222+
</div>
223+
) : (
224+
<div>
225+
<p className="mb-2 font-text text-sm text-meta">
226+
{t("permissions.writeNote", "These orgs and users can update the content & metadata. They can read it too. They cannot manage permissions, transfer, or delete — those stay with the owner.")}
227+
</p>
228+
<PrincipalSelector
229+
value={writeGrantees}
230+
onChange={setWriteGrantees}
231+
orgOptions={orgOptions}
232+
idPrefix="write"
233+
/>
234+
</div>
235+
)}
236+
237+
<div className="mt-5 flex justify-end gap-2 border-t border-accent/10 pt-4">
238+
<Button variant="secondary" onClick={onCancel}>
239+
{t("common.cancel", "Cancel")}
240+
</Button>
241+
<Button onClick={handleSave} loading={saving}>
242+
{t("common.save", "Save")}
243+
</Button>
244+
</div>
245+
</>
246+
);
247+
}
248+
249+
function TabButton({
250+
active,
251+
onClick,
252+
children,
253+
}: {
254+
active: boolean;
255+
onClick: () => void;
256+
children: React.ReactNode;
257+
}) {
258+
return (
259+
<button
260+
type="button"
261+
onClick={onClick}
262+
className={`flex-1 rounded px-3 py-1.5 font-mono text-xs uppercase tracking-wider transition-colors ${
263+
active ? "bg-card text-strong card-impression" : "text-meta hover:text-strong"
264+
}`}
265+
>
266+
{children}
267+
</button>
268+
);
269+
}

0 commit comments

Comments
 (0)