"use client"; /** * FilePermissionsDialog — file-level permission overrides (TASK-0104 §2). * * Targets the FILE `Permission` grants table (`GET/PUT /files/:id/permissions`), * NOT the page-share model: explicit grants on a single file, where the * DEFAULT is inherit (no explicit grant) and `NONE` is an EXPLICIT DENY * (overrides inheritance/role). Styled on ShareDialog (grant rows + add row). * * The API REPLACES the grant set on every save (full-desired-set PUT), so the * dialog edits a local copy of the grants and persists atomically. The * frontend is never trusted for authorization — the API enforces EDIT on the * file; this UI only renders and sends. */ import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Plus, Shield, Trash2 } from "lucide-react"; import { toast } from "@/stores/ui-store"; import { workspaceApi } from "@/lib/api"; import { filesApi, type FileGrant, type FilePermissionGrantInput, } from "@/lib/files/api"; import { Dialog } from "@/components/dialogs/dialog"; import type { Team, WorkspaceMember } from "@/lib/types"; export type FilePermissionLevel = "NONE" | "VIEW" | "COMMENT" | "EDIT" | "FULL_ACCESS"; const LEVELS: { value: FilePermissionLevel; label: string; description: string }[] = [ { value: "NONE", label: "No access", description: "Explicit deny — overrides inheritance." }, { value: "VIEW", label: "Can view", description: "Read/download this file only." }, { value: "COMMENT", label: "Can comment", description: "View + comment on this file." }, { value: "EDIT", label: "Can edit", description: "Rename, replace, manage versions." }, { value: "FULL_ACCESS", label: "Full access", description: "Everything, including purge + permissions." } ]; /** Role grants use the role NAME as granteeId (backend contract). */ const ROLES: { id: string; label: string }[] = [ { id: "OWNER", label: "Owners" }, { id: "ADMIN", label: "Admins" }, { id: "MEMBER", label: "Members" }, { id: "GUEST", label: "Guests" } ]; type GranteeType = "user" | "team" | "role"; interface GranteeOption { type: GranteeType; id: string; label: string; sub?: string; } export interface FilePermissionsDialogProps { open: boolean; fileId: string; fileName?: string; workspaceId: string; onClose: () => void; } export function FilePermissionsDialog({ open, fileId, fileName, workspaceId, onClose }: FilePermissionsDialogProps) { const qc = useQueryClient(); const members = useQuery({ queryKey: ["workspaces", workspaceId, "members"], queryFn: () => workspaceApi.members(workspaceId), enabled: open && workspaceId.length > 0, staleTime: 60_000 }); const teams = useQuery({ queryKey: ["workspaces", workspaceId, "teams"], queryFn: () => workspaceApi.teams(workspaceId), enabled: open && workspaceId.length > 0, staleTime: 60_000 }); const grantsQuery = useQuery({ queryKey: ["files", fileId, "permissions"], queryFn: () => filesApi.listPermissions(fileId, { workspaceId }), enabled: open && !!fileId, retry: false }); // Local editable copy (the PUT replaces the whole set). Initialized ONCE // per open from the server grants — when the query first resolves — so an // unsaved edit is never clobbered by a refetch, and a fast "Save" before // the list loads can't wipe the real grants (Save stays disabled until // initialized). const [grants, setGrants] = useState([]); const initializedRef = useRef(false); useEffect(() => { if (!open) { initializedRef.current = false; setGrants([]); return; } if (!initializedRef.current && grantsQuery.data) { initializedRef.current = true; setGrants( grantsQuery.data.map((g) => ({ granteeType: g.granteeType, granteeId: g.granteeId, level: g.level })) ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, grantsQuery.data]); // "Loaded" = the grants list actually resolved (Save/Add stay disabled on // an error, e.g. 403 no-edit-access, so a stale/empty set can't be pushed). const grantsLoaded = open && initializedRef.current; const [newType, setNewType] = useState("user"); const [newId, setNewId] = useState(""); const [newLevel, setNewLevel] = useState("VIEW"); const options = useMemo(() => { const out: GranteeOption[] = []; const m: WorkspaceMember[] = members.data ?? []; for (const mem of m) out.push({ type: "user", id: mem.userId, label: mem.user.name, sub: mem.user.email }); const t: Team[] = teams.data ?? []; for (const team of t) out.push({ type: "team", id: team.id, label: team.name, sub: "Team" }); for (const r of ROLES) out.push({ type: "role", id: r.id, label: r.label, sub: "Role" }); return out; }, [members.data, teams.data]); const save = useMutation({ mutationFn: (input: FilePermissionGrantInput[]) => filesApi.setPermissions(fileId, input, { workspaceId }), onSuccess: (data) => { qc.setQueryData(["files", fileId, "permissions"], data); setGrants(data.map((g: FileGrant) => ({ granteeType: g.granteeType, granteeId: g.granteeId, level: g.level }))); toast("success", "Permissions saved"); }, onError: (e) => toast("error", "Save failed", e instanceof Error ? e.message : undefined) }); const reset = useMutation({ mutationFn: () => filesApi.setPermissions(fileId, [], { workspaceId }), onSuccess: (data) => { qc.setQueryData(["files", fileId, "permissions"], data); setGrants([]); toast("success", "All overrides removed", "The file inherits workspace permissions."); }, onError: (e) => toast("error", "Reset failed", e instanceof Error ? e.message : undefined) }); const removeGrant = (idx: number) => setGrants((g) => g.filter((_, i) => i !== idx)); const setLevel = (idx: number, level: FilePermissionLevel) => setGrants((g) => g.map((grant, i) => (i === idx ? { ...grant, level } : grant))); const addGrant = () => { const opt = options.find((o) => o.type === newType && o.id === newId); if (!opt) return; // one grant per grantee: re-level the existing row instead of duplicating if (grants.some((g) => g.granteeType === newType && g.granteeId === newId)) { setGrants((g) => g.map((grant) => (grant.granteeType === newType && grant.granteeId === newId ? { ...grant, level: newLevel } : grant))); return; } setGrants((g) => [...g, { granteeType: newType, granteeId: newId, level: newLevel }]); setNewId(""); }; const granteeLabel = (g: FilePermissionGrantInput): string => { const opt = options.find((o) => o.type === g.granteeType && o.id === g.granteeId); return opt ? opt.label : g.granteeId; }; return ( Explicit overrides for {fileName ?? "this file"}. No override = inherit workspace permissions. “No access” is an explicit deny. } width="max-w-xl" footer={ <> } >
{/* existing grants (error → no placeholder; the message below explains) */} {!grantsLoaded && !grantsQuery.isError ? (

Loading permissions…

) : grantsLoaded && grants.length === 0 ? (

No explicit grants — this file inherits workspace permissions.

) : (
    {grants.map((g, idx) => { const denied = g.level === "NONE"; return (
  • {granteeLabel(g)} ({g.granteeType === "role" ? "role" : g.granteeType === "team" ? "team" : "member"})
  • ); })}
)} {/* add a grant */}

Add an override

{(() => { const lvl = LEVELS.find((l) => l.value === newLevel); return lvl ?

{lvl.description}

: null; })()}
{/* reset (remove all overrides → back to inherit) */} {grants.length > 0 && (

Removing all overrides restores inherited permissions.

)} {grantsQuery.isError && (

Could not load permissions ({grantsQuery.error instanceof Error ? grantsQuery.error.message : "unknown"}). You may not have edit access to this file.

)} {save.isPending && (

Saving…

)}
); }