1"use client";
2
3
16import { useEffect, useMemo, useRef, useState } from "react";
17import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
18import { Loader2, Plus, Shield, Trash2 } from "lucide-react";
19import { toast } from "@/stores/ui-store";
20import { workspaceApi } from "@/lib/api";
21import {
22 filesApi,
23 type FileGrant,
24 type FilePermissionGrantInput,
25} from "@/lib/files/api";
26import { Dialog } from "@/components/dialogs/dialog";
27import type { Team, WorkspaceMember } from "@/lib/types";
28
29export type FilePermissionLevel = "NONE" | "VIEW" | "COMMENT" | "EDIT" | "FULL_ACCESS";
30
31const LEVELS: { value: FilePermissionLevel; label: string; description: string }[] = [
32 { value: "NONE", label: "No access", description: "Explicit deny — overrides inheritance." },
33 { value: "VIEW", label: "Can view", description: "Read/download this file only." },
34 { value: "COMMENT", label: "Can comment", description: "View + comment on this file." },
35 { value: "EDIT", label: "Can edit", description: "Rename, replace, manage versions." },
36 { value: "FULL_ACCESS", label: "Full access", description: "Everything, including purge + permissions." }
37];
38
39
40const ROLES: { id: string; label: string }[] = [
41 { id: "OWNER", label: "Owners" },
42 { id: "ADMIN", label: "Admins" },
43 { id: "MEMBER", label: "Members" },
44 { id: "GUEST", label: "Guests" }
45];
46
47type GranteeType = "user" | "team" | "role";
48interface GranteeOption {
49 type: GranteeType;
50 id: string;
51 label: string;
52 sub?: string;
53}
54
55export interface FilePermissionsDialogProps {
56 open: boolean;
57 fileId: string;
58 fileName?: string;
59 workspaceId: string;
60 onClose: () => void;
61}
62
63export function FilePermissionsDialog({ open, fileId, fileName, workspaceId, onClose }: FilePermissionsDialogProps) {
64 const qc = useQueryClient();
65
66 const members = useQuery({
67 queryKey: ["workspaces", workspaceId, "members"],
68 queryFn: () => workspaceApi.members(workspaceId),
69 enabled: open && workspaceId.length > 0,
70 staleTime: 60_000
71 });
72 const teams = useQuery({
73 queryKey: ["workspaces", workspaceId, "teams"],
74 queryFn: () => workspaceApi.teams(workspaceId),
75 enabled: open && workspaceId.length > 0,
76 staleTime: 60_000
77 });
78
79 const grantsQuery = useQuery({
80 queryKey: ["files", fileId, "permissions"],
81 queryFn: () => filesApi.listPermissions(fileId, { workspaceId }),
82 enabled: open && !!fileId,
83 retry: false
84 });
85
86
87
88
89
90
91 const [grants, setGrants] = useState<FilePermissionGrantInput[]>([]);
92 const initializedRef = useRef(false);
93 useEffect(() => {
94 if (!open) {
95 initializedRef.current = false;
96 setGrants([]);
97 return;
98 }
99 if (!initializedRef.current && grantsQuery.data) {
100 initializedRef.current = true;
101 setGrants(
102 grantsQuery.data.map((g) => ({ granteeType: g.granteeType, granteeId: g.granteeId, level: g.level }))
103 );
104 }
105
106 }, [open, grantsQuery.data]);
107
108
109 const grantsLoaded = open && initializedRef.current;
110
111 const [newType, setNewType] = useState<GranteeType>("user");
112 const [newId, setNewId] = useState("");
113 const [newLevel, setNewLevel] = useState<FilePermissionLevel>("VIEW");
114
115 const options = useMemo<GranteeOption[]>(() => {
116 const out: GranteeOption[] = [];
117 const m: WorkspaceMember[] = members.data ?? [];
118 for (const mem of m) out.push({ type: "user", id: mem.userId, label: mem.user.name, sub: mem.user.email });
119 const t: Team[] = teams.data ?? [];
120 for (const team of t) out.push({ type: "team", id: team.id, label: team.name, sub: "Team" });
121 for (const r of ROLES) out.push({ type: "role", id: r.id, label: r.label, sub: "Role" });
122 return out;
123 }, [members.data, teams.data]);
124
125 const save = useMutation({
126 mutationFn: (input: FilePermissionGrantInput[]) => filesApi.setPermissions(fileId, input, { workspaceId }),
127 onSuccess: (data) => {
128 qc.setQueryData(["files", fileId, "permissions"], data);
129 setGrants(data.map((g: FileGrant) => ({ granteeType: g.granteeType, granteeId: g.granteeId, level: g.level })));
130 toast("success", "Permissions saved");
131 },
132 onError: (e) => toast("error", "Save failed", e instanceof Error ? e.message : undefined)
133 });
134
135 const reset = useMutation({
136 mutationFn: () => filesApi.setPermissions(fileId, [], { workspaceId }),
137 onSuccess: (data) => {
138 qc.setQueryData(["files", fileId, "permissions"], data);
139 setGrants([]);
140 toast("success", "All overrides removed", "The file inherits workspace permissions.");
141 },
142 onError: (e) => toast("error", "Reset failed", e instanceof Error ? e.message : undefined)
143 });
144
145 const removeGrant = (idx: number) => setGrants((g) => g.filter((_, i) => i !== idx));
146 const setLevel = (idx: number, level: FilePermissionLevel) =>
147 setGrants((g) => g.map((grant, i) => (i === idx ? { ...grant, level } : grant)));
148
149 const addGrant = () => {
150 const opt = options.find((o) => o.type === newType && o.id === newId);
151 if (!opt) return;
152
153 if (grants.some((g) => g.granteeType === newType && g.granteeId === newId)) {
154 setGrants((g) => g.map((grant) => (grant.granteeType === newType && grant.granteeId === newId ? { ...grant, level: newLevel } : grant)));
155 return;
156 }
157 setGrants((g) => [...g, { granteeType: newType, granteeId: newId, level: newLevel }]);
158 setNewId("");
159 };
160
161 const granteeLabel = (g: FilePermissionGrantInput): string => {
162 const opt = options.find((o) => o.type === g.granteeType && o.id === g.granteeId);
163 return opt ? opt.label : g.granteeId;
164 };
165
166 return (
167 <Dialog
168 open={open}
169 onClose={onClose}
170 title="File permissions"
171 description={
172 <>
173 Explicit overrides for <span className="font-medium text-foreground">{fileName ?? "this file"}</span>.
174 No override = inherit workspace permissions. “No access” is an explicit deny.
175 </>
176 }
177 width="max-w-xl"
178 footer={
179 <>
180 <button
181 type="button"
182 onClick={onClose}
183 disabled={save.isPending}
184 className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium hover:bg-accent disabled:opacity-50"
185 >
186 Cancel
187 </button>
188 <button
189 type="button"
190 onClick={() => save.mutate(grants)}
191 disabled={save.isPending || !grantsLoaded}
192 className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
193 >
194 {save.isPending ? "Saving…" : "Save changes"}
195 </button>
196 </>
197 }
198 >
199 <div className="space-y-4">
200 {}
201 {!grantsLoaded && !grantsQuery.isError ? (
202 <p className="flex items-center gap-2 rounded-md border border-dashed border-border px-3 py-4 text-center text-sm text-muted-foreground">
203 <Loader2 className="size-4 animate-spin" aria-hidden />
204 Loading permissions…
205 </p>
206 ) : grantsLoaded && grants.length === 0 ? (
207 <p className="rounded-md border border-dashed border-border px-3 py-4 text-center text-sm text-muted-foreground">
208 No explicit grants — this file inherits workspace permissions.
209 </p>
210 ) : (
211 <ul className="space-y-2">
212 {grants.map((g, idx) => {
213 const denied = g.level === "NONE";
214 return (
215 <li key={`${g.granteeType}:${g.granteeId}`} className="flex items-center gap-2">
216 <span className="flex size-8 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
217 <Shield className="size-4" aria-hidden />
218 </span>
219 <span className="min-w-0 flex-1 truncate text-sm font-medium">
220 {granteeLabel(g)}
221 <span className="ml-1.5 text-xs font-normal text-muted-foreground">
222 ({g.granteeType === "role" ? "role" : g.granteeType === "team" ? "team" : "member"})
223 </span>
224 </span>
225 <select
226 value={g.level}
227 onChange={(e) => setLevel(idx, e.target.value as FilePermissionLevel)}
228 aria-label={`Permission level for ${granteeLabel(g)}`}
229 className={`rounded-md border px-2 py-1.5 text-sm ${denied ? "border-red-300 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300" : "border-border bg-background"}`}
230 >
231 {LEVELS.map((l) => (
232 <option key={l.value} value={l.value}>
233 {l.label}
234 </option>
235 ))}
236 </select>
237 <button
238 type="button"
239 onClick={() => removeGrant(idx)}
240 aria-label={`Remove grant for ${granteeLabel(g)}`}
241 className="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
242 >
243 <Trash2 className="size-4" aria-hidden />
244 </button>
245 </li>
246 );
247 })}
248 </ul>
249 )}
250
251 {}
252 <div className="rounded-md border border-border p-3">
253 <p className="mb-2 text-sm font-medium">Add an override</p>
254 <div className="flex flex-wrap items-center gap-2">
255 <select
256 value={newType}
257 onChange={(e) => {
258 setNewType(e.target.value as GranteeType);
259 setNewId("");
260 }}
261 aria-label="Grantee type"
262 className="rounded-md border border-border bg-background px-2 py-1.5 text-sm"
263 >
264 <option value="user">Member</option>
265 <option value="team">Team</option>
266 <option value="role">Role</option>
267 </select>
268 <select
269 value={newId}
270 onChange={(e) => setNewId(e.target.value)}
271 aria-label="Grantee"
272 className="min-w-40 flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm"
273 >
274 <option value="" disabled>
275 {members.isLoading || teams.isLoading ? "Loading…" : "Choose…"}
276 </option>
277 {options
278 .filter((o) => o.type === newType)
279 .map((o) => (
280 <option key={`${o.type}:${o.id}`} value={o.id}>
281 {o.label}
282 {o.sub ? ` — ${o.sub}` : ""}
283 </option>
284 ))}
285 </select>
286 <select
287 value={newLevel}
288 onChange={(e) => setNewLevel(e.target.value as FilePermissionLevel)}
289 aria-label="Permission level"
290 className="rounded-md border border-border bg-background px-2 py-1.5 text-sm"
291 >
292 {LEVELS.map((l) => (
293 <option key={l.value} value={l.value}>
294 {l.label}
295 </option>
296 ))}
297 </select>
298 <button
299 type="button"
300 onClick={addGrant}
301 disabled={!newId || !grantsLoaded || save.isPending}
302 className="flex items-center gap-1 rounded-md border px-2.5 py-1.5 text-sm font-medium hover:bg-muted disabled:opacity-50"
303 >
304 <Plus className="size-3.5" aria-hidden />
305 Add
306 </button>
307 </div>
308 {(() => {
309 const lvl = LEVELS.find((l) => l.value === newLevel);
310 return lvl ? <p className="mt-1.5 text-[11px] text-muted-foreground">{lvl.description}</p> : null;
311 })()}
312 </div>
313
314 {}
315 {grants.length > 0 && (
316 <div className="flex items-center justify-between gap-2">
317 <p className="text-[11px] text-muted-foreground">
318 Removing all overrides restores inherited permissions.
319 </p>
320 <button
321 type="button"
322 onClick={() => reset.mutate()}
323 disabled={reset.isPending || save.isPending}
324 className="shrink-0 rounded-md border border-red-300 px-2.5 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950"
325 >
326 {reset.isPending ? "Working…" : "Remove all overrides"}
327 </button>
328 </div>
329 )}
330
331 {grantsQuery.isError && (
332 <p className="text-sm text-red-600 dark:text-red-400">
333 Could not load permissions ({grantsQuery.error instanceof Error ? grantsQuery.error.message : "unknown"}).
334 You may not have edit access to this file.
335 </p>
336 )}
337 {save.isPending && (
338 <p className="flex items-center gap-2 text-sm text-muted-foreground">
339 <Loader2 className="size-4 animate-spin" aria-hidden /> Saving…
340 </p>
341 )}
342 </div>
343 </Dialog>
344 );
345}
346