|
| 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