From 9886cdd1949fd68775583c650fad2f1b5111776e Mon Sep 17 00:00:00 2001 From: Shining <250120269+chronoai-shining@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:01:19 +0800 Subject: [PATCH 1/5] feat(web): add typed grants to skillset frontend types (#1125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SkillsetDetail` gains the optional `grants` field and `SkillsetPermissionsInput` accepts `grants` (legacy `sharedWith*` arrays made optional) — parity with the skill types from #1123, which the web side hadn't mirrored for skillsets yet. Additive; no behaviour change. Part of #1125 --- ornn-web/src/types/skillset.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ornn-web/src/types/skillset.ts b/ornn-web/src/types/skillset.ts index c41483d4..da9b3d79 100644 --- a/ornn-web/src/types/skillset.ts +++ b/ornn-web/src/types/skillset.ts @@ -15,6 +15,8 @@ * @module types/skillset */ +import type { SkillGrant } from "@/types/domain"; + /** * Skillset kind (#969 v1). `generic` is the DEFAULT — a plain curated bundle. * `consensus-supported` is an author CLAIM that the members are an @@ -79,6 +81,8 @@ export interface SkillsetDetail { createdByDisplayName?: string | undefined; sharedWithUsers: string[]; sharedWithOrgs: string[]; + /** Canonical typed ACL (#1123). Present in detail responses via effectiveGrants. */ + grants?: SkillGrant[]; createdOn: string; updatedOn: string; } @@ -214,8 +218,10 @@ export interface PublishSkillsetInput { /** Body for PUT /api/v1/skillsets/:id/permissions. Mirrors skills. */ export interface SkillsetPermissionsInput { isPrivate: boolean; - sharedWithUsers: string[]; - sharedWithOrgs: string[]; + /** Canonical typed ACL (#1123/#1125); legacy lists optional for back-compat. */ + grants?: SkillGrant[]; + sharedWithUsers?: string[]; + sharedWithOrgs?: string[]; } /** From bb7cdd5b318cd578384527ade5e0721a4f52d363 Mon Sep 17 00:00:00 2001 From: Shining <250120269+chronoai-shining@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:01:19 +0800 Subject: [PATCH 2/5] feat(web): shared two-tab Read/Write permissions editor (#1125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the reusable building blocks that separate read and write access: - `PrincipalSelector` — org checkboxes (incl. unresolved-grant handling) + user email typeahead chips for one audience, emitting a flat Principal[]. Consolidates the org/user picking logic previously duplicated in the skill and skillset modals. - `PermissionsEditor` — a Read tab (Public toggle, or restricted orgs/users) and a Write tab (orgs/users who can edit; no public option). Save composes the canonical `grants` (read-tab → read, write-tab → read_write, write wins on overlap; read grants dropped when public since everyone reads) and carries `isPrivate` from the Read tab — so "public read + org/user write" is finally expressible. - `initialGrants` helper — seed the editor from a skill/skillset detail (canonical grants, or read-level derived from legacy lists) + a reset-key signature. Standalone (wired into the modals next). Part of #1125 --- .../permissions/PermissionsEditor.tsx | 269 ++++++++++++++++++ .../permissions/PrincipalSelector.tsx | 215 ++++++++++++++ .../components/permissions/initialGrants.ts | 32 +++ 3 files changed, 516 insertions(+) create mode 100644 ornn-web/src/components/permissions/PermissionsEditor.tsx create mode 100644 ornn-web/src/components/permissions/PrincipalSelector.tsx create mode 100644 ornn-web/src/components/permissions/initialGrants.ts diff --git a/ornn-web/src/components/permissions/PermissionsEditor.tsx b/ornn-web/src/components/permissions/PermissionsEditor.tsx new file mode 100644 index 00000000..eb847cb8 --- /dev/null +++ b/ornn-web/src/components/permissions/PermissionsEditor.tsx @@ -0,0 +1,269 @@ +/** + * PermissionsEditor — two-tab Read / Write access editor (#1125). + * + * Separates the two audiences that the typed `grants` ACL supports + * independently: + * - Read tab — a Public toggle (everyone reads) OR a restricted set of + * orgs/users who can read. + * - Write tab — orgs/users who can edit (read + write). No public option; + * editors implicitly get read. + * + * Composing both tabs lets an owner express any combination the backend + * already supports — notably "public read + org/user write", which the old + * single-ladder modal couldn't reach (turning on Public disabled the grant + * pickers). Shared by the skill + skillset permission modals via the thin + * wrappers in `components/skill` / `components/skillset`. + * + * @module components/permissions/PermissionsEditor + */ + +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/Button"; +import { useMyOrgs } from "@/hooks/useMe"; +import { useToastStore } from "@/stores/toastStore"; +import { resolveUsers, fetchOrgSummary } from "@/services/usersApi"; +import type { SkillGrant } from "@/types/domain"; +import { translateError } from "@/utils/translateError"; +import { PrincipalSelector, type OrgOption, type Principal } from "./PrincipalSelector"; + +interface PermissionsEditorProps { + initialIsPrivate: boolean; + initialGrants: SkillGrant[]; + /** Labels (e.g. "skill" / "skillset") for the copy. */ + entityKind: "skill" | "skillset"; + saving: boolean; + /** Persist. Should reject on error so the editor can surface a toast. */ + onSave: (isPrivate: boolean, grants: SkillGrant[]) => Promise; + onCancel: () => void; +} + +const key = (p: { type: string; id: string }) => `${p.type}:${p.id}`; + +/** + * Compose the canonical grants from the two audiences. When public, read + * grants are redundant (everyone reads) so only write grants are emitted. + * A principal in both audiences resolves to read_write (write wins). + */ +function buildGrants(read: Principal[], write: Principal[], isPublic: boolean): SkillGrant[] { + const map = new Map(); + if (!isPublic) { + for (const p of read) map.set(key(p), { type: p.type, id: p.id, level: "read" }); + } + for (const p of write) map.set(key(p), { type: p.type, id: p.id, level: "read_write" }); + return [...map.values()]; +} + +const signature = (grants: SkillGrant[]): string => + grants.map((g) => `${g.type}:${g.id}:${g.level}`).sort().join("|"); + +function toPrincipals(grants: SkillGrant[], level: SkillGrant["level"]): Principal[] { + return grants.filter((g) => g.level === level).map((g) => ({ type: g.type, id: g.id, label: g.id })); +} + +export function PermissionsEditor({ + initialIsPrivate, + initialGrants, + entityKind, + saving, + onSave, + onCancel, +}: PermissionsEditorProps) { + const { t } = useTranslation(); + const addToast = useToastStore((s) => s.addToast); + const { data: myOrgs = [] } = useMyOrgs(); + + const [tab, setTab] = useState<"read" | "write">("read"); + const [isPublic, setIsPublic] = useState(!initialIsPrivate); + const [readGrantees, setReadGrantees] = useState(() => toPrincipals(initialGrants, "read")); + const [writeGrantees, setWriteGrantees] = useState(() => + toPrincipals(initialGrants, "read_write"), + ); + + // Resolve user-grant labels once on mount (the typeahead supplies labels + // for freshly-added users; persisted grants arrive as bare ids). + useEffect(() => { + const userIds = initialGrants.filter((g) => g.type === "user").map((g) => g.id); + if (userIds.length === 0) return; + let cancelled = false; + (async () => { + const resolved = await resolveUsers(userIds).catch(() => []); + if (cancelled || resolved.length === 0) return; + const byId = new Map(resolved.map((r) => [r.userId, r])); + const relabel = (ps: Principal[]) => + ps.map((p) => + p.type === "user" && byId.has(p.id) + ? { ...p, label: byId.get(p.id)!.email || byId.get(p.id)!.displayName || p.id } + : p, + ); + setReadGrantees(relabel); + setWriteGrantees(relabel); + })(); + return () => { + cancelled = true; + }; + // initialGrants is stable per mount (the modal keys the editor on the ACL + // signature, so a reopen / ACL change remounts with fresh initial state). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Org options = caller's memberships + any granted org not in them (backfilled). + const grantedOrgIds = useMemo( + () => [...new Set([...readGrantees, ...writeGrantees].filter((p) => p.type === "org").map((p) => p.id))], + [readGrantees, writeGrantees], + ); + const unknownOrgIds = useMemo( + () => grantedOrgIds.filter((id) => !myOrgs.some((o) => o.userId === id)), + [grantedOrgIds, myOrgs], + ); + const { data: fetchedUnknownOrgs = [] } = useQuery({ + queryKey: ["orgs-backfill", unknownOrgIds.slice().sort().join(",")], + queryFn: async () => { + const resolved = await Promise.all(unknownOrgIds.map((id) => fetchOrgSummary(id))); + return resolved.map((entry, i) => { + const orgId = unknownOrgIds[i]!; + return entry + ? { ...entry, isUnresolved: false } + : { userId: orgId, displayName: orgId, avatarUrl: null, isUnresolved: true }; + }); + }, + enabled: unknownOrgIds.length > 0, + staleTime: 5 * 60_000, + }); + + const orgOptions: OrgOption[] = useMemo(() => { + const map = new Map(); + for (const o of myOrgs) { + map.set(o.userId, { id: o.userId, label: o.displayName, isMember: true, isUnresolved: false }); + } + for (const o of fetchedUnknownOrgs) { + if (!map.has(o.userId)) { + map.set(o.userId, { id: o.userId, label: o.displayName, isMember: false, isUnresolved: o.isUnresolved }); + } + } + return [...map.values()]; + }, [myOrgs, fetchedUnknownOrgs]); + + const handleSave = async () => { + const isPrivate = !isPublic; + const grants = buildGrants(readGrantees, writeGrantees, isPublic); + const initialBuilt = buildGrants( + toPrincipals(initialGrants, "read"), + toPrincipals(initialGrants, "read_write"), + !initialIsPrivate, + ); + if (isPrivate === initialIsPrivate && signature(grants) === signature(initialBuilt)) { + addToast({ type: "info", message: t("permissions.noChanges", "No changes to save.") }); + onCancel(); + return; + } + try { + await onSave(isPrivate, grants); + addToast({ type: "success", message: t("permissions.saveSuccess", "Permissions updated") }); + onCancel(); + } catch (err) { + addToast({ type: "error", message: translateError(err) }); + } + }; + + const writeCount = writeGrantees.length; + + return ( + <> + {/* Tab bar */} +
+ setTab("read")}> + {t("permissions.tabRead", "Read access")} + + setTab("write")}> + {t("permissions.tabWrite", "Write access")} + {writeCount > 0 && ( + + {writeCount} + + )} + +
+ + {tab === "read" ? ( +
+ + +
+

+ {isPublic + ? t("permissions.readPublicNote", "This is public — everyone can read it. Use the Write tab to grant edit access to specific orgs or users.") + : t("permissions.readRestrictedNote", "Private: only you, platform admins, and the orgs/users below can read it. (Editors from the Write tab can read too.)")} +

+ +
+
+ ) : ( +
+

+ {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.")} +

+ +
+ )} + +
+ + +
+ + ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/ornn-web/src/components/permissions/PrincipalSelector.tsx b/ornn-web/src/components/permissions/PrincipalSelector.tsx new file mode 100644 index 00000000..7e57ef6c --- /dev/null +++ b/ornn-web/src/components/permissions/PrincipalSelector.tsx @@ -0,0 +1,215 @@ +/** + * PrincipalSelector — pick a set of orgs + users for one access audience + * (#1125). Reusable across the Read and Write tabs of the permissions editor + * and across the skill + skillset modals (it consolidates the org-checkbox + + * user-email-typeahead logic that used to be duplicated in each modal). + * + * Presentational: org options are supplied by the parent (which owns the + * `useMyOrgs` + unknown-org backfill); this component owns only the + * per-input typeahead state. It emits a flat `Principal[]` for its audience. + * + * @module components/permissions/PrincipalSelector + */ + +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { searchUsersByEmail } from "@/services/usersApi"; + +/** One granted principal within an audience. */ +export interface Principal { + type: "user" | "org"; + id: string; + /** Best-known display label (email / org name); falls back to the id. */ + label: string; + /** True when the org/user could not be resolved (stale grant). */ + isUnresolved?: boolean; +} + +/** An org the caller can choose from (their memberships + any granted unknowns). */ +export interface OrgOption { + id: string; + label: string; + isMember: boolean; + isUnresolved: boolean; +} + +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(handle); + }, [value, delayMs]); + return debounced; +} + +interface PrincipalSelectorProps { + value: Principal[]; + onChange: (next: Principal[]) => void; + orgOptions: OrgOption[]; + disabled?: boolean; + /** Distinguishes the Read vs Write inputs (keys / a11y). */ + idPrefix: string; +} + +export function PrincipalSelector({ + value, + onChange, + orgOptions, + disabled = false, + idPrefix, +}: PrincipalSelectorProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [focused, setFocused] = useState(false); + const inputRef = useRef(null); + + const selectedOrgIds = new Set(value.filter((p) => p.type === "org").map((p) => p.id)); + const selectedUsers = value.filter((p) => p.type === "user"); + + const debounced = useDebouncedValue(query.trim(), 200); + // 2-char minimum mirrors the directory search guard (#816). + const shouldSearch = !disabled && debounced.length >= 2; + const { data: suggestions = [] } = useQuery({ + queryKey: ["users-search", debounced], + queryFn: () => searchUsersByEmail(debounced, 8), + enabled: shouldSearch, + staleTime: 10_000, + }); + + const toggleOrg = (opt: OrgOption) => { + if (selectedOrgIds.has(opt.id)) { + onChange(value.filter((p) => !(p.type === "org" && p.id === opt.id))); + } else { + onChange([...value, { type: "org", id: opt.id, label: opt.label, isUnresolved: opt.isUnresolved }]); + } + }; + + const addUser = (entry: { userId: string; email: string; displayName: string }) => { + if (value.some((p) => p.type === "user" && p.id === entry.userId)) return; + onChange([...value, { type: "user", id: entry.userId, label: entry.email || entry.displayName || entry.userId }]); + setQuery(""); + setFocused(false); + inputRef.current?.blur(); + }; + + const removeUser = (id: string) => + onChange(value.filter((p) => !(p.type === "user" && p.id === id))); + + const dimmed = disabled ? "opacity-60 pointer-events-none" : ""; + + return ( +
+ {/* Organizations */} +

+ {t("permissions.orgsLabel", "Organizations")} +

+
+ {orgOptions.length === 0 && ( +

+ {t("permissions.noOrgs", "No organizations to choose from.")} +

+ )} + {orgOptions.map((org) => ( + + ))} +
+ + {/* Users */} +

+ {t("permissions.usersLabel", "Users")} +

+
+ {selectedUsers.map((u) => ( + + {u.label} + + + ))} + {selectedUsers.length === 0 && ( +

+ {t("permissions.noUsersYet", "No users added yet.")} +

+ )} +
+
+ setQuery(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setTimeout(() => setFocused(false), 150)} + placeholder={t("permissions.searchPlaceholder", "type an email to find a user...") as string} + className="w-full rounded border border-accent/20 bg-elevated px-3 py-2 font-text text-sm text-strong focus:border-accent/60 focus:outline-none" + /> + {focused && !disabled && debounced.length < 2 && ( +
+

+ {t("permissions.searchHint", "Type at least 2 characters to search.")} +

+
+ )} + {focused && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/ornn-web/src/components/permissions/initialGrants.ts b/ornn-web/src/components/permissions/initialGrants.ts new file mode 100644 index 00000000..5ec441f0 --- /dev/null +++ b/ornn-web/src/components/permissions/initialGrants.ts @@ -0,0 +1,32 @@ +/** + * Shared helpers (#1125) for seeding the PermissionsEditor from a skill or + * skillset detail, and for the modal's reset-key signature. Both detail types + * expose the same `grants` + legacy `sharedWith*` fields. + * + * @module components/permissions/initialGrants + */ + +import type { SkillGrant } from "@/types/domain"; + +interface GrantSource { + grants?: SkillGrant[]; + sharedWithUsers: string[]; + sharedWithOrgs: string[]; +} + +/** + * The grants to seed the editor with: the canonical `grants` when present, + * else read-level grants derived from the legacy lists (older cached detail). + */ +export function initialGrantsForEditor(detail: GrantSource): SkillGrant[] { + if (detail.grants) return detail.grants; + return [ + ...detail.sharedWithUsers.map((id) => ({ type: "user" as const, id, level: "read" as const })), + ...detail.sharedWithOrgs.map((id) => ({ type: "org" as const, id, level: "read" as const })), + ]; +} + +/** Order-independent signature for the modal reset key. */ +export function grantsSignature(grants: SkillGrant[]): string { + return grants.map((g) => `${g.type}:${g.id}:${g.level}`).sort().join("|"); +} From d4ec61dfe8765d1d5304784fcdf7aac4fbc81a35 Mon Sep 17 00:00:00 2001 From: Shining <250120269+chronoai-shining@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:01:32 +0800 Subject: [PATCH 3/5] feat(web): skill permissions modal uses the two-tab editor (#1125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-visibility-ladder PermissionsModal (and the #1123 inline per-grant level toggles) with a thin wrapper around the shared `PermissionsEditor`. An owner can now set independent read/write audiences — notably public read + org/user write, which the old modal couldn't express because turning on Public disabled the grant pickers. Tests rewritten for the new UI: public-read + org-write round-trip, Write tab seeded from an existing read_write grant, and the no-change short-circuit. Part of #1125 --- .../skill/PermissionsModal.test.tsx | 76 +- .../src/components/skill/PermissionsModal.tsx | 674 +----------------- 2 files changed, 65 insertions(+), 685 deletions(-) diff --git a/ornn-web/src/components/skill/PermissionsModal.test.tsx b/ornn-web/src/components/skill/PermissionsModal.test.tsx index 34bf2d45..6b8a173c 100644 --- a/ornn-web/src/components/skill/PermissionsModal.test.tsx +++ b/ornn-web/src/components/skill/PermissionsModal.test.tsx @@ -1,9 +1,9 @@ /** - * PermissionsModal level tests (#1123). + * PermissionsModal tests (#1125) — the two-tab Read / Write editor. * - * Guards the typed-grant flow: an existing read_write user grant renders the - * read-write toggle, flipping it to read and saving sends the canonical - * `grants` payload with the new level. + * Headline guard: an owner can set "public read + org write" (the combination + * the old single-ladder modal could not express), and Save emits the correct + * typed `grants` + `isPrivate`. * * orgs / directory / mutation / toast are mocked; framer-motion is stubbed; * react-i18next is global. @@ -12,14 +12,13 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { SkillDetail } from "@/types/domain"; const mutateAsync = vi.fn(); const addToast = vi.fn(); const resolveUsers = vi.fn(); -const searchUsersByEmail = vi.fn(); vi.mock("framer-motion", () => ({ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -45,7 +44,7 @@ vi.mock("framer-motion", () => ({ ), })); -vi.mock("@/hooks/useMe", () => ({ useMyOrgs: () => ({ data: [] }) })); +vi.mock("@/hooks/useMe", () => ({ useMyOrgs: () => ({ data: [{ userId: "org-1", displayName: "Org One" }] }) })); vi.mock("@/hooks/useSkills", () => ({ useUpdateSkillPermissions: () => ({ mutateAsync, isPending: false }), })); @@ -54,8 +53,8 @@ vi.mock("@/stores/toastStore", () => ({ })); vi.mock("@/services/usersApi", () => ({ resolveUsers: (...a: unknown[]) => resolveUsers(...a), - searchUsersByEmail: (...a: unknown[]) => searchUsersByEmail(...a), - fetchOrgSummary: vi.fn(), + searchUsersByEmail: vi.fn().mockResolvedValue([]), + fetchOrgSummary: vi.fn().mockResolvedValue(null), })); import { PermissionsModal } from "./PermissionsModal"; @@ -73,18 +72,18 @@ function skill(overrides: Partial = {}): SkillDetail { presignedPackageUrl: "", metadata: {}, version: "1.0", - sharedWithUsers: ["u1"], + sharedWithUsers: [], sharedWithOrgs: [], - grants: [{ type: "user", id: "u1", level: "read_write" }], + grants: [], ...overrides, } as SkillDetail; } -function renderModal() { +function renderModal(s: SkillDetail) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( - {}} skill={skill()} /> + {}} skill={s} /> , ); } @@ -92,35 +91,42 @@ function renderModal() { beforeEach(() => { mutateAsync.mockReset().mockResolvedValue({ skill: skill() }); addToast.mockReset(); - // Resolve u1 to a real label so the chip is not "unresolved" → the level - // toggle renders. - resolveUsers.mockReset().mockResolvedValue([ - { userId: "u1", email: "u1@x.io", displayName: "User One" }, - ]); - searchUsersByEmail.mockReset().mockResolvedValue([]); + resolveUsers.mockReset().mockResolvedValue([]); }); afterEach(() => cleanup()); -describe("PermissionsModal — permission levels (#1123)", () => { - it("renders the read-write toggle for an existing read_write grant and saves the flipped level as typed grants", async () => { - renderModal(); +describe("PermissionsModal — two-tab Read/Write editor (#1125)", () => { + it("lets an owner set public read + org write and saves the right grants", async () => { + renderModal(skill()); - // After the directory resolves u1, its chip shows the read-write toggle. - const toggle = await screen.findByRole("button", { name: /read-write/i }); - expect(toggle).toBeTruthy(); + // Read tab is default → turn on Public. + fireEvent.click(screen.getByRole("checkbox", { name: /public/i })); - // Flip read-write → read. - fireEvent.click(toggle); - await screen.findByRole("button", { name: /^read$/i }); + // Switch to the Write tab and grant the org write access. + fireEvent.click(screen.getByRole("button", { name: /write access/i })); + fireEvent.click(screen.getByRole("checkbox", { name: "Org One" })); - // Save sends the canonical typed grants with the new level. + // Save → public (isPrivate:false) + a read_write org grant; no read grant + // (public makes read grants redundant, so they're dropped). fireEvent.click(screen.getByRole("button", { name: /^save$/i })); - await waitFor(() => - expect(mutateAsync).toHaveBeenCalledWith({ - isPrivate: true, - grants: [{ type: "user", id: "u1", level: "read" }], - }), - ); + expect(mutateAsync).toHaveBeenCalledWith({ + isPrivate: false, + grants: [{ type: "org", id: "org-1", level: "read_write" }], + }); + }); + + it("seeds the Write tab from an existing read_write grant", () => { + renderModal(skill({ grants: [{ type: "org", id: "org-1", level: "read_write" }] })); + // The Write tab shows a count badge of 1. + const writeTab = screen.getByRole("button", { name: /write access/i }); + expect(writeTab.textContent).toContain("1"); + }); + + it("short-circuits with no changes when nothing is edited", () => { + renderModal(skill({ isPrivate: false, grants: [] })); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + expect(mutateAsync).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.objectContaining({ type: "info" })); }); }); diff --git a/ornn-web/src/components/skill/PermissionsModal.tsx b/ornn-web/src/components/skill/PermissionsModal.tsx index 91e308e1..9d749e08 100644 --- a/ornn-web/src/components/skill/PermissionsModal.tsx +++ b/ornn-web/src/components/skill/PermissionsModal.tsx @@ -1,59 +1,20 @@ /** - * PermissionsModal — per-skill visibility editor. + * PermissionsModal — per-skill access editor (#1125). * - * Axis: broader at the top, narrower at the bottom, with a subtle cyan - * up-arrow in the left gutter. Four levels: - * - * Public — anyone on Ornn, incl. unauthenticated visitors - * Orgs — admin/member of each chosen organization (additive) - * Users — explicit per-user grants (email typeahead, additive) - * Private — only the author + platform admin - * - * Saving is unconditional — `PUT /api/v1/skills/:id/permissions` applies - * the desired state directly. Audit runs out-of-band; if it later flags - * risk, the owner and every consumer receive a notification, but the - * share itself is never blocked. + * Thin wrapper: the Modal shell + the skill permissions mutation, around the + * shared two-tab `PermissionsEditor` (Read / Write). Replaces the old + * single-visibility-ladder UI so an owner can set independent read and write + * audiences (e.g. public read + org write). * * @module components/skill/PermissionsModal */ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; import { Modal } from "@/components/ui/Modal"; -import { Button } from "@/components/ui/Button"; -import { useMyOrgs } from "@/hooks/useMe"; import { useUpdateSkillPermissions } from "@/hooks/useSkills"; -import { useToastStore } from "@/stores/toastStore"; -import { - searchUsersByEmail, - resolveUsers, - fetchOrgSummary, - type UserDirectoryEntry, -} from "@/services/usersApi"; -import type { SkillDetail, SkillGrant, SkillPermissionLevel } from "@/types/domain"; -import { translateError } from "@/utils/translateError"; - -/** A selected user grant carries its permission level alongside the label. */ -type UserGrantEntry = UserDirectoryEntry & { level: SkillPermissionLevel }; - -/** - * Resolve the skill's initial typed grants. Prefers the canonical `grants` - * field; falls back to deriving READ-level grants from the legacy lists for - * older cached payloads (#1123). - */ -function initialGrantsOf(skill: SkillDetail): SkillGrant[] { - if (skill.grants) return skill.grants; - return [ - ...skill.sharedWithUsers.map((id) => ({ type: "user" as const, id, level: "read" as const })), - ...skill.sharedWithOrgs.map((id) => ({ type: "org" as const, id, level: "read" as const })), - ]; -} - -/** Stable signature of a grant set for change-detection (order-independent). */ -function grantSignature(grants: SkillGrant[]): string { - return grants.map((g) => `${g.type}:${g.id}:${g.level}`).sort().join("|"); -} +import { PermissionsEditor } from "@/components/permissions/PermissionsEditor"; +import { initialGrantsForEditor, grantsSignature } from "@/components/permissions/initialGrants"; +import type { SkillDetail } from "@/types/domain"; interface PermissionsModalProps { isOpen: boolean; @@ -61,618 +22,31 @@ interface PermissionsModalProps { skill: SkillDetail; } -function useDebouncedValue(value: T, delayMs: number): T { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const t = setTimeout(() => setDebounced(value), delayMs); - return () => clearTimeout(t); - }, [value, delayMs]); - return debounced; -} - export function PermissionsModal({ isOpen, onClose, skill }: PermissionsModalProps) { const { t } = useTranslation(); + const mutation = useUpdateSkillPermissions(skill.guid); + const grants = initialGrantsForEditor(skill); + return ( - {/* Keyed on the skill's ACL signature (+ open) so the form's state - resets by construction whenever the modal reopens or the - underlying ACLs change — no synchronous reset effect, no - cascading render (#888). The outer Modal owns the open/close - animation, so its AnimatePresence stays stable. */} - + mutation.mutateAsync({ isPrivate, grants: nextGrants }).then(() => undefined) + } + onCancel={onClose} /> ); } - -interface PermissionsFormProps { - skill: SkillDetail; - onClose: () => void; - t: ReturnType["t"]; -} - -function PermissionsForm({ skill, onClose, t }: PermissionsFormProps) { - const addToast = useToastStore((s) => s.addToast); - const { data: myOrgs = [] } = useMyOrgs(); - const permissionsMutation = useUpdateSkillPermissions(skill.guid); - - // Lazy init from the skill's typed grants — the very first render is - // already in the reset state, so no synchronous setState-in-effect is - // needed. Re-open / ACL-change resets via the `key` at the call site. - const initialGrants = initialGrantsOf(skill); - const [isPublic, setIsPublic] = useState(!skill.isPrivate); - const [sharedUsers, setSharedUsers] = useState(() => - initialGrants - .filter((g) => g.type === "user") - .map((g) => ({ userId: g.id, email: "", displayName: g.id, level: g.level })), - ); - const [sharedOrgIds, setSharedOrgIds] = useState(() => - initialGrants.filter((g) => g.type === "org").map((g) => g.id), - ); - // Per-org level, keyed by org id. Only checked orgs are read on save. - const [orgLevels, setOrgLevels] = useState>(() => - Object.fromEntries( - initialGrants.filter((g) => g.type === "org").map((g) => [g.id, g.level]), - ), - ); - const [userQuery, setUserQuery] = useState(""); - const [userInputFocused, setUserInputFocused] = useState(false); - const userInputRef = useRef(null); - - // Resolve saved user_ids into email/displayName once the form mounts. - // This is a genuine external-system sync (user directory API) and only - // setStates from the async callback, never synchronously in the effect - // body — so it doesn't trip set-state-in-effect (#888). - useEffect(() => { - const ids = skill.sharedWithUsers; - if (ids.length === 0) return; - let cancelled = false; - (async () => { - const resolved = await resolveUsers(ids).catch(() => []); - if (cancelled || resolved.length === 0) return; - const byId = new Map(resolved.map((r) => [r.userId, r])); - setSharedUsers((prev) => - prev.map((existing) => { - const hit = byId.get(existing.userId); - // Merge the resolved label but PRESERVE the grant's level (#1123). - return hit ? { ...hit, level: existing.level } : existing; - }), - ); - })(); - return () => { - cancelled = true; - }; - }, [skill]); - - const debouncedQuery = useDebouncedValue(userQuery.trim(), 200); - // Only query at 2+ chars. The backend now rejects empty/1-char `q` with - // 400 (#816), so firing on bare focus or a single keystroke would surface - // an error toast for a query the user never finished typing. Below the - // threshold we render a "keep typing" hint instead of a results list. - const shouldSearch = !isPublic && debouncedQuery.length >= 2; - const { data: suggestions = [] } = useQuery({ - queryKey: ["users-search", debouncedQuery], - queryFn: () => searchUsersByEmail(debouncedQuery, 8), - enabled: shouldSearch, - staleTime: 10_000, - }); - - const unknownOrgIds = useMemo( - () => sharedOrgIds.filter((id) => !myOrgs.some((o) => o.userId === id)), - [sharedOrgIds, myOrgs], - ); - const { data: fetchedUnknownOrgs = [] } = useQuery({ - queryKey: ["orgs-backfill", unknownOrgIds.sort().join(",")], - queryFn: async () => { - const resolved = await Promise.all(unknownOrgIds.map((id) => fetchOrgSummary(id))); - return resolved.map((entry, i) => { - // `i` is bounded by `unknownOrgIds.length` (Promise.all preserves - // indexing). `!` is safe under noUncheckedIndexedAccess (#450). - const orgId = unknownOrgIds[i]!; - // #720 — entry is null when NyxID couldn't resolve the id (org - // deleted, or caller can't see it). Keep the row visible so - // the owner can revoke, but flag `isUnresolved` so the chip - // gets the warning treatment. - return entry - ? { ...entry, isUnresolved: false } - : { userId: orgId, displayName: orgId, avatarUrl: null, isUnresolved: true }; - }); - }, - // The form only mounts while the modal is open (Modal renders its - // children conditionally), so the prior `isOpen &&` gate is implicit. - enabled: unknownOrgIds.length > 0, - staleTime: 5 * 60_000, - }); - - const allOrgOptions = useMemo(() => { - const map = new Map(); - for (const o of myOrgs) { - map.set(o.userId, { userId: o.userId, displayName: o.displayName, isMember: true, isUnresolved: false }); - } - for (const o of fetchedUnknownOrgs) { - if (!map.has(o.userId)) { - map.set(o.userId, { - userId: o.userId, - displayName: o.displayName, - isMember: false, - isUnresolved: o.isUnresolved, - }); - } - } - return Array.from(map.values()); - }, [myOrgs, fetchedUnknownOrgs]); - - const toggleOrg = (orgId: string) => { - setSharedOrgIds((prev) => - prev.includes(orgId) ? prev.filter((id) => id !== orgId) : [...prev, orgId], - ); - // New grants default to read; preserve an existing level on re-check. - setOrgLevels((prev) => (prev[orgId] ? prev : { ...prev, [orgId]: "read" })); - }; - - const setOrgLevel = (orgId: string, level: SkillPermissionLevel) => { - setOrgLevels((prev) => ({ ...prev, [orgId]: level })); - }; - - const addUser = (entry: UserDirectoryEntry) => { - if (sharedUsers.some((u) => u.userId === entry.userId)) return; - // New per-user grants default to read. - setSharedUsers((prev) => [...prev, { ...entry, level: "read" }]); - setUserQuery(""); - setUserInputFocused(false); - userInputRef.current?.blur(); - }; - - const setUserLevel = (userId: string, level: SkillPermissionLevel) => { - setSharedUsers((prev) => prev.map((u) => (u.userId === userId ? { ...u, level } : u))); - }; - - const removeUser = (userId: string) => { - setSharedUsers((prev) => prev.filter((u) => u.userId !== userId)); - }; - - const orgsActive = !isPublic && sharedOrgIds.length > 0; - const usersActive = !isPublic && sharedUsers.length > 0; - const privateActive = !isPublic && !orgsActive && !usersActive; - - // The canonical typed grants this form would persist (#1123). - const buildGrants = (): SkillGrant[] => [ - ...sharedUsers.map((u) => ({ type: "user" as const, id: u.userId, level: u.level })), - ...sharedOrgIds.map((id) => ({ - type: "org" as const, - id, - level: orgLevels[id] ?? "read", - })), - ]; - - const handleSave = async () => { - const afterPrivate = !isPublic; - const grants = buildGrants(); - - // Level-aware "nothing changed" short-circuit (covers a level flip, - // not just add/remove). - const privateChanged = skill.isPrivate !== afterPrivate; - const grantsChanged = grantSignature(grants) !== grantSignature(initialGrants); - - if (!privateChanged && !grantsChanged) { - addToast({ - type: "info", - message: t("permissions.noChanges", "No changes to save."), - }); - onClose(); - return; - } - - try { - await permissionsMutation.mutateAsync({ - isPrivate: afterPrivate, - grants, - }); - addToast({ - type: "success", - message: t("permissions.saveSuccess", "Permissions updated"), - }); - onClose(); - } catch (err) { - const message = translateError(err); - addToast({ type: "error", message }); - } - }; - - return ( - <> -
-
- - - -
-
- -
- - setIsPublic((v) => !v)} - > - - - - -
- -

- {t("permissions.orgsTitle", "Shared with organizations")} -

-

- {t( - "permissions.orgsDesc", - "Every admin and member of a checked org can see and use this skill.", - )} -

-
- {allOrgOptions.length === 0 && ( -

- {t("permissions.noOrgs", "No organizations to choose from.")} -

- )} - {allOrgOptions.map((org) => { - const checked = sharedOrgIds.includes(org.userId); - return ( - - ); - })} -
-
- - -

- {t("permissions.usersTitle", "Shared with specific users")} -

-

- {t( - "permissions.usersDesc", - "Search by email. Only users who have signed into Ornn appear here.", - )} -

-
- {sharedUsers.map((u) => { - // #720 — user couldn't be resolved via the directory. - // `resolveUsers` leaves the placeholder - // `{ userId, email: "", displayName: userId }` in place - // when a lookup misses, so `!u.email && u.displayName === u.userId` - // is the signal that the grant points at a user who no - // longer exists (or who the caller can't see). - const isUnresolved = !u.email && u.displayName === u.userId; - return ( - - {isUnresolved && ( - - - - - )} - {u.email || u.displayName || u.userId} - {!isUnresolved && ( - setUserLevel(u.userId, lvl)} - t={t} - /> - )} - - - ); - })} - {sharedUsers.length === 0 && ( -

- {t("permissions.noUsersYet", "No users added yet.")} -

- )} -
-
- setUserQuery(e.target.value)} - onFocus={() => setUserInputFocused(true)} - onBlur={() => setTimeout(() => setUserInputFocused(false), 150)} - placeholder={ - t("permissions.searchPlaceholder", "type an email to find a user...") as string - } - className="w-full bg-card rounded border border-accent/20 bg-elevated px-3 py-2 font-text text-sm text-strong focus:outline-none focus:border-accent/60" - disabled={isPublic} - /> - {userInputFocused && - !isPublic && - debouncedQuery.length < 2 && - userQuery.trim().length < 2 && ( -
-

- {t( - "permissions.searchHint", - "Type at least 2 characters to search.", - )} -

-
- )} - {userInputFocused && suggestions.length > 0 && ( -
- {suggestions.map((s) => ( - - ))} -
- )} -
-
-
- - - -

- {t("permissions.privateTitle", "Private")} -

-

- {t( - "permissions.privateDesc", - "Only you and platform admins can see this skill. Active when nothing above is set.", - )} -

-
-
-
- -
- - -
- - ); -} - -/** - * Compact read / read-write level switch for one grant (#1123). Clicking - * flips the level. `stopPropagation` keeps a click off the surrounding - * checkbox label / tier card. read-write is accent-highlighted; read is - * muted (the default, lowest-privilege state). - */ -function LevelToggle({ - level, - onChange, - t, -}: { - level: SkillPermissionLevel; - onChange: (level: SkillPermissionLevel) => void; - t: ReturnType["t"]; -}) { - const isWrite = level === "read_write"; - return ( - - ); -} - -/** Section divider with a small uppercase label centered on top. */ -function SectionHeader({ label }: { label: string }) { - return ( -
-
- - {label} - -
-
- ); -} - -interface TierCardProps { - active: boolean; - /** Overrides `active` visually — greyed out when Public is on for sub-tiers. */ - dimmed?: boolean; - /** - * Tier of access this card represents — drives the highlight color - * when active. Matches the visibility-card chip on `SkillDetailPage`: - * public → green (success) - * limited → yellow (warning) — orgs / users - * private → grey (info / mineral) - */ - accent: "public" | "limited" | "private"; - onToggle?: () => void; - className?: string; - children: ReactNode; -} - -const TIER_ACTIVE_CLASS: Record = { - public: "border-success/60 bg-success-soft", - limited: "border-warning/60 bg-warning-soft", - private: "border-info/60 bg-info-soft", -}; - -function TierCard({ - active, - dimmed = false, - accent, - onToggle, - className = "", - children, -}: TierCardProps) { - const ringClass = active ? TIER_ACTIVE_CLASS[accent] : "border-subtle bg-elevated/40"; - const dimmedClass = dimmed ? "opacity-60" : ""; - return ( -
- {children} -
- ); -} From 659cd449d3f7a9e7cb467de9a6ee462280c2ed6a Mon Sep 17 00:00:00 2001 From: Shining <250120269+chronoai-shining@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:01:32 +0800 Subject: [PATCH 4/5] feat(web): skillset permissions modal uses the two-tab editor (#1125) Rewire SkillsetPermissionsModal to the shared `PermissionsEditor`, keeping it in lock-step with the skill modal and removing the near-duplicate single-ladder implementation. This also brings skillsets the read / read-write level support they never had. Test updated to the new `{ isPrivate, grants }` payload; the skills-hook-isolation guard stays. Part of #1125 --- .../SkillsetPermissionsModal.test.tsx | 10 +- .../skillset/SkillsetPermissionsModal.tsx | 439 +----------------- 2 files changed, 27 insertions(+), 422 deletions(-) diff --git a/ornn-web/src/components/skillset/SkillsetPermissionsModal.test.tsx b/ornn-web/src/components/skillset/SkillsetPermissionsModal.test.tsx index 883ecf66..b4ccc761 100644 --- a/ornn-web/src/components/skillset/SkillsetPermissionsModal.test.tsx +++ b/ornn-web/src/components/skillset/SkillsetPermissionsModal.test.tsx @@ -96,18 +96,16 @@ describe("SkillsetPermissionsModal", () => { it("saves the desired ACL state (flip private → public)", async () => { wrap( {}} skillset={PRIVATE_SET} />); - // Check the Public checkbox (the first checkbox in the tier stack). + // Check the Public checkbox (the only checkbox on the Read tab when the + // caller has no orgs). const publicCheckbox = screen.getAllByRole("checkbox")[0]!; fireEvent.click(publicCheckbox); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(updateSkillsetPermissions).toHaveBeenCalledTimes(1)); - expect(updateSkillsetPermissions).toHaveBeenCalledWith({ - isPrivate: false, - sharedWithUsers: [], - sharedWithOrgs: [], - }); + // New canonical payload (#1125): public, no grants. + expect(updateSkillsetPermissions).toHaveBeenCalledWith({ isPrivate: false, grants: [] }); }); it("short-circuits with no save when nothing changed", async () => { diff --git a/ornn-web/src/components/skillset/SkillsetPermissionsModal.tsx b/ornn-web/src/components/skillset/SkillsetPermissionsModal.tsx index 888240b2..862dedd0 100644 --- a/ornn-web/src/components/skillset/SkillsetPermissionsModal.tsx +++ b/ornn-web/src/components/skillset/SkillsetPermissionsModal.tsx @@ -1,32 +1,21 @@ /** - * SkillsetPermissionsModal — per-skillset visibility editor. + * SkillsetPermissionsModal — per-skillset access editor (#1125). * - * A DUPLICATE of the skills `PermissionsModal`, bound to `SkillsetDetail` + - * `useUpdateSkillsetPermissions` (the skills modal is left untouched — it - * couples to `SkillDetail` + `useUpdateSkillPermissions`). Same UX: broader at - * the top (Public), narrower at the bottom (Private), with org + user grants - * in between. Saving is unconditional — `PUT /api/v1/skillsets/:id/permissions` - * applies the desired state directly. + * Thin wrapper: the Modal shell + the skillset permissions mutation, around + * the shared two-tab `PermissionsEditor`. Kept in lock-step with the skill + * `PermissionsModal` (they now share the editor + selector). This also brings + * skillsets the read/read-write level support they never had under the old + * single-ladder UI. * * @module components/skillset/SkillsetPermissionsModal */ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; import { Modal } from "@/components/ui/Modal"; -import { Button } from "@/components/ui/Button"; -import { useMyOrgs } from "@/hooks/useMe"; import { useUpdateSkillsetPermissions } from "@/hooks/useSkillsets"; -import { useToastStore } from "@/stores/toastStore"; -import { - searchUsersByEmail, - resolveUsers, - fetchOrgSummary, - type UserDirectoryEntry, -} from "@/services/usersApi"; +import { PermissionsEditor } from "@/components/permissions/PermissionsEditor"; +import { initialGrantsForEditor, grantsSignature } from "@/components/permissions/initialGrants"; import type { SkillsetDetail } from "@/types/skillset"; -import { translateError } from "@/utils/translateError"; interface SkillsetPermissionsModalProps { isOpen: boolean; @@ -34,411 +23,29 @@ interface SkillsetPermissionsModalProps { skillset: SkillsetDetail; } -function useDebouncedValue(value: T, delayMs: number): T { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const handle = setTimeout(() => setDebounced(value), delayMs); - return () => clearTimeout(handle); - }, [value, delayMs]); - return debounced; -} - -export function SkillsetPermissionsModal({ - isOpen, - onClose, - skillset, -}: SkillsetPermissionsModalProps) { +export function SkillsetPermissionsModal({ isOpen, onClose, skillset }: SkillsetPermissionsModalProps) { const { t } = useTranslation(); + const mutation = useUpdateSkillsetPermissions(skillset.guid, skillset.name); + const grants = initialGrantsForEditor(skillset); + return ( - {/* Keyed on the ACL signature (+ open) so form state resets by - construction whenever the modal reopens or the ACLs change (#888). */} - + mutation.mutateAsync({ isPrivate, grants: nextGrants }).then(() => undefined) + } + onCancel={onClose} /> ); } - -interface PermissionsFormProps { - skillset: SkillsetDetail; - onClose: () => void; - t: ReturnType["t"]; -} - -function PermissionsForm({ skillset, onClose, t }: PermissionsFormProps) { - const addToast = useToastStore((s) => s.addToast); - const { data: myOrgs = [] } = useMyOrgs(); - const permissionsMutation = useUpdateSkillsetPermissions(skillset.guid, skillset.name); - - const [isPublic, setIsPublic] = useState(!skillset.isPrivate); - const [sharedUsers, setSharedUsers] = useState(() => - skillset.sharedWithUsers.map((id) => ({ userId: id, email: "", displayName: id })), - ); - const [sharedOrgIds, setSharedOrgIds] = useState(skillset.sharedWithOrgs); - const [userQuery, setUserQuery] = useState(""); - const [userInputFocused, setUserInputFocused] = useState(false); - const userInputRef = useRef(null); - - // Resolve saved user_ids into email/displayName once the form mounts. - useEffect(() => { - const ids = skillset.sharedWithUsers; - if (ids.length === 0) return; - let cancelled = false; - (async () => { - const resolved = await resolveUsers(ids).catch(() => []); - if (cancelled || resolved.length === 0) return; - const byId = new Map(resolved.map((r) => [r.userId, r])); - setSharedUsers((prev) => prev.map((existing) => byId.get(existing.userId) ?? existing)); - })(); - return () => { - cancelled = true; - }; - }, [skillset]); - - const debouncedQuery = useDebouncedValue(userQuery.trim(), 200); - const shouldSearch = !isPublic && (userInputFocused || debouncedQuery.length > 0); - const { data: suggestions = [] } = useQuery({ - queryKey: ["users-search", debouncedQuery], - queryFn: () => searchUsersByEmail(debouncedQuery, 8), - enabled: shouldSearch, - staleTime: 10_000, - }); - - const unknownOrgIds = useMemo( - () => sharedOrgIds.filter((id) => !myOrgs.some((o) => o.userId === id)), - [sharedOrgIds, myOrgs], - ); - const { data: fetchedUnknownOrgs = [] } = useQuery({ - queryKey: ["orgs-backfill", unknownOrgIds.sort().join(",")], - queryFn: async () => { - const resolved = await Promise.all(unknownOrgIds.map((id) => fetchOrgSummary(id))); - return resolved.map((entry, i) => { - const orgId = unknownOrgIds[i]!; - return entry - ? { ...entry, isUnresolved: false } - : { userId: orgId, displayName: orgId, avatarUrl: null, isUnresolved: true }; - }); - }, - enabled: unknownOrgIds.length > 0, - staleTime: 5 * 60_000, - }); - - const allOrgOptions = useMemo(() => { - const map = new Map< - string, - { userId: string; displayName: string; isMember: boolean; isUnresolved: boolean } - >(); - for (const o of myOrgs) { - map.set(o.userId, { userId: o.userId, displayName: o.displayName, isMember: true, isUnresolved: false }); - } - for (const o of fetchedUnknownOrgs) { - if (!map.has(o.userId)) { - map.set(o.userId, { - userId: o.userId, - displayName: o.displayName, - isMember: false, - isUnresolved: o.isUnresolved, - }); - } - } - return Array.from(map.values()); - }, [myOrgs, fetchedUnknownOrgs]); - - const toggleOrg = (orgId: string) => { - setSharedOrgIds((prev) => - prev.includes(orgId) ? prev.filter((id) => id !== orgId) : [...prev, orgId], - ); - }; - - const addUser = (entry: UserDirectoryEntry) => { - if (sharedUsers.some((u) => u.userId === entry.userId)) return; - setSharedUsers((prev) => [...prev, entry]); - setUserQuery(""); - setUserInputFocused(false); - userInputRef.current?.blur(); - }; - - const removeUser = (userId: string) => { - setSharedUsers((prev) => prev.filter((u) => u.userId !== userId)); - }; - - const orgsActive = !isPublic && sharedOrgIds.length > 0; - const usersActive = !isPublic && sharedUsers.length > 0; - const privateActive = !isPublic && !orgsActive && !usersActive; - - const handleSave = async () => { - const beforePrivate = skillset.isPrivate; - const beforeUsers = new Set(skillset.sharedWithUsers); - const beforeOrgs = new Set(skillset.sharedWithOrgs); - const afterPrivate = !isPublic; - - const privateChanged = beforePrivate !== afterPrivate; - const usersChanged = - sharedUsers.length !== beforeUsers.size || - sharedUsers.some((u) => !beforeUsers.has(u.userId)); - const orgsChanged = - sharedOrgIds.length !== beforeOrgs.size || - sharedOrgIds.some((id) => !beforeOrgs.has(id)); - - if (!privateChanged && !usersChanged && !orgsChanged) { - addToast({ type: "info", message: t("permissions.noChanges", "No changes to save.") }); - onClose(); - return; - } - - try { - await permissionsMutation.mutateAsync({ - isPrivate: !isPublic, - sharedWithUsers: sharedUsers.map((u) => u.userId), - sharedWithOrgs: sharedOrgIds, - }); - addToast({ type: "success", message: t("permissions.saveSuccess", "Permissions updated") }); - onClose(); - } catch (err) { - addToast({ type: "error", message: translateError(err) }); - } - }; - - return ( - <> -
-
- - - -
-
- -
- - setIsPublic((v) => !v)}> - - - - -
- -

- {t("permissions.orgsTitle", "Shared with organizations")} -

-

- {t( - "skillsetPermissions.orgsDesc", - "Every admin and member of a checked org can see and use this skillset.", - )} -

-
- {allOrgOptions.length === 0 && ( -

- {t("permissions.noOrgs", "No organizations to choose from.")} -

- )} - {allOrgOptions.map((org) => { - const checked = sharedOrgIds.includes(org.userId); - return ( - - ); - })} -
-
- - -

- {t("permissions.usersTitle", "Shared with specific users")} -

-

- {t( - "permissions.usersDesc", - "Search by email. Only users who have signed into Ornn appear here.", - )} -

-
- {sharedUsers.map((u) => ( - - {u.email || u.displayName || u.userId} - - - ))} - {sharedUsers.length === 0 && ( -

- {t("permissions.noUsersYet", "No users added yet.")} -

- )} -
-
- setUserQuery(e.target.value)} - onFocus={() => setUserInputFocused(true)} - onBlur={() => setTimeout(() => setUserInputFocused(false), 150)} - placeholder={ - t("permissions.searchPlaceholder", "type an email to find a user...") as string - } - className="w-full rounded border border-accent/20 bg-elevated px-3 py-2 font-text text-sm text-strong focus:outline-none focus:border-accent/60" - disabled={isPublic} - /> - {userInputFocused && suggestions.length > 0 && ( -
- {suggestions.map((s) => ( - - ))} -
- )} -
-
-
- - - -

- {t("permissions.privateTitle", "Private")} -

-

- {t( - "skillsetPermissions.privateDesc", - "Only you and platform admins can see this skillset. Active when nothing above is set.", - )} -

-
-
-
- -
- - -
- - ); -} - -function SectionHeader({ label }: { label: string }) { - return ( -
-
- {label} -
-
- ); -} - -interface TierCardProps { - active: boolean; - dimmed?: boolean; - accent: "public" | "limited" | "private"; - onToggle?: () => void; - className?: string; - children: ReactNode; -} - -const TIER_ACTIVE_CLASS: Record = { - public: "border-success/60 bg-success-soft", - limited: "border-warning/60 bg-warning-soft", - private: "border-info/60 bg-info-soft", -}; - -function TierCard({ active, dimmed = false, accent, onToggle, className = "", children }: TierCardProps) { - const ringClass = active ? TIER_ACTIVE_CLASS[accent] : "border-subtle bg-elevated/40"; - const dimmedClass = dimmed ? "opacity-60" : ""; - return ( -
- {children} -
- ); -} From 858dc2229c3d6837a2b16fb4bf8eadb92ebc88da Mon Sep 17 00:00:00 2001 From: Shining <250120269+chronoai-shining@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:01:53 +0800 Subject: [PATCH 5/5] docs: changeset for read/write permissions tabs (#1125) Part of #1125 --- .changeset/permissions-read-write-tabs.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/permissions-read-write-tabs.md diff --git a/.changeset/permissions-read-write-tabs.md b/.changeset/permissions-read-write-tabs.md new file mode 100644 index 00000000..99c44b44 --- /dev/null +++ b/.changeset/permissions-read-write-tabs.md @@ -0,0 +1,8 @@ +--- +"ornn-api": minor +"ornn-web": minor +--- + +Manage Permissions: separate Read and Write access into two tabs. + +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.