@burdenoff/microfe-assethandler 2026.1110.1 → 2026.1111.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assethandler/components/AttachmentsPanel.d.ts +23 -0
- package/dist/assethandler/components/AttachmentsPanel.js +189 -111
- package/dist/assethandler/components/AttachmentsPanel.js.map +1 -1
- package/dist/assethandler/utils/graphqlErrors.d.ts +25 -0
- package/dist/assethandler/utils/graphqlErrors.js +37 -0
- package/dist/assethandler/utils/graphqlErrors.js.map +1 -0
- package/dist/test/mocks/fe-libs-ui.d.ts +1 -0
- package/package.json +1 -1
|
@@ -5,6 +5,29 @@
|
|
|
5
5
|
* `initiateUpload` → presigned PUT → `completeUpload` flow, and mints public
|
|
6
6
|
* share links via `createFileShareLink`. All entries are live data.
|
|
7
7
|
*
|
|
8
|
+
* Sharing follows the files service's rules, which depend on visibility:
|
|
9
|
+
*
|
|
10
|
+
* - PRIVATE and TEAM files can only be shared by their uploader or someone
|
|
11
|
+
* holding a SHARE/MANAGE grant or a MANAGER share. For these the panel asks
|
|
12
|
+
* `getUserFilePermissions(fileId).canShare` before minting a link. The check
|
|
13
|
+
* runs lazily (on each desktop click and each time the mobile menu opens), so
|
|
14
|
+
* a long file list never fans out into one permission query per row.
|
|
15
|
+
* - WORKSPACE and PUBLIC files are governed only by the gateway's
|
|
16
|
+
* `@rbac(share, file)` check. `canShare` reflects explicit grants alone for
|
|
17
|
+
* these files, so it is not consulted: the panel calls `createFileShareLink`
|
|
18
|
+
* directly and treats its FORBIDDEN as the denial.
|
|
19
|
+
*
|
|
20
|
+
* The two denials arrive differently and get different copy. The gateway's
|
|
21
|
+
* role check answers HTTP 403 (an Apollo `ServerError`), so the caller is told
|
|
22
|
+
* their workspace role cannot share. The service's per-file rule answers HTTP
|
|
23
|
+
* 200 with a `FORBIDDEN` GraphQL error, so the caller is told who can share
|
|
24
|
+
* the file. A 503 `RBAC_SERVICE_UNAVAILABLE` is an outage, not a denial.
|
|
25
|
+
*
|
|
26
|
+
* `getUserFilePermissions` needs its own `@rbac(read, file-permission)`, which
|
|
27
|
+
* some roles lack. A FORBIDDEN from that query, from either source, means
|
|
28
|
+
* "unknown", never "cannot share": the panel falls through and lets the share
|
|
29
|
+
* mutation decide.
|
|
30
|
+
*
|
|
8
31
|
* `entityType`/`entityId` scope the panel to the record it is mounted on and
|
|
9
32
|
* are used for context labelling; uploaded files land in the workspace file
|
|
10
33
|
* store so they can be shared and reused.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { notify as e } from "../utils/toast.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import { getForbiddenSource as t, isForbiddenError as n } from "../utils/graphqlErrors.js";
|
|
3
|
+
import { useCallback as r, useMemo as i, useRef as a, useState as o } from "react";
|
|
4
|
+
import { Download as s, File as c, FileArchive as l, FileAudio as u, FileCode2 as d, FileSpreadsheet as f, FileText as p, FileVideo as m, Image as h, Link2 as g, Loader2 as _, Lock as v, MoreHorizontal as y, Paperclip as b, Upload as ee } from "lucide-react";
|
|
5
|
+
import { gql as x } from "@apollo/client";
|
|
6
|
+
import { useApolloClient as te, useMutation as S, useQuery as C } from "@apollo/client/react";
|
|
7
|
+
import { jsx as w, jsxs as T } from "react/jsx-runtime";
|
|
8
|
+
import { Button as ne, Card as E, CardContent as D, CardHeader as O, CardTitle as k, DropdownMenu as A, DropdownMenuContent as j, DropdownMenuItem as M, DropdownMenuTrigger as re, IllustratedEmptyState as ie } from "@burdenoff/fe-libs/ui";
|
|
8
9
|
// src/assethandler/components/AttachmentsPanel.tsx
|
|
9
|
-
var
|
|
10
|
+
var ae = x`
|
|
10
11
|
query AssetHandlerGetFiles($pagination: PaginationInput, $sort: FileSortInput) {
|
|
11
12
|
getFiles(pagination: $pagination, sort: $sort) {
|
|
12
13
|
total
|
|
@@ -19,10 +20,11 @@ var N = v`
|
|
|
19
20
|
size
|
|
20
21
|
downloadUrl
|
|
21
22
|
createdAt
|
|
23
|
+
visibility
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
|
-
`,
|
|
27
|
+
`, oe = x`
|
|
26
28
|
mutation AssetHandlerInitiateUpload($input: InitiateUploadInput!) {
|
|
27
29
|
initiateUpload(input: $input) {
|
|
28
30
|
uploadUrl
|
|
@@ -31,7 +33,7 @@ var N = v`
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
|
-
`,
|
|
36
|
+
`, se = x`
|
|
35
37
|
mutation AssetHandlerCompleteUpload($input: CompleteUploadInput!) {
|
|
36
38
|
completeUpload(input: $input) {
|
|
37
39
|
fileId
|
|
@@ -41,15 +43,40 @@ var N = v`
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
|
-
`,
|
|
46
|
+
`, ce = x`
|
|
45
47
|
mutation AssetHandlerCreateFileShareLink($input: CreateShareGrantLinkInput!) {
|
|
46
48
|
createFileShareLink(input: $input) {
|
|
47
49
|
id
|
|
48
50
|
shareUrl
|
|
49
51
|
}
|
|
50
52
|
}
|
|
53
|
+
`, N = x`
|
|
54
|
+
query AssetHandlerUserFilePermissions($fileId: ID!) {
|
|
55
|
+
getUserFilePermissions(fileId: $fileId) {
|
|
56
|
+
fileId
|
|
57
|
+
canShare
|
|
58
|
+
}
|
|
59
|
+
}
|
|
51
60
|
`;
|
|
52
|
-
function
|
|
61
|
+
function P(e) {
|
|
62
|
+
return e === "PRIVATE" || e === "TEAM";
|
|
63
|
+
}
|
|
64
|
+
var F = "You can't share this file", I = "Only the uploader, or someone with share or manage access to this file, can create a share link. Ask one of them to share it or to grant you access.", L = "Your workspace role doesn't allow sharing files. Ask a workspace admin for share access.", R = "You don't have permission to share this file. Ask the file's uploader or a workspace admin.";
|
|
65
|
+
function z(e) {
|
|
66
|
+
return e === "denied-role" || e === "denied-restricted" || e === "denied-other";
|
|
67
|
+
}
|
|
68
|
+
function B(e) {
|
|
69
|
+
switch (e) {
|
|
70
|
+
case "denied-role": return L;
|
|
71
|
+
case "denied-restricted": return I;
|
|
72
|
+
case "denied-other": return R;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function V(e, n) {
|
|
76
|
+
let r = t(e);
|
|
77
|
+
return r === "gateway" ? "denied-role" : r === "service" ? P(n) ? "denied-restricted" : "denied-other" : null;
|
|
78
|
+
}
|
|
79
|
+
function H(e) {
|
|
53
80
|
if (!e) return "0 B";
|
|
54
81
|
let t = [
|
|
55
82
|
"B",
|
|
@@ -59,11 +86,11 @@ function L(e) {
|
|
|
59
86
|
], n = Math.min(Math.floor(Math.log(e) / Math.log(1024)), t.length - 1);
|
|
60
87
|
return `${(e / 1024 ** n).toFixed(n === 0 ? 0 : 1)} ${t[n]}`;
|
|
61
88
|
}
|
|
62
|
-
function
|
|
89
|
+
function U(e) {
|
|
63
90
|
let t = e.lastIndexOf(".");
|
|
64
91
|
return t >= 0 ? e.slice(t + 1).toLowerCase() : "bin";
|
|
65
92
|
}
|
|
66
|
-
function
|
|
93
|
+
function W(e) {
|
|
67
94
|
let t = new Date(e).getTime();
|
|
68
95
|
if (Number.isNaN(t)) return "";
|
|
69
96
|
let n = Date.now() - t, r = Math.round(n / 1e3);
|
|
@@ -75,8 +102,8 @@ function z(e) {
|
|
|
75
102
|
let o = Math.round(a / 24);
|
|
76
103
|
return o < 30 ? `${o} day${o === 1 ? "" : "s"} ago` : new Date(e).toLocaleDateString();
|
|
77
104
|
}
|
|
78
|
-
function
|
|
79
|
-
let t = (e.mimeType || "").toLowerCase(), n = (e.extension ||
|
|
105
|
+
function G(e) {
|
|
106
|
+
let t = (e.mimeType || "").toLowerCase(), n = (e.extension || U(e.name)).toLowerCase();
|
|
80
107
|
return t.startsWith("image/") || [
|
|
81
108
|
"png",
|
|
82
109
|
"jpg",
|
|
@@ -135,68 +162,68 @@ function B(e) {
|
|
|
135
162
|
"pages"
|
|
136
163
|
].includes(n) ? "doc" : "other";
|
|
137
164
|
}
|
|
138
|
-
var
|
|
165
|
+
var K = {
|
|
139
166
|
image: {
|
|
140
|
-
Icon:
|
|
167
|
+
Icon: h,
|
|
141
168
|
iconClass: "text-status-success-text",
|
|
142
169
|
tileClass: "bg-status-success-bg-subtle"
|
|
143
170
|
},
|
|
144
171
|
pdf: {
|
|
145
|
-
Icon:
|
|
172
|
+
Icon: p,
|
|
146
173
|
iconClass: "text-status-error-text",
|
|
147
174
|
tileClass: "bg-status-error-bg-subtle"
|
|
148
175
|
},
|
|
149
176
|
sheet: {
|
|
150
|
-
Icon:
|
|
177
|
+
Icon: f,
|
|
151
178
|
iconClass: "text-status-success-text",
|
|
152
179
|
tileClass: "bg-status-success-bg-subtle"
|
|
153
180
|
},
|
|
154
181
|
doc: {
|
|
155
|
-
Icon:
|
|
182
|
+
Icon: p,
|
|
156
183
|
iconClass: "text-status-info-text",
|
|
157
184
|
tileClass: "bg-status-info-bg-subtle"
|
|
158
185
|
},
|
|
159
186
|
archive: {
|
|
160
|
-
Icon:
|
|
187
|
+
Icon: l,
|
|
161
188
|
iconClass: "text-status-warning-text",
|
|
162
189
|
tileClass: "bg-status-warning-bg-subtle"
|
|
163
190
|
},
|
|
164
191
|
code: {
|
|
165
|
-
Icon:
|
|
192
|
+
Icon: d,
|
|
166
193
|
iconClass: "text-status-info-text",
|
|
167
194
|
tileClass: "bg-status-info-bg-subtle"
|
|
168
195
|
},
|
|
169
196
|
video: {
|
|
170
|
-
Icon:
|
|
197
|
+
Icon: m,
|
|
171
198
|
iconClass: "text-status-info-text",
|
|
172
199
|
tileClass: "bg-status-info-bg-subtle"
|
|
173
200
|
},
|
|
174
201
|
audio: {
|
|
175
|
-
Icon:
|
|
202
|
+
Icon: u,
|
|
176
203
|
iconClass: "text-status-warning-text",
|
|
177
204
|
tileClass: "bg-status-warning-bg-subtle"
|
|
178
205
|
},
|
|
179
206
|
other: {
|
|
180
|
-
Icon:
|
|
207
|
+
Icon: c,
|
|
181
208
|
iconClass: "text-text-secondary",
|
|
182
209
|
tileClass: "bg-bg-sunken"
|
|
183
210
|
}
|
|
184
211
|
};
|
|
185
|
-
function
|
|
186
|
-
let t =
|
|
187
|
-
return t === "image" && e.downloadUrl ? /* @__PURE__ */
|
|
212
|
+
function le({ file: e }) {
|
|
213
|
+
let t = G(e), { Icon: n, iconClass: r, tileClass: i } = K[t];
|
|
214
|
+
return t === "image" && e.downloadUrl ? /* @__PURE__ */ w("img", {
|
|
188
215
|
src: e.downloadUrl,
|
|
189
216
|
alt: "",
|
|
190
217
|
loading: "lazy",
|
|
191
218
|
className: "h-10 w-10 shrink-0 rounded-lg border border-border-subtle object-cover"
|
|
192
|
-
}) : /* @__PURE__ */
|
|
219
|
+
}) : /* @__PURE__ */ w("span", {
|
|
193
220
|
className: `flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${i}`,
|
|
194
221
|
"aria-hidden": "true",
|
|
195
|
-
children: /* @__PURE__ */
|
|
222
|
+
children: /* @__PURE__ */ w(n, { className: `h-5 w-5 ${r}` })
|
|
196
223
|
});
|
|
197
224
|
}
|
|
198
|
-
function
|
|
199
|
-
let
|
|
225
|
+
function q({ entityType: t, entityId: c }) {
|
|
226
|
+
let l = a(null), [u, d] = o(!1), [f, p] = o(null), [m, h] = o({}), x = te(), { data: I, loading: L, refetch: R } = C(ae, {
|
|
200
227
|
variables: {
|
|
201
228
|
pagination: {
|
|
202
229
|
limit: 25,
|
|
@@ -209,14 +236,14 @@ function U({ entityType: a, entityId: o }) {
|
|
|
209
236
|
},
|
|
210
237
|
fetchPolicy: "cache-and-network",
|
|
211
238
|
errorPolicy: "all"
|
|
212
|
-
}), [
|
|
239
|
+
}), [G] = S(oe), [K] = S(se), [q] = S(ce), J = i(() => I?.getFiles.files ?? [], [I]), ue = async (t) => {
|
|
213
240
|
let n = t.target.files?.[0];
|
|
214
241
|
if (t.target.value = "", n) {
|
|
215
|
-
|
|
242
|
+
d(!0);
|
|
216
243
|
try {
|
|
217
|
-
let t = await
|
|
244
|
+
let t = await G({ variables: { input: {
|
|
218
245
|
fileName: n.name,
|
|
219
|
-
extension:
|
|
246
|
+
extension: U(n.name),
|
|
220
247
|
fileSize: n.size,
|
|
221
248
|
mimeType: n.type || "application/octet-stream",
|
|
222
249
|
visibility: "WORKSPACE"
|
|
@@ -228,150 +255,201 @@ function U({ entityType: a, entityId: o }) {
|
|
|
228
255
|
headers: { "Content-Type": n.type || "application/octet-stream" }
|
|
229
256
|
});
|
|
230
257
|
if (!a.ok) throw Error(`Storage rejected the upload (HTTP ${a.status}).`);
|
|
231
|
-
await
|
|
258
|
+
await K({ variables: { input: {
|
|
232
259
|
sessionId: i,
|
|
233
260
|
fileSize: n.size
|
|
234
|
-
} } }), e.success("File uploaded", `${n.name} is now available on this record.`), await
|
|
261
|
+
} } }), e.success("File uploaded", `${n.name} is now available on this record.`), await R();
|
|
235
262
|
} catch (t) {
|
|
236
263
|
e.error("Upload failed", t instanceof Error ? t.message : "The file could not be uploaded.");
|
|
237
264
|
} finally {
|
|
238
|
-
|
|
265
|
+
d(!1);
|
|
239
266
|
}
|
|
240
267
|
}
|
|
241
|
-
},
|
|
242
|
-
|
|
268
|
+
}, Y = r((e, t) => {
|
|
269
|
+
h((n) => {
|
|
270
|
+
let r = { ...n };
|
|
271
|
+
return t ? r[e] = t : delete r[e], r;
|
|
272
|
+
});
|
|
273
|
+
}, []), X = r(async (e) => {
|
|
274
|
+
Y(e, "checking");
|
|
275
|
+
try {
|
|
276
|
+
let t = (await x.query({
|
|
277
|
+
query: N,
|
|
278
|
+
variables: { fileId: e },
|
|
279
|
+
fetchPolicy: "network-only"
|
|
280
|
+
})).data?.getUserFilePermissions.canShare === !0 ? "allowed" : "denied-restricted";
|
|
281
|
+
return Y(e, t), t;
|
|
282
|
+
} catch (t) {
|
|
283
|
+
return n(t) ? (Y(e, "unverified"), "unverified") : (Y(e, null), "failed");
|
|
284
|
+
}
|
|
285
|
+
}, [x, Y]), de = (e, t) => {
|
|
286
|
+
t && (P(e.visibility) ? m[e.id] !== "checking" && X(e.id) : z(m[e.id]) && Y(e.id, null));
|
|
287
|
+
}, Z = async (t, { recheck: n }) => {
|
|
288
|
+
let r = t.id;
|
|
289
|
+
p(r);
|
|
243
290
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
291
|
+
if (P(t.visibility)) {
|
|
292
|
+
let t = m[r], i = !n && (t === "allowed" || t === "unverified") ? t : await X(r);
|
|
293
|
+
if (i === "failed") throw Error("Your share access could not be checked. Please try again.");
|
|
294
|
+
if (i === "denied-restricted") {
|
|
295
|
+
e.error(F, B(i));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
let i = (await q({ variables: { input: {
|
|
300
|
+
resourceId: r,
|
|
246
301
|
role: "VIEWER"
|
|
247
302
|
} } })).data?.createFileShareLink.shareUrl;
|
|
248
|
-
if (!
|
|
249
|
-
navigator.clipboard?.writeText ? (await navigator.clipboard.writeText(
|
|
250
|
-
} catch (
|
|
251
|
-
|
|
303
|
+
if (!i) throw Error("No share link was returned.");
|
|
304
|
+
Y(r, "allowed"), navigator.clipboard?.writeText ? (await navigator.clipboard.writeText(i), e.success("Share link copied", "A view-only link is on your clipboard.")) : e.info("Share link ready", i);
|
|
305
|
+
} catch (n) {
|
|
306
|
+
let i = V(n, t.visibility);
|
|
307
|
+
if (i) {
|
|
308
|
+
Y(r, i), e.error(F, B(i));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
e.error("Could not create share link", n instanceof Error ? n.message : "Please try again.");
|
|
252
312
|
} finally {
|
|
253
|
-
|
|
313
|
+
p(null);
|
|
254
314
|
}
|
|
255
|
-
},
|
|
256
|
-
return /* @__PURE__ */
|
|
315
|
+
}, Q = I?.getFiles.total ?? 0, $ = J.length > 0;
|
|
316
|
+
return /* @__PURE__ */ T(E, { children: [/* @__PURE__ */ w(O, {
|
|
257
317
|
className: "flex flex-row items-center justify-between gap-2 space-y-0",
|
|
258
|
-
children: /* @__PURE__ */
|
|
318
|
+
children: /* @__PURE__ */ T(k, {
|
|
259
319
|
className: "flex items-center gap-2 text-base",
|
|
260
320
|
children: [
|
|
261
|
-
/* @__PURE__ */
|
|
321
|
+
/* @__PURE__ */ w(b, { className: "h-4 w-4" }),
|
|
262
322
|
"Attachments",
|
|
263
|
-
|
|
323
|
+
Q ? /* @__PURE__ */ T("span", {
|
|
264
324
|
className: "text-sm font-normal text-text-secondary",
|
|
265
325
|
children: [
|
|
266
|
-
|
|
326
|
+
Q,
|
|
267
327
|
" file",
|
|
268
|
-
|
|
328
|
+
Q === 1 ? "" : "s"
|
|
269
329
|
]
|
|
270
330
|
}) : null
|
|
271
331
|
]
|
|
272
332
|
})
|
|
273
|
-
}), /* @__PURE__ */
|
|
333
|
+
}), /* @__PURE__ */ T(D, {
|
|
274
334
|
className: "space-y-3",
|
|
275
335
|
children: [
|
|
276
|
-
/* @__PURE__ */
|
|
277
|
-
ref:
|
|
336
|
+
/* @__PURE__ */ w("input", {
|
|
337
|
+
ref: l,
|
|
278
338
|
type: "file",
|
|
279
339
|
className: "hidden",
|
|
280
|
-
onChange: (e) => void
|
|
281
|
-
"aria-label": `Upload a file for ${
|
|
340
|
+
onChange: (e) => void ue(e),
|
|
341
|
+
"aria-label": `Upload a file for ${t} ${c}`
|
|
282
342
|
}),
|
|
283
|
-
|
|
343
|
+
L && !$ ? /* @__PURE__ */ w("div", {
|
|
284
344
|
className: "space-y-2",
|
|
285
|
-
children: Array.from({ length: 3 }).map((e, t) => /* @__PURE__ */
|
|
286
|
-
}) :
|
|
345
|
+
children: Array.from({ length: 3 }).map((e, t) => /* @__PURE__ */ w("div", { className: "h-14 rounded-lg bg-bg-sunken motion-safe:animate-vc-shimmer" }, t))
|
|
346
|
+
}) : $ ? /* @__PURE__ */ w("ul", {
|
|
287
347
|
className: "space-y-1.5",
|
|
288
|
-
children:
|
|
289
|
-
let t =
|
|
290
|
-
return /* @__PURE__ */
|
|
348
|
+
children: J.map((e) => {
|
|
349
|
+
let t = f === e.id, n = m[e.id], r = n === "checking", i = z(n) ? n : null, a = i !== null;
|
|
350
|
+
return /* @__PURE__ */ T("li", {
|
|
291
351
|
className: "flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-surface p-2.5",
|
|
292
352
|
children: [
|
|
293
|
-
/* @__PURE__ */
|
|
294
|
-
/* @__PURE__ */
|
|
353
|
+
/* @__PURE__ */ w(le, { file: e }),
|
|
354
|
+
/* @__PURE__ */ T("div", {
|
|
295
355
|
className: "min-w-0 flex-1",
|
|
296
|
-
children: [/* @__PURE__ */
|
|
356
|
+
children: [/* @__PURE__ */ w("p", {
|
|
297
357
|
className: "truncate text-sm font-medium",
|
|
298
358
|
title: e.name,
|
|
299
359
|
children: e.name
|
|
300
|
-
}), /* @__PURE__ */
|
|
360
|
+
}), /* @__PURE__ */ T("p", {
|
|
301
361
|
className: "truncate text-xs text-text-secondary",
|
|
302
362
|
children: [
|
|
303
|
-
|
|
363
|
+
H(Number(e.size)),
|
|
304
364
|
" · ",
|
|
305
|
-
|
|
365
|
+
W(e.createdAt)
|
|
306
366
|
]
|
|
307
367
|
})]
|
|
308
368
|
}),
|
|
309
|
-
/* @__PURE__ */
|
|
369
|
+
/* @__PURE__ */ T("div", {
|
|
310
370
|
className: "hidden items-center gap-1 sm:flex",
|
|
311
|
-
children: [e.downloadUrl ? /* @__PURE__ */
|
|
371
|
+
children: [e.downloadUrl ? /* @__PURE__ */ w("a", {
|
|
312
372
|
href: e.downloadUrl,
|
|
313
373
|
target: "_blank",
|
|
314
374
|
rel: "noreferrer",
|
|
315
375
|
className: "rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary",
|
|
316
376
|
"aria-label": `Download ${e.name}`,
|
|
317
377
|
title: "Download",
|
|
318
|
-
children: /* @__PURE__ */
|
|
319
|
-
}) : null, /* @__PURE__ */
|
|
378
|
+
children: /* @__PURE__ */ w(s, { className: "h-4 w-4" })
|
|
379
|
+
}) : null, /* @__PURE__ */ w("button", {
|
|
320
380
|
type: "button",
|
|
321
|
-
disabled: t,
|
|
322
|
-
|
|
323
|
-
|
|
381
|
+
disabled: t || r,
|
|
382
|
+
"aria-disabled": a ? !0 : void 0,
|
|
383
|
+
"data-share-state": a ? "denied" : void 0,
|
|
384
|
+
onClick: () => void Z(e, { recheck: !0 }),
|
|
385
|
+
className: `rounded-md p-1.5 transition-colors disabled:opacity-60 ${a ? "cursor-not-allowed text-text-tertiary opacity-60" : "text-text-secondary hover:bg-bg-sunken hover:text-text-primary"}`,
|
|
324
386
|
"aria-label": `Copy share link for ${e.name}`,
|
|
325
|
-
title: "Copy share link",
|
|
326
|
-
children: t ? /* @__PURE__ */
|
|
387
|
+
title: i ? `${F}. ${B(i)}` : "Copy share link",
|
|
388
|
+
children: t || r ? /* @__PURE__ */ w(_, { className: "h-4 w-4 animate-spin" }) : a ? /* @__PURE__ */ w(v, {
|
|
389
|
+
className: "h-4 w-4",
|
|
390
|
+
"aria-hidden": "true"
|
|
391
|
+
}) : /* @__PURE__ */ w(g, { className: "h-4 w-4" })
|
|
327
392
|
})]
|
|
328
393
|
}),
|
|
329
|
-
/* @__PURE__ */
|
|
394
|
+
/* @__PURE__ */ w("div", {
|
|
330
395
|
className: "sm:hidden",
|
|
331
|
-
children: /* @__PURE__ */
|
|
332
|
-
|
|
333
|
-
children: /* @__PURE__ */
|
|
334
|
-
type: "button",
|
|
335
|
-
className: "rounded-md p-1.5 text-text-secondary hover:bg-bg-sunken hover:text-text-primary",
|
|
336
|
-
"aria-label": `Actions for ${e.name}`,
|
|
337
|
-
children: /* @__PURE__ */ x(h, { className: "h-4 w-4" })
|
|
338
|
-
})
|
|
339
|
-
}), /* @__PURE__ */ S(k, {
|
|
340
|
-
align: "end",
|
|
341
|
-
children: [e.downloadUrl ? /* @__PURE__ */ x(A, {
|
|
396
|
+
children: /* @__PURE__ */ T(A, {
|
|
397
|
+
onOpenChange: (t) => de(e, t),
|
|
398
|
+
children: [/* @__PURE__ */ w(re, {
|
|
342
399
|
asChild: !0,
|
|
343
|
-
children: /* @__PURE__ */
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
children:
|
|
400
|
+
children: /* @__PURE__ */ w("button", {
|
|
401
|
+
type: "button",
|
|
402
|
+
className: "rounded-md p-1.5 text-text-secondary hover:bg-bg-sunken hover:text-text-primary",
|
|
403
|
+
"aria-label": `Actions for ${e.name}`,
|
|
404
|
+
children: /* @__PURE__ */ w(y, { className: "h-4 w-4" })
|
|
348
405
|
})
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
406
|
+
}), /* @__PURE__ */ T(j, {
|
|
407
|
+
align: "end",
|
|
408
|
+
children: [
|
|
409
|
+
e.downloadUrl ? /* @__PURE__ */ w(M, {
|
|
410
|
+
asChild: !0,
|
|
411
|
+
children: /* @__PURE__ */ T("a", {
|
|
412
|
+
href: e.downloadUrl,
|
|
413
|
+
target: "_blank",
|
|
414
|
+
rel: "noreferrer",
|
|
415
|
+
children: [/* @__PURE__ */ w(s, { className: "mr-2 h-4 w-4" }), "Download"]
|
|
416
|
+
})
|
|
417
|
+
}) : null,
|
|
418
|
+
/* @__PURE__ */ T(M, {
|
|
419
|
+
disabled: t || r || a,
|
|
420
|
+
onSelect: (t) => {
|
|
421
|
+
t.preventDefault(), Z(e, { recheck: !1 });
|
|
422
|
+
},
|
|
423
|
+
children: [r ? /* @__PURE__ */ w(_, { className: "mr-2 h-4 w-4 animate-spin" }) : a ? /* @__PURE__ */ w(v, {
|
|
424
|
+
className: "mr-2 h-4 w-4",
|
|
425
|
+
"aria-hidden": "true"
|
|
426
|
+
}) : /* @__PURE__ */ w(g, { className: "mr-2 h-4 w-4" }), r ? "Checking share access…" : a ? "No share access" : "Copy share link"]
|
|
427
|
+
}),
|
|
428
|
+
i ? /* @__PURE__ */ w("p", {
|
|
429
|
+
className: "max-w-56 px-2 py-1.5 text-xs text-text-secondary",
|
|
430
|
+
children: B(i)
|
|
431
|
+
}) : null
|
|
432
|
+
]
|
|
355
433
|
})]
|
|
356
|
-
})
|
|
434
|
+
})
|
|
357
435
|
})
|
|
358
436
|
]
|
|
359
437
|
}, e.id);
|
|
360
438
|
})
|
|
361
|
-
}) : /* @__PURE__ */
|
|
439
|
+
}) : /* @__PURE__ */ w(ie, {
|
|
362
440
|
illustration: "empty-data",
|
|
363
441
|
title: "No files yet",
|
|
364
442
|
description: "Upload photos, receipts, warranties or documents to keep them with this record."
|
|
365
443
|
}),
|
|
366
|
-
/* @__PURE__ */
|
|
444
|
+
/* @__PURE__ */ T(ne, {
|
|
367
445
|
className: "w-full",
|
|
368
|
-
disabled:
|
|
369
|
-
onClick: () =>
|
|
370
|
-
children: [
|
|
446
|
+
disabled: u,
|
|
447
|
+
onClick: () => l.current?.click(),
|
|
448
|
+
children: [u ? /* @__PURE__ */ w(_, { className: "mr-2 h-4 w-4 animate-spin" }) : /* @__PURE__ */ w(ee, { className: "mr-2 h-4 w-4" }), u ? "Uploading…" : "Upload files"]
|
|
371
449
|
})
|
|
372
450
|
]
|
|
373
451
|
})] });
|
|
374
452
|
}
|
|
375
|
-
export {
|
|
453
|
+
export { q as AttachmentsPanel };
|
|
376
454
|
|
|
377
455
|
//# sourceMappingURL=AttachmentsPanel.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AttachmentsPanel.js","names":["ImageIcon","FileIcon","GET_FILES_QUERY","INITIATE_UPLOAD_MUTATION","COMPLETE_UPLOAD_MUTATION","CREATE_SHARE_LINK_MUTATION","formatSize","bytes","units","exponent","extensionOf","dot","fileName","relativeTime","then","iso","diff","sec","min","hr","day","kindOf","mime","file","ext","KIND_STYLE","Image","FileText","FileSpreadsheet","FileArchive","FileCode2","FileVideo","FileAudio","File","FileBadge","kind","tileClass","Icon","iconClass","AttachmentsPanel","fileInputRef","useRef","uploading","setUploading","useState","sharingId","setSharingId","useQuery","initiateUpload","useMutation","completeUpload","createShareLink","files","useMemo","data","handleFileSelected","event","initiated","uploadUrl","sessionId","putResponse","notify","refetch","error","handleShare","fileId","url","total","hasFiles","Card","CardHeader","CardTitle","Paperclip","CardContent","entityType","entityId","loading","i","isSharing","Download","Loader2","Link2","DropdownMenu","DropdownMenuTrigger","MoreHorizontal","DropdownMenuContent","DropdownMenuItem","e","IllustratedEmptyState","Button","Upload"],"sources":["../../../src/assethandler/components/AttachmentsPanel.tsx"],"sourcesContent":["import { useMemo, useRef, useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport {\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n IllustratedEmptyState,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from '@burdenoff/fe-libs/ui';\nimport {\n Paperclip,\n Upload,\n Link2,\n FileText,\n FileSpreadsheet,\n FileArchive,\n FileCode2,\n FileVideo,\n FileAudio,\n Image as ImageIcon,\n File as FileIcon,\n Loader2,\n Download,\n MoreHorizontal,\n} from 'lucide-react';\nimport { notify } from '../utils/toast';\n\n/**\n * Attach and reference files for an AssetHandler entity, backed by the\n * federated wspace-files-svc (same wspace gateway/Apollo client). Fully real (no fabricated data) —\n * lists workspace files via `getFiles`, uploads new files through the\n * `initiateUpload` → presigned PUT → `completeUpload` flow, and mints public\n * share links via `createFileShareLink`. All entries are live data.\n *\n * `entityType`/`entityId` scope the panel to the record it is mounted on and\n * are used for context labelling; uploaded files land in the workspace file\n * store so they can be shared and reused.\n */\nexport interface AttachmentsPanelProps {\n entityType: string;\n entityId: string;\n}\n\ninterface FileNode {\n id: string;\n name: string;\n extension: string;\n mimeType: string;\n size: number;\n downloadUrl?: string | null;\n createdAt: string;\n}\n\ninterface GetFilesResult {\n getFiles: {\n files: FileNode[];\n total: number;\n hasMore: boolean;\n };\n}\n\nconst GET_FILES_QUERY = gql`\n query AssetHandlerGetFiles($pagination: PaginationInput, $sort: FileSortInput) {\n getFiles(pagination: $pagination, sort: $sort) {\n total\n hasMore\n files {\n id\n name\n extension\n mimeType\n size\n downloadUrl\n createdAt\n }\n }\n }\n`;\n\nconst INITIATE_UPLOAD_MUTATION = gql`\n mutation AssetHandlerInitiateUpload($input: InitiateUploadInput!) {\n initiateUpload(input: $input) {\n uploadUrl\n session {\n id\n }\n }\n }\n`;\n\nconst COMPLETE_UPLOAD_MUTATION = gql`\n mutation AssetHandlerCompleteUpload($input: CompleteUploadInput!) {\n completeUpload(input: $input) {\n fileId\n file {\n id\n name\n }\n }\n }\n`;\n\nconst CREATE_SHARE_LINK_MUTATION = gql`\n mutation AssetHandlerCreateFileShareLink($input: CreateShareGrantLinkInput!) {\n createFileShareLink(input: $input) {\n id\n shareUrl\n }\n }\n`;\n\nfunction formatSize(bytes: number): string {\n if (!bytes) return '0 B';\n const units = ['B', 'KB', 'MB', 'GB'];\n const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);\n const value = bytes / Math.pow(1024, exponent);\n return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;\n}\n\nfunction extensionOf(fileName: string): string {\n const dot = fileName.lastIndexOf('.');\n return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : 'bin';\n}\n\n/** Compact relative time (\"2 hrs ago\"), with the full timestamp on hover. */\nfunction relativeTime(iso: string): string {\n const then = new Date(iso).getTime();\n if (Number.isNaN(then)) return '';\n const diff = Date.now() - then;\n const sec = Math.round(diff / 1000);\n if (sec < 45) return 'just now';\n const min = Math.round(sec / 60);\n if (min < 60) return `${min} min ago`;\n const hr = Math.round(min / 60);\n if (hr < 24) return `${hr} hr${hr === 1 ? '' : 's'} ago`;\n const day = Math.round(hr / 24);\n if (day < 30) return `${day} day${day === 1 ? '' : 's'} ago`;\n return new Date(iso).toLocaleDateString();\n}\n\ntype FileKind =\n 'image' | 'pdf' | 'sheet' | 'doc' | 'archive' | 'code' | 'video' | 'audio' | 'other';\n\n/** Map a file's extension/mime to a recognisable kind for icon + tint. */\nfunction kindOf(file: FileNode): FileKind {\n const mime = (file.mimeType || '').toLowerCase();\n const ext = (file.extension || extensionOf(file.name)).toLowerCase();\n if (\n mime.startsWith('image/') ||\n ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp'].includes(ext)\n )\n return 'image';\n if (mime.startsWith('video/') || ['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext))\n return 'video';\n if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext))\n return 'audio';\n if (mime === 'application/pdf' || ext === 'pdf') return 'pdf';\n if (['xls', 'xlsx', 'csv', 'tsv', 'ods'].includes(ext)) return 'sheet';\n if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext)) return 'archive';\n if (\n [\n 'js',\n 'ts',\n 'tsx',\n 'jsx',\n 'json',\n 'xml',\n 'yml',\n 'yaml',\n 'html',\n 'css',\n 'sh',\n 'py',\n 'sql',\n ].includes(ext)\n )\n return 'code';\n if (['doc', 'docx', 'txt', 'md', 'rtf', 'odt', 'pages'].includes(ext)) return 'doc';\n return 'other';\n}\n\nconst KIND_STYLE: Record<\n FileKind,\n { Icon: typeof FileText; iconClass: string; tileClass: string }\n> = {\n image: {\n Icon: ImageIcon,\n iconClass: 'text-status-success-text',\n tileClass: 'bg-status-success-bg-subtle',\n },\n pdf: {\n Icon: FileText,\n iconClass: 'text-status-error-text',\n tileClass: 'bg-status-error-bg-subtle',\n },\n sheet: {\n Icon: FileSpreadsheet,\n iconClass: 'text-status-success-text',\n tileClass: 'bg-status-success-bg-subtle',\n },\n doc: {\n Icon: FileText,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n archive: {\n Icon: FileArchive,\n iconClass: 'text-status-warning-text',\n tileClass: 'bg-status-warning-bg-subtle',\n },\n code: {\n Icon: FileCode2,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n video: {\n Icon: FileVideo,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n audio: {\n Icon: FileAudio,\n iconClass: 'text-status-warning-text',\n tileClass: 'bg-status-warning-bg-subtle',\n },\n other: { Icon: FileIcon, iconClass: 'text-text-secondary', tileClass: 'bg-bg-sunken' },\n};\n\n/** File-type badge tile — an inline thumbnail for images, a tinted glyph otherwise. */\nfunction FileBadge({ file }: { file: FileNode }) {\n const kind = kindOf(file);\n const { Icon, iconClass, tileClass } = KIND_STYLE[kind];\n if (kind === 'image' && file.downloadUrl) {\n return (\n <img\n src={file.downloadUrl}\n alt=\"\"\n loading=\"lazy\"\n className=\"h-10 w-10 shrink-0 rounded-lg border border-border-subtle object-cover\"\n />\n );\n }\n return (\n <span\n className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${tileClass}`}\n aria-hidden=\"true\"\n >\n <Icon className={`h-5 w-5 ${iconClass}`} />\n </span>\n );\n}\n\nexport function AttachmentsPanel({ entityType, entityId }: AttachmentsPanelProps) {\n const fileInputRef = useRef<HTMLInputElement>(null);\n const [uploading, setUploading] = useState(false);\n const [sharingId, setSharingId] = useState<string | null>(null);\n\n const { data, loading, refetch } = useQuery<GetFilesResult>(GET_FILES_QUERY, {\n variables: {\n pagination: { limit: 25, offset: 0 },\n sort: { field: 'CREATED_AT', order: 'DESC' },\n },\n fetchPolicy: 'cache-and-network',\n errorPolicy: 'all',\n });\n\n const [initiateUpload] = useMutation<{\n initiateUpload: { uploadUrl?: string | null; session: { id: string } };\n }>(INITIATE_UPLOAD_MUTATION);\n const [completeUpload] = useMutation(COMPLETE_UPLOAD_MUTATION);\n const [createShareLink] = useMutation<{\n createFileShareLink: { id: string; shareUrl?: string | null };\n }>(CREATE_SHARE_LINK_MUTATION);\n\n const files = useMemo(() => data?.getFiles.files ?? [], [data]);\n\n const handleFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {\n const file = event.target.files?.[0];\n // Reset the input so selecting the same file again re-triggers change.\n event.target.value = '';\n if (!file) return;\n\n setUploading(true);\n try {\n const initiated = await initiateUpload({\n variables: {\n input: {\n fileName: file.name,\n extension: extensionOf(file.name),\n fileSize: file.size,\n mimeType: file.type || 'application/octet-stream',\n visibility: 'WORKSPACE',\n },\n },\n });\n\n const uploadUrl = initiated.data?.initiateUpload.uploadUrl;\n const sessionId = initiated.data?.initiateUpload.session.id;\n if (!uploadUrl || !sessionId) {\n throw new Error('Upload could not be initialised.');\n }\n\n const putResponse = await fetch(uploadUrl, {\n method: 'PUT',\n body: file,\n headers: { 'Content-Type': file.type || 'application/octet-stream' },\n });\n if (!putResponse.ok) {\n throw new Error(`Storage rejected the upload (HTTP ${putResponse.status}).`);\n }\n\n await completeUpload({\n variables: { input: { sessionId, fileSize: file.size } },\n });\n\n notify.success('File uploaded', `${file.name} is now available on this record.`);\n await refetch();\n } catch (error) {\n notify.error(\n 'Upload failed',\n error instanceof Error ? error.message : 'The file could not be uploaded.'\n );\n } finally {\n setUploading(false);\n }\n };\n\n const handleShare = async (fileId: string) => {\n setSharingId(fileId);\n try {\n const result = await createShareLink({\n variables: { input: { resourceId: fileId, role: 'VIEWER' } },\n });\n const url = result.data?.createFileShareLink.shareUrl;\n if (!url) {\n throw new Error('No share link was returned.');\n }\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(url);\n notify.success('Share link copied', 'A view-only link is on your clipboard.');\n } else {\n notify.info('Share link ready', url);\n }\n } catch (error) {\n notify.error(\n 'Could not create share link',\n error instanceof Error ? error.message : 'Please try again.'\n );\n } finally {\n setSharingId(null);\n }\n };\n\n const total = data?.getFiles.total ?? 0;\n const hasFiles = files.length > 0;\n\n return (\n <Card>\n <CardHeader className=\"flex flex-row items-center justify-between gap-2 space-y-0\">\n <CardTitle className=\"flex items-center gap-2 text-base\">\n <Paperclip className=\"h-4 w-4\" />\n Attachments\n {total ? (\n <span className=\"text-sm font-normal text-text-secondary\">\n {total} file{total === 1 ? '' : 's'}\n </span>\n ) : null}\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n <input\n ref={fileInputRef}\n type=\"file\"\n className=\"hidden\"\n onChange={(event) => void handleFileSelected(event)}\n aria-label={`Upload a file for ${entityType} ${entityId}`}\n />\n\n {loading && !hasFiles ? (\n <div className=\"space-y-2\">\n {Array.from({ length: 3 }).map((_, i) => (\n <div\n key={i}\n className=\"h-14 rounded-lg bg-bg-sunken motion-safe:animate-vc-shimmer\"\n />\n ))}\n </div>\n ) : !hasFiles ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No files yet\"\n description=\"Upload photos, receipts, warranties or documents to keep them with this record.\"\n />\n ) : (\n <ul className=\"space-y-1.5\">\n {files.map((file) => {\n const isSharing = sharingId === file.id;\n return (\n <li\n key={file.id}\n className=\"flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-surface p-2.5\"\n >\n <FileBadge file={file} />\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium\" title={file.name}>\n {file.name}\n </p>\n <p className=\"truncate text-xs text-text-secondary\">\n {formatSize(Number(file.size))} · {relativeTime(file.createdAt)}\n </p>\n </div>\n {/* Desktop: inline actions. Mobile: kebab menu to avoid crowding. */}\n <div className=\"hidden items-center gap-1 sm:flex\">\n {file.downloadUrl ? (\n <a\n href={file.downloadUrl}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary\"\n aria-label={`Download ${file.name}`}\n title=\"Download\"\n >\n <Download className=\"h-4 w-4\" />\n </a>\n ) : null}\n <button\n type=\"button\"\n disabled={isSharing}\n onClick={() => void handleShare(file.id)}\n className=\"rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary disabled:opacity-60\"\n aria-label={`Copy share link for ${file.name}`}\n title=\"Copy share link\"\n >\n {isSharing ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : (\n <Link2 className=\"h-4 w-4\" />\n )}\n </button>\n </div>\n <div className=\"sm:hidden\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n className=\"rounded-md p-1.5 text-text-secondary hover:bg-bg-sunken hover:text-text-primary\"\n aria-label={`Actions for ${file.name}`}\n >\n <MoreHorizontal className=\"h-4 w-4\" />\n </button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {file.downloadUrl ? (\n <DropdownMenuItem asChild>\n <a href={file.downloadUrl} target=\"_blank\" rel=\"noreferrer\">\n <Download className=\"mr-2 h-4 w-4\" />\n Download\n </a>\n </DropdownMenuItem>\n ) : null}\n <DropdownMenuItem\n disabled={isSharing}\n onSelect={(e) => {\n e.preventDefault();\n void handleShare(file.id);\n }}\n >\n <Link2 className=\"mr-2 h-4 w-4\" />\n Copy share link\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Prominent full-width primary upload CTA (matches the reference concept). */}\n <Button\n className=\"w-full\"\n disabled={uploading}\n onClick={() => fileInputRef.current?.click()}\n >\n {uploading ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : (\n <Upload className=\"mr-2 h-4 w-4\" />\n )}\n {uploading ? 'Uploading…' : 'Upload files'}\n </Button>\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AttachmentsPanel.js","names":["ImageIcon","FileIcon","GET_FILES_QUERY","INITIATE_UPLOAD_MUTATION","COMPLETE_UPLOAD_MUTATION","CREATE_SHARE_LINK_MUTATION","USER_FILE_PERMISSIONS_QUERY","requiresShareCheck","visibility","SHARE_DENIED_TITLE","RESTRICTED_SHARE_DENIED_DESCRIPTION","ROLE_SHARE_DENIED_DESCRIPTION","OTHER_SHARE_DENIED_DESCRIPTION","isShareDenial","access","shareDeniedDescription","denial","shareDenialFor","source","getForbiddenSource","error","formatSize","bytes","units","exponent","extensionOf","dot","fileName","relativeTime","then","iso","diff","sec","min","hr","day","kindOf","mime","file","ext","KIND_STYLE","Image","FileText","FileSpreadsheet","FileArchive","FileCode2","FileVideo","FileAudio","File","FileBadge","kind","tileClass","Icon","iconClass","AttachmentsPanel","fileInputRef","useRef","uploading","setUploading","useState","sharingId","setSharingId","shareAccess","setShareAccess","apolloClient","useApolloClient","useQuery","initiateUpload","useMutation","completeUpload","createShareLink","files","useMemo","data","handleFileSelected","event","initiated","uploadUrl","sessionId","putResponse","notify","refetch","setAccess","useCallback","next","current","fileId","checkCanShare","outcome","isForbiddenError","handleMenuOpenChange","open","handleShare","known","recheck","url","total","hasFiles","Card","CardHeader","CardTitle","Paperclip","CardContent","entityType","entityId","loading","i","isSharing","isChecking","isDenied","Download","Loader2","Lock","Link2","DropdownMenu","DropdownMenuTrigger","MoreHorizontal","DropdownMenuContent","DropdownMenuItem","e","IllustratedEmptyState","Button","Upload"],"sources":["../../../src/assethandler/components/AttachmentsPanel.tsx"],"sourcesContent":["import { useCallback, useMemo, useRef, useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useApolloClient, useMutation, useQuery } from '@apollo/client/react';\nimport {\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n IllustratedEmptyState,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from '@burdenoff/fe-libs/ui';\nimport {\n Paperclip,\n Upload,\n Link2,\n FileText,\n FileSpreadsheet,\n FileArchive,\n FileCode2,\n FileVideo,\n FileAudio,\n Image as ImageIcon,\n File as FileIcon,\n Loader2,\n Download,\n MoreHorizontal,\n Lock,\n} from 'lucide-react';\nimport { notify } from '../utils/toast';\nimport { getForbiddenSource, isForbiddenError } from '../utils/graphqlErrors';\n\n/**\n * Attach and reference files for an AssetHandler entity, backed by the\n * federated wspace-files-svc (same wspace gateway/Apollo client). Fully real (no fabricated data) —\n * lists workspace files via `getFiles`, uploads new files through the\n * `initiateUpload` → presigned PUT → `completeUpload` flow, and mints public\n * share links via `createFileShareLink`. All entries are live data.\n *\n * Sharing follows the files service's rules, which depend on visibility:\n *\n * - PRIVATE and TEAM files can only be shared by their uploader or someone\n * holding a SHARE/MANAGE grant or a MANAGER share. For these the panel asks\n * `getUserFilePermissions(fileId).canShare` before minting a link. The check\n * runs lazily (on each desktop click and each time the mobile menu opens), so\n * a long file list never fans out into one permission query per row.\n * - WORKSPACE and PUBLIC files are governed only by the gateway's\n * `@rbac(share, file)` check. `canShare` reflects explicit grants alone for\n * these files, so it is not consulted: the panel calls `createFileShareLink`\n * directly and treats its FORBIDDEN as the denial.\n *\n * The two denials arrive differently and get different copy. The gateway's\n * role check answers HTTP 403 (an Apollo `ServerError`), so the caller is told\n * their workspace role cannot share. The service's per-file rule answers HTTP\n * 200 with a `FORBIDDEN` GraphQL error, so the caller is told who can share\n * the file. A 503 `RBAC_SERVICE_UNAVAILABLE` is an outage, not a denial.\n *\n * `getUserFilePermissions` needs its own `@rbac(read, file-permission)`, which\n * some roles lack. A FORBIDDEN from that query, from either source, means\n * \"unknown\", never \"cannot share\": the panel falls through and lets the share\n * mutation decide.\n *\n * `entityType`/`entityId` scope the panel to the record it is mounted on and\n * are used for context labelling; uploaded files land in the workspace file\n * store so they can be shared and reused.\n */\nexport interface AttachmentsPanelProps {\n entityType: string;\n entityId: string;\n}\n\ninterface FileNode {\n id: string;\n name: string;\n extension: string;\n mimeType: string;\n size: number;\n downloadUrl?: string | null;\n createdAt: string;\n visibility: FileVisibility;\n}\n\ninterface GetFilesResult {\n getFiles: {\n files: FileNode[];\n total: number;\n hasMore: boolean;\n };\n}\n\nconst GET_FILES_QUERY = gql`\n query AssetHandlerGetFiles($pagination: PaginationInput, $sort: FileSortInput) {\n getFiles(pagination: $pagination, sort: $sort) {\n total\n hasMore\n files {\n id\n name\n extension\n mimeType\n size\n downloadUrl\n createdAt\n visibility\n }\n }\n }\n`;\n\nconst INITIATE_UPLOAD_MUTATION = gql`\n mutation AssetHandlerInitiateUpload($input: InitiateUploadInput!) {\n initiateUpload(input: $input) {\n uploadUrl\n session {\n id\n }\n }\n }\n`;\n\nconst COMPLETE_UPLOAD_MUTATION = gql`\n mutation AssetHandlerCompleteUpload($input: CompleteUploadInput!) {\n completeUpload(input: $input) {\n fileId\n file {\n id\n name\n }\n }\n }\n`;\n\nconst CREATE_SHARE_LINK_MUTATION = gql`\n mutation AssetHandlerCreateFileShareLink($input: CreateShareGrantLinkInput!) {\n createFileShareLink(input: $input) {\n id\n shareUrl\n }\n }\n`;\n\nconst USER_FILE_PERMISSIONS_QUERY = gql`\n query AssetHandlerUserFilePermissions($fileId: ID!) {\n getUserFilePermissions(fileId: $fileId) {\n fileId\n canShare\n }\n }\n`;\n\ninterface UserFilePermissionsResult {\n getUserFilePermissions: { fileId: string; canShare: boolean };\n}\n\ntype FileVisibility = 'PRIVATE' | 'TEAM' | 'WORKSPACE' | 'PUBLIC';\n\n/**\n * Why sharing a file was refused, which decides the copy shown:\n *\n * - `denied-role`: the gateway's `@rbac(share, file)` role check (HTTP 403).\n * - `denied-restricted`: the files service's per-file SHARE rule on a PRIVATE\n * or TEAM file, from the `canShare` pre-check or a service FORBIDDEN.\n * - `denied-other`: a service FORBIDDEN on a WORKSPACE or PUBLIC file, which\n * the per-file rule does not cover, so no specific reason can be given.\n */\ntype ShareDenial = 'denied-role' | 'denied-restricted' | 'denied-other';\n\n/**\n * Per-file share state. `unverified` means the permission query itself was\n * FORBIDDEN, so the share mutation is the only authority left to ask.\n */\ntype ShareAccess = 'checking' | 'allowed' | 'unverified' | ShareDenial;\n\n/** Result of one capability check; `failed` is a non-FORBIDDEN query error. */\ntype ShareCheckOutcome = 'allowed' | 'denied-restricted' | 'unverified' | 'failed';\n\n/**\n * Only PRIVATE and TEAM files have a per-file share rule on the server, so only\n * they are pre-checked. WORKSPACE and PUBLIC sharing is decided by the\n * gateway's role check, which `canShare` does not reflect.\n */\nfunction requiresShareCheck(visibility: FileVisibility | null | undefined): boolean {\n return visibility === 'PRIVATE' || visibility === 'TEAM';\n}\n\nconst SHARE_DENIED_TITLE = \"You can't share this file\";\nconst RESTRICTED_SHARE_DENIED_DESCRIPTION =\n 'Only the uploader, or someone with share or manage access to this file, can create a share link. Ask one of them to share it or to grant you access.';\nconst ROLE_SHARE_DENIED_DESCRIPTION =\n \"Your workspace role doesn't allow sharing files. Ask a workspace admin for share access.\";\nconst OTHER_SHARE_DENIED_DESCRIPTION =\n \"You don't have permission to share this file. Ask the file's uploader or a workspace admin.\";\n\nfunction isShareDenial(access: ShareAccess | undefined): access is ShareDenial {\n return access === 'denied-role' || access === 'denied-restricted' || access === 'denied-other';\n}\n\nfunction shareDeniedDescription(denial: ShareDenial): string {\n switch (denial) {\n case 'denied-role':\n return ROLE_SHARE_DENIED_DESCRIPTION;\n case 'denied-restricted':\n return RESTRICTED_SHARE_DENIED_DESCRIPTION;\n case 'denied-other':\n return OTHER_SHARE_DENIED_DESCRIPTION;\n }\n}\n\n/**\n * Maps a FORBIDDEN from `createFileShareLink` to its denial. The gateway's\n * HTTP 403 is always the role check; a service FORBIDDEN is the per-file\n * SHARE rule only for PRIVATE and TEAM files.\n */\nfunction shareDenialFor(\n error: unknown,\n visibility: FileVisibility | null | undefined\n): ShareDenial | null {\n const source = getForbiddenSource(error);\n if (source === 'gateway') return 'denied-role';\n if (source === 'service') {\n return requiresShareCheck(visibility) ? 'denied-restricted' : 'denied-other';\n }\n return null;\n}\n\nfunction formatSize(bytes: number): string {\n if (!bytes) return '0 B';\n const units = ['B', 'KB', 'MB', 'GB'];\n const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);\n const value = bytes / Math.pow(1024, exponent);\n return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;\n}\n\nfunction extensionOf(fileName: string): string {\n const dot = fileName.lastIndexOf('.');\n return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : 'bin';\n}\n\n/** Compact relative time (\"2 hrs ago\"), with the full timestamp on hover. */\nfunction relativeTime(iso: string): string {\n const then = new Date(iso).getTime();\n if (Number.isNaN(then)) return '';\n const diff = Date.now() - then;\n const sec = Math.round(diff / 1000);\n if (sec < 45) return 'just now';\n const min = Math.round(sec / 60);\n if (min < 60) return `${min} min ago`;\n const hr = Math.round(min / 60);\n if (hr < 24) return `${hr} hr${hr === 1 ? '' : 's'} ago`;\n const day = Math.round(hr / 24);\n if (day < 30) return `${day} day${day === 1 ? '' : 's'} ago`;\n return new Date(iso).toLocaleDateString();\n}\n\ntype FileKind =\n 'image' | 'pdf' | 'sheet' | 'doc' | 'archive' | 'code' | 'video' | 'audio' | 'other';\n\n/** Map a file's extension/mime to a recognisable kind for icon + tint. */\nfunction kindOf(file: FileNode): FileKind {\n const mime = (file.mimeType || '').toLowerCase();\n const ext = (file.extension || extensionOf(file.name)).toLowerCase();\n if (\n mime.startsWith('image/') ||\n ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp'].includes(ext)\n )\n return 'image';\n if (mime.startsWith('video/') || ['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext))\n return 'video';\n if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext))\n return 'audio';\n if (mime === 'application/pdf' || ext === 'pdf') return 'pdf';\n if (['xls', 'xlsx', 'csv', 'tsv', 'ods'].includes(ext)) return 'sheet';\n if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext)) return 'archive';\n if (\n [\n 'js',\n 'ts',\n 'tsx',\n 'jsx',\n 'json',\n 'xml',\n 'yml',\n 'yaml',\n 'html',\n 'css',\n 'sh',\n 'py',\n 'sql',\n ].includes(ext)\n )\n return 'code';\n if (['doc', 'docx', 'txt', 'md', 'rtf', 'odt', 'pages'].includes(ext)) return 'doc';\n return 'other';\n}\n\nconst KIND_STYLE: Record<\n FileKind,\n { Icon: typeof FileText; iconClass: string; tileClass: string }\n> = {\n image: {\n Icon: ImageIcon,\n iconClass: 'text-status-success-text',\n tileClass: 'bg-status-success-bg-subtle',\n },\n pdf: {\n Icon: FileText,\n iconClass: 'text-status-error-text',\n tileClass: 'bg-status-error-bg-subtle',\n },\n sheet: {\n Icon: FileSpreadsheet,\n iconClass: 'text-status-success-text',\n tileClass: 'bg-status-success-bg-subtle',\n },\n doc: {\n Icon: FileText,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n archive: {\n Icon: FileArchive,\n iconClass: 'text-status-warning-text',\n tileClass: 'bg-status-warning-bg-subtle',\n },\n code: {\n Icon: FileCode2,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n video: {\n Icon: FileVideo,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n audio: {\n Icon: FileAudio,\n iconClass: 'text-status-warning-text',\n tileClass: 'bg-status-warning-bg-subtle',\n },\n other: { Icon: FileIcon, iconClass: 'text-text-secondary', tileClass: 'bg-bg-sunken' },\n};\n\n/** File-type badge tile — an inline thumbnail for images, a tinted glyph otherwise. */\nfunction FileBadge({ file }: { file: FileNode }) {\n const kind = kindOf(file);\n const { Icon, iconClass, tileClass } = KIND_STYLE[kind];\n if (kind === 'image' && file.downloadUrl) {\n return (\n <img\n src={file.downloadUrl}\n alt=\"\"\n loading=\"lazy\"\n className=\"h-10 w-10 shrink-0 rounded-lg border border-border-subtle object-cover\"\n />\n );\n }\n return (\n <span\n className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${tileClass}`}\n aria-hidden=\"true\"\n >\n <Icon className={`h-5 w-5 ${iconClass}`} />\n </span>\n );\n}\n\nexport function AttachmentsPanel({ entityType, entityId }: AttachmentsPanelProps) {\n const fileInputRef = useRef<HTMLInputElement>(null);\n const [uploading, setUploading] = useState(false);\n const [sharingId, setSharingId] = useState<string | null>(null);\n const [shareAccess, setShareAccess] = useState<Record<string, ShareAccess>>({});\n const apolloClient = useApolloClient();\n\n const { data, loading, refetch } = useQuery<GetFilesResult>(GET_FILES_QUERY, {\n variables: {\n pagination: { limit: 25, offset: 0 },\n sort: { field: 'CREATED_AT', order: 'DESC' },\n },\n fetchPolicy: 'cache-and-network',\n errorPolicy: 'all',\n });\n\n const [initiateUpload] = useMutation<{\n initiateUpload: { uploadUrl?: string | null; session: { id: string } };\n }>(INITIATE_UPLOAD_MUTATION);\n const [completeUpload] = useMutation(COMPLETE_UPLOAD_MUTATION);\n const [createShareLink] = useMutation<{\n createFileShareLink: { id: string; shareUrl?: string | null };\n }>(CREATE_SHARE_LINK_MUTATION);\n\n const files = useMemo(() => data?.getFiles.files ?? [], [data]);\n\n const handleFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {\n const file = event.target.files?.[0];\n // Reset the input so selecting the same file again re-triggers change.\n event.target.value = '';\n if (!file) return;\n\n setUploading(true);\n try {\n const initiated = await initiateUpload({\n variables: {\n input: {\n fileName: file.name,\n extension: extensionOf(file.name),\n fileSize: file.size,\n mimeType: file.type || 'application/octet-stream',\n visibility: 'WORKSPACE',\n },\n },\n });\n\n const uploadUrl = initiated.data?.initiateUpload.uploadUrl;\n const sessionId = initiated.data?.initiateUpload.session.id;\n if (!uploadUrl || !sessionId) {\n throw new Error('Upload could not be initialised.');\n }\n\n const putResponse = await fetch(uploadUrl, {\n method: 'PUT',\n body: file,\n headers: { 'Content-Type': file.type || 'application/octet-stream' },\n });\n if (!putResponse.ok) {\n throw new Error(`Storage rejected the upload (HTTP ${putResponse.status}).`);\n }\n\n await completeUpload({\n variables: { input: { sessionId, fileSize: file.size } },\n });\n\n notify.success('File uploaded', `${file.name} is now available on this record.`);\n await refetch();\n } catch (error) {\n notify.error(\n 'Upload failed',\n error instanceof Error ? error.message : 'The file could not be uploaded.'\n );\n } finally {\n setUploading(false);\n }\n };\n\n const setAccess = useCallback((fileId: string, access: ShareAccess | null) => {\n setShareAccess((current) => {\n const next = { ...current };\n if (access) next[fileId] = access;\n else delete next[fileId];\n return next;\n });\n }, []);\n\n /**\n * Asks the files service whether the caller may share a PRIVATE or TEAM file.\n * Always hits the network so a grant or revoke made elsewhere is picked up on\n * the next attempt. A FORBIDDEN from the query, whether the gateway's 403 or\n * a service error, means the caller cannot read file permissions, which says\n * nothing about sharing, so it resolves to `unverified` and the share\n * mutation decides. Any other failure (a 503 included) is `failed`.\n */\n const checkCanShare = useCallback(\n async (fileId: string): Promise<ShareCheckOutcome> => {\n setAccess(fileId, 'checking');\n try {\n const result = await apolloClient.query<UserFilePermissionsResult>({\n query: USER_FILE_PERMISSIONS_QUERY,\n variables: { fileId },\n fetchPolicy: 'network-only',\n });\n const outcome =\n result.data?.getUserFilePermissions.canShare === true ? 'allowed' : 'denied-restricted';\n setAccess(fileId, outcome);\n return outcome;\n } catch (error) {\n if (isForbiddenError(error)) {\n setAccess(fileId, 'unverified');\n return 'unverified';\n }\n setAccess(fileId, null);\n return 'failed';\n }\n },\n [apolloClient, setAccess]\n );\n\n const handleMenuOpenChange = (file: FileNode, open: boolean) => {\n if (!open) return;\n if (requiresShareCheck(file.visibility)) {\n if (shareAccess[file.id] !== 'checking') void checkCanShare(file.id);\n } else if (isShareDenial(shareAccess[file.id])) {\n // No pre-check exists for WORKSPACE/PUBLIC files: re-opening the menu\n // re-enables the action so the next attempt asks the server again.\n setAccess(file.id, null);\n }\n };\n\n const handleShare = async (file: FileNode, { recheck }: { recheck: boolean }) => {\n const fileId = file.id;\n setSharingId(fileId);\n try {\n if (requiresShareCheck(file.visibility)) {\n // The mobile menu already checked on open; the desktop action checks on\n // click. Anything other than a settled outcome is checked again here.\n const known = shareAccess[fileId];\n const outcome =\n !recheck && (known === 'allowed' || known === 'unverified')\n ? known\n : await checkCanShare(fileId);\n if (outcome === 'failed') {\n throw new Error('Your share access could not be checked. Please try again.');\n }\n if (outcome === 'denied-restricted') {\n notify.error(SHARE_DENIED_TITLE, shareDeniedDescription(outcome));\n return;\n }\n }\n const result = await createShareLink({\n variables: { input: { resourceId: fileId, role: 'VIEWER' } },\n });\n const url = result.data?.createFileShareLink.shareUrl;\n if (!url) {\n throw new Error('No share link was returned.');\n }\n // The server minted a link, so any earlier denied state is stale.\n setAccess(fileId, 'allowed');\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(url);\n notify.success('Share link copied', 'A view-only link is on your clipboard.');\n } else {\n notify.info('Share link ready', url);\n }\n } catch (error) {\n const denial = shareDenialFor(error, file.visibility);\n if (denial) {\n // The mutation is the authoritative denial: the gateway's role check\n // (HTTP 403), or the service's per-file rule, e.g. a grant revoked\n // since the pre-check (HTTP 200 FORBIDDEN).\n setAccess(fileId, denial);\n notify.error(SHARE_DENIED_TITLE, shareDeniedDescription(denial));\n return;\n }\n notify.error(\n 'Could not create share link',\n error instanceof Error ? error.message : 'Please try again.'\n );\n } finally {\n setSharingId(null);\n }\n };\n\n const total = data?.getFiles.total ?? 0;\n const hasFiles = files.length > 0;\n\n return (\n <Card>\n <CardHeader className=\"flex flex-row items-center justify-between gap-2 space-y-0\">\n <CardTitle className=\"flex items-center gap-2 text-base\">\n <Paperclip className=\"h-4 w-4\" />\n Attachments\n {total ? (\n <span className=\"text-sm font-normal text-text-secondary\">\n {total} file{total === 1 ? '' : 's'}\n </span>\n ) : null}\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n <input\n ref={fileInputRef}\n type=\"file\"\n className=\"hidden\"\n onChange={(event) => void handleFileSelected(event)}\n aria-label={`Upload a file for ${entityType} ${entityId}`}\n />\n\n {loading && !hasFiles ? (\n <div className=\"space-y-2\">\n {Array.from({ length: 3 }).map((_, i) => (\n <div\n key={i}\n className=\"h-14 rounded-lg bg-bg-sunken motion-safe:animate-vc-shimmer\"\n />\n ))}\n </div>\n ) : !hasFiles ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No files yet\"\n description=\"Upload photos, receipts, warranties or documents to keep them with this record.\"\n />\n ) : (\n <ul className=\"space-y-1.5\">\n {files.map((file) => {\n const isSharing = sharingId === file.id;\n const access = shareAccess[file.id];\n const isChecking = access === 'checking';\n const denial = isShareDenial(access) ? access : null;\n const isDenied = denial !== null;\n return (\n <li\n key={file.id}\n className=\"flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-surface p-2.5\"\n >\n <FileBadge file={file} />\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium\" title={file.name}>\n {file.name}\n </p>\n <p className=\"truncate text-xs text-text-secondary\">\n {formatSize(Number(file.size))} · {relativeTime(file.createdAt)}\n </p>\n </div>\n {/* Desktop: inline actions. Mobile: kebab menu to avoid crowding. */}\n <div className=\"hidden items-center gap-1 sm:flex\">\n {file.downloadUrl ? (\n <a\n href={file.downloadUrl}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary\"\n aria-label={`Download ${file.name}`}\n title=\"Download\"\n >\n <Download className=\"h-4 w-4\" />\n </a>\n ) : null}\n {/* A denied action looks disabled (lock icon, muted, not-allowed\n cursor) but stays clickable so the next click asks again. */}\n <button\n type=\"button\"\n disabled={isSharing || isChecking}\n aria-disabled={isDenied ? true : undefined}\n data-share-state={isDenied ? 'denied' : undefined}\n onClick={() => void handleShare(file, { recheck: true })}\n className={`rounded-md p-1.5 transition-colors disabled:opacity-60 ${\n isDenied\n ? 'cursor-not-allowed text-text-tertiary opacity-60'\n : 'text-text-secondary hover:bg-bg-sunken hover:text-text-primary'\n }`}\n aria-label={`Copy share link for ${file.name}`}\n title={\n denial\n ? `${SHARE_DENIED_TITLE}. ${shareDeniedDescription(denial)}`\n : 'Copy share link'\n }\n >\n {isSharing || isChecking ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : isDenied ? (\n <Lock className=\"h-4 w-4\" aria-hidden=\"true\" />\n ) : (\n <Link2 className=\"h-4 w-4\" />\n )}\n </button>\n </div>\n <div className=\"sm:hidden\">\n <DropdownMenu onOpenChange={(open) => handleMenuOpenChange(file, open)}>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n className=\"rounded-md p-1.5 text-text-secondary hover:bg-bg-sunken hover:text-text-primary\"\n aria-label={`Actions for ${file.name}`}\n >\n <MoreHorizontal className=\"h-4 w-4\" />\n </button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {file.downloadUrl ? (\n <DropdownMenuItem asChild>\n <a href={file.downloadUrl} target=\"_blank\" rel=\"noreferrer\">\n <Download className=\"mr-2 h-4 w-4\" />\n Download\n </a>\n </DropdownMenuItem>\n ) : null}\n <DropdownMenuItem\n disabled={isSharing || isChecking || isDenied}\n onSelect={(e) => {\n e.preventDefault();\n void handleShare(file, { recheck: false });\n }}\n >\n {isChecking ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : isDenied ? (\n <Lock className=\"mr-2 h-4 w-4\" aria-hidden=\"true\" />\n ) : (\n <Link2 className=\"mr-2 h-4 w-4\" />\n )}\n {isChecking\n ? 'Checking share access…'\n : isDenied\n ? 'No share access'\n : 'Copy share link'}\n </DropdownMenuItem>\n {denial ? (\n <p className=\"max-w-56 px-2 py-1.5 text-xs text-text-secondary\">\n {shareDeniedDescription(denial)}\n </p>\n ) : null}\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Prominent full-width primary upload CTA (matches the reference concept). */}\n <Button\n className=\"w-full\"\n disabled={uploading}\n onClick={() => fileInputRef.current?.click()}\n >\n {uploading ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : (\n <Upload className=\"mr-2 h-4 w-4\" />\n )}\n {uploading ? 'Uploading…' : 'Upload files'}\n </Button>\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a FORBIDDEN denial came from.
|
|
3
|
+
*
|
|
4
|
+
* - `gateway`: the API gateway's `@rbac` check rejected the operation. The
|
|
5
|
+
* gateway answers with HTTP 403 and a JSON body, which Apollo Client 4
|
|
6
|
+
* surfaces as a `ServerError` (`statusCode` + `bodyText`, no `errors` array).
|
|
7
|
+
* This is a role decision: the caller's workspace role lacks the action.
|
|
8
|
+
* - `service`: the operation passed the gateway and the subgraph itself
|
|
9
|
+
* refused it. That arrives as HTTP 200 with `errors[].extensions.code`
|
|
10
|
+
* `FORBIDDEN` (`CombinedGraphQLErrors`, or `graphQLErrors` on older shapes).
|
|
11
|
+
* For files this is the per-file SHARE rule on PRIVATE and TEAM files.
|
|
12
|
+
*/
|
|
13
|
+
export type ForbiddenSource = 'gateway' | 'service';
|
|
14
|
+
/**
|
|
15
|
+
* Classifies a thrown Apollo error as a FORBIDDEN denial and says where it came
|
|
16
|
+
* from, or returns `null` when it is not a denial. Checks are structural so the
|
|
17
|
+
* helper does not depend on Apollo's error class identity.
|
|
18
|
+
*
|
|
19
|
+
* Only HTTP 403 counts at the transport level. Any other status, notably a 503
|
|
20
|
+
* `RBAC_SERVICE_UNAVAILABLE` from the gateway, is an outage and never a denial,
|
|
21
|
+
* even if its body happens to mention FORBIDDEN.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getForbiddenSource(error: unknown): ForbiddenSource | null;
|
|
24
|
+
/** True when a thrown Apollo error is a FORBIDDEN denial from any source. */
|
|
25
|
+
export declare function isForbiddenError(error: unknown): boolean;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function e(e) {
|
|
2
|
+
return Array.isArray(e) && e.some((e) => typeof e != "object" || !e ? !1 : e.extensions?.code === "FORBIDDEN");
|
|
3
|
+
}
|
|
4
|
+
function t(e) {
|
|
5
|
+
let t = [e, e.networkError];
|
|
6
|
+
for (let e of t) {
|
|
7
|
+
if (typeof e != "object" || !e) continue;
|
|
8
|
+
let { statusCode: t, bodyText: n, result: r } = e;
|
|
9
|
+
if (typeof t == "number") return {
|
|
10
|
+
status: t,
|
|
11
|
+
body: n ?? r
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
function n(t) {
|
|
17
|
+
let n = t;
|
|
18
|
+
if (typeof t == "string") try {
|
|
19
|
+
n = JSON.parse(t);
|
|
20
|
+
} catch {
|
|
21
|
+
return !0;
|
|
22
|
+
}
|
|
23
|
+
if (typeof n != "object" || !n) return !0;
|
|
24
|
+
let r = n.errors;
|
|
25
|
+
return !Array.isArray(r) || e(r);
|
|
26
|
+
}
|
|
27
|
+
function r(r) {
|
|
28
|
+
if (typeof r != "object" || !r) return null;
|
|
29
|
+
let i = t(r);
|
|
30
|
+
return i ? i.status === 403 && n(i.body) ? "gateway" : null : [r.errors, r.graphQLErrors].some(e) ? "service" : null;
|
|
31
|
+
}
|
|
32
|
+
function i(e) {
|
|
33
|
+
return r(e) !== null;
|
|
34
|
+
}
|
|
35
|
+
export { r as getForbiddenSource, i as isForbiddenError };
|
|
36
|
+
|
|
37
|
+
//# sourceMappingURL=graphqlErrors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graphqlErrors.js","names":["hasForbiddenCode","list","entry","httpFailure","sources","error","source","statusCode","bodyText","result","isForbiddenBody","parsed","body","errors","getForbiddenSource","http","isForbiddenError"],"sources":["../../../src/assethandler/utils/graphqlErrors.ts"],"sourcesContent":["/**\n * Where a FORBIDDEN denial came from.\n *\n * - `gateway`: the API gateway's `@rbac` check rejected the operation. The\n * gateway answers with HTTP 403 and a JSON body, which Apollo Client 4\n * surfaces as a `ServerError` (`statusCode` + `bodyText`, no `errors` array).\n * This is a role decision: the caller's workspace role lacks the action.\n * - `service`: the operation passed the gateway and the subgraph itself\n * refused it. That arrives as HTTP 200 with `errors[].extensions.code`\n * `FORBIDDEN` (`CombinedGraphQLErrors`, or `graphQLErrors` on older shapes).\n * For files this is the per-file SHARE rule on PRIVATE and TEAM files.\n */\nexport type ForbiddenSource = 'gateway' | 'service';\n\nconst FORBIDDEN_CODE = 'FORBIDDEN';\nconst HTTP_FORBIDDEN = 403;\n\nfunction hasForbiddenCode(list: unknown): boolean {\n return (\n Array.isArray(list) &&\n list.some((entry) => {\n if (typeof entry !== 'object' || entry === null) return false;\n return (entry as { extensions?: { code?: unknown } }).extensions?.code === FORBIDDEN_CODE;\n })\n );\n}\n\n/**\n * Reads the HTTP status and body of a transport-level error: Apollo 4's\n * `ServerError` carries them directly, Apollo 3 nested them under\n * `networkError` (`statusCode`, `bodyText` on a `ServerParseError`, or\n * `result` on a `ServerError`).\n */\nfunction httpFailure(error: object): { status: number; body: unknown } | null {\n const sources: unknown[] = [error, (error as { networkError?: unknown }).networkError];\n for (const source of sources) {\n if (typeof source !== 'object' || source === null) continue;\n const { statusCode, bodyText, result } = source as {\n statusCode?: unknown;\n bodyText?: unknown;\n result?: unknown;\n };\n if (typeof statusCode !== 'number') continue;\n return { status: statusCode, body: bodyText ?? result };\n }\n return null;\n}\n\n/**\n * Decides whether an HTTP 403 body is a gateway FORBIDDEN. A body that parses\n * to a GraphQL `errors` array must carry the FORBIDDEN code; a body that does\n * not parse to that shape leaves the 403 status as the only signal, which is\n * still a denial.\n */\nfunction isForbiddenBody(body: unknown): boolean {\n let parsed: unknown = body;\n if (typeof body === 'string') {\n try {\n parsed = JSON.parse(body);\n } catch {\n return true;\n }\n }\n if (typeof parsed !== 'object' || parsed === null) return true;\n const errors = (parsed as { errors?: unknown }).errors;\n if (!Array.isArray(errors)) return true;\n return hasForbiddenCode(errors);\n}\n\n/**\n * Classifies a thrown Apollo error as a FORBIDDEN denial and says where it came\n * from, or returns `null` when it is not a denial. Checks are structural so the\n * helper does not depend on Apollo's error class identity.\n *\n * Only HTTP 403 counts at the transport level. Any other status, notably a 503\n * `RBAC_SERVICE_UNAVAILABLE` from the gateway, is an outage and never a denial,\n * even if its body happens to mention FORBIDDEN.\n */\nexport function getForbiddenSource(error: unknown): ForbiddenSource | null {\n if (typeof error !== 'object' || error === null) return null;\n\n const http = httpFailure(error);\n if (http) {\n return http.status === HTTP_FORBIDDEN && isForbiddenBody(http.body) ? 'gateway' : null;\n }\n\n const lists = [\n (error as { errors?: unknown }).errors,\n (error as { graphQLErrors?: unknown }).graphQLErrors,\n ];\n return lists.some(hasForbiddenCode) ? 'service' : null;\n}\n\n/** True when a thrown Apollo error is a FORBIDDEN denial from any source. */\nexport function isForbiddenError(error: unknown): boolean {\n return getForbiddenSource(error) !== null;\n}\n"],"mappings":"AAiBA,SAASA,EAAiB,GAAwB;CAChD,OACE,MAAM,QAAQC,CAAI,KAClBA,EAAK,MAAM,MACL,OAAOC,KAAU,aAAYA,IAAuB,KAChDA,EAA8C,YAAY,SAAS,WAC5E;AAEL;AAQA,SAASC,EAAY,GAAyD;CAC5E,IAAMC,IAAqB,CAACC,GAAQA,EAAqC,YAAY;CACrF,KAAK,IAAMC,KAAUF,GAAS;EAC5B,IAAI,OAAOE,KAAW,aAAYA,GAAiB;EACnD,IAAM,EAAE,eAAY,aAAU,cAAWA;EAKzC,IAAI,OAAOC,KAAe,UAC1B,OAAO;GAAE,QAAQA;GAAY,MAAMC,KAAYC;EAAO;CACxD;CACA,OAAO;AACT;AAQA,SAASC,EAAgB,GAAwB;CAC/C,IAAIC,IAAkBC;CACtB,IAAI,OAAOA,KAAS,UAClB,IAAI;EACF,IAAS,KAAK,MAAMA,CAAI;CAC1B,QAAQ;EACN,OAAO;CACT;CAEF,IAAI,OAAOD,KAAW,aAAYA,GAAiB,OAAO;CAC1D,IAAME,IAAUF,EAAgC;CAEhD,OADA,CAAK,MAAM,QAAQE,CAAM,KAClBb,EAAiBa,CAAM;AAChC;AAWA,SAAgBC,EAAmB,GAAwC;CACzE,IAAI,OAAOT,KAAU,aAAYA,GAAgB,OAAO;CAExD,IAAMU,IAAOZ,EAAYE,CAAK;CAS9B,OARIU,IACKA,EAAK,WAAW,OAAkBL,EAAgBK,EAAK,IAAI,IAAI,YAAY,OAO7E,CAHJV,EAA+B,QAC/BA,EAAsC,aAElC,CAAA,CAAM,KAAKL,CAAgB,IAAI,YAAY;AACpD;AAGA,SAAgBgB,EAAiB,GAAyB;CACxD,OAAOF,EAAmBT,CAAK,MAAM;AACvC"}
|