@anchrd/intel-ui 0.23.0 → 0.25.0
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/package.json +2 -2
- package/src/access-summary/access-summary.tsx +282 -0
- package/src/archive/archive.tsx +81 -22
- package/src/data/intel-data-provider/intel-data-provider.ts +17 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +7 -0
- package/src/folder-contents/folder-contents.tsx +12 -0
- package/src/i18n/de.json +16 -3
- package/src/i18n/en.json +16 -3
- package/src/i18n/es.json +16 -3
- package/src/resource-menu/resource-menu.tsx +17 -74
- package/src/title-row/title-row.tsx +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@anchrd/intel-contract": "^0.
|
|
36
|
+
"@anchrd/intel-contract": "^0.17.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { ResourceGrant } from "@anchrd/intel-contract/share";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useId, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { AppLogo } from "@/branding/branding.tsx";
|
|
6
|
+
import {
|
|
7
|
+
Tooltip,
|
|
8
|
+
TooltipContent,
|
|
9
|
+
TooltipProvider,
|
|
10
|
+
TooltipTrigger,
|
|
11
|
+
} from "@/components/ui/tooltip.tsx";
|
|
12
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
13
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
14
|
+
import { useUserName } from "@/user-name/user-name.ts";
|
|
15
|
+
|
|
16
|
+
type Principal = ResourceGrant["principal"];
|
|
17
|
+
|
|
18
|
+
type GrantGroup = {
|
|
19
|
+
key: string;
|
|
20
|
+
principal: Principal;
|
|
21
|
+
grants: ResourceGrant[];
|
|
22
|
+
owner: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function principalKey(principal: Principal): string {
|
|
26
|
+
if (principal.type === "organization") return "organization";
|
|
27
|
+
if (principal.type === "email") return `email:${principal.email.toLowerCase()}`;
|
|
28
|
+
return `user:${principal.id}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function groupGrants(grants: ResourceGrant[], ownerIds: string[] = []): GrantGroup[] {
|
|
32
|
+
const grouped = new Map<string, GrantGroup>();
|
|
33
|
+
for (const ownerId of ownerIds) {
|
|
34
|
+
grouped.set(`user:${ownerId}`, {
|
|
35
|
+
key: `user:${ownerId}`,
|
|
36
|
+
principal: { type: "user", id: ownerId },
|
|
37
|
+
grants: [],
|
|
38
|
+
owner: true,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
for (const grant of grants) {
|
|
42
|
+
const key = principalKey(grant.principal);
|
|
43
|
+
const group = grouped.get(key);
|
|
44
|
+
if (group) group.grants.push(grant);
|
|
45
|
+
else grouped.set(key, { key, principal: grant.principal, grants: [grant], owner: false });
|
|
46
|
+
}
|
|
47
|
+
return [...grouped.values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function initials(value: string): string {
|
|
51
|
+
const parts = value
|
|
52
|
+
.replace(/@.*$/, "")
|
|
53
|
+
.split(/[\s._+-]+/)
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
if (parts.length === 0) return "?";
|
|
56
|
+
if (parts.length === 1) return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
|
|
57
|
+
return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function usePrincipalLabel(principal: Principal): string {
|
|
61
|
+
const i18n = useI18n();
|
|
62
|
+
const userName = useUserName(principal.type === "user" ? principal.id : null);
|
|
63
|
+
if (principal.type === "organization") return i18n.t("node.organization");
|
|
64
|
+
if (principal.type === "email") return principal.email;
|
|
65
|
+
return userName ?? i18n.t("node.someUser");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const circleTones = [
|
|
69
|
+
"bg-muted text-muted-foreground",
|
|
70
|
+
"bg-accent text-accent-foreground",
|
|
71
|
+
"bg-secondary text-secondary-foreground",
|
|
72
|
+
] as const;
|
|
73
|
+
|
|
74
|
+
function AccessCircle({
|
|
75
|
+
group,
|
|
76
|
+
position,
|
|
77
|
+
count,
|
|
78
|
+
expanded,
|
|
79
|
+
controls,
|
|
80
|
+
activate,
|
|
81
|
+
}: {
|
|
82
|
+
group: GrantGroup;
|
|
83
|
+
position: number;
|
|
84
|
+
count: number;
|
|
85
|
+
expanded: boolean;
|
|
86
|
+
controls: string;
|
|
87
|
+
activate(element: HTMLButtonElement): void;
|
|
88
|
+
}) {
|
|
89
|
+
const i18n = useI18n();
|
|
90
|
+
const label = usePrincipalLabel(group.principal);
|
|
91
|
+
const circle = (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
aria-label={position === 0 ? `${i18n.t("node.accessDetails", { count })}: ${label}` : label}
|
|
95
|
+
aria-expanded={expanded}
|
|
96
|
+
aria-controls={controls}
|
|
97
|
+
data-organization-access={group.principal.type === "organization" ? "" : undefined}
|
|
98
|
+
onClick={(event) => activate(event.currentTarget)}
|
|
99
|
+
className={`relative flex size-7 items-center justify-center rounded-full text-xs font-medium ring-2 ring-background outline-none transition hover:z-10 hover:scale-105 focus-visible:z-10 focus-visible:ring-ring ${circleTones[position % circleTones.length]}`}
|
|
100
|
+
>
|
|
101
|
+
{group.principal.type === "organization" ? (
|
|
102
|
+
<AppLogo className="size-4 rounded-none bg-transparent text-current [&_svg]:size-3" />
|
|
103
|
+
) : (
|
|
104
|
+
initials(label)
|
|
105
|
+
)}
|
|
106
|
+
</button>
|
|
107
|
+
);
|
|
108
|
+
return (
|
|
109
|
+
<TooltipProvider delayDuration={300}>
|
|
110
|
+
<Tooltip>
|
|
111
|
+
<TooltipTrigger asChild>{circle}</TooltipTrigger>
|
|
112
|
+
<TooltipContent>
|
|
113
|
+
{group.principal.type === "organization" ? i18n.t("node.organizationAccess") : label}
|
|
114
|
+
</TooltipContent>
|
|
115
|
+
</Tooltip>
|
|
116
|
+
</TooltipProvider>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function AccessDetail({
|
|
121
|
+
group,
|
|
122
|
+
onRevoke,
|
|
123
|
+
}: {
|
|
124
|
+
group: GrantGroup;
|
|
125
|
+
onRevoke?(grantId: string): void;
|
|
126
|
+
}) {
|
|
127
|
+
const i18n = useI18n();
|
|
128
|
+
const label = usePrincipalLabel(group.principal);
|
|
129
|
+
return (
|
|
130
|
+
<li className="flex items-start justify-between gap-3 text-sm">
|
|
131
|
+
<span className="min-w-0">
|
|
132
|
+
<span className="block truncate font-medium">{label}</span>
|
|
133
|
+
<span className="text-xs text-muted-foreground">
|
|
134
|
+
{[
|
|
135
|
+
...(group.owner ? [i18n.t("node.owner")] : []),
|
|
136
|
+
...group.grants.map((grant) => i18n.t(`node.verb.${grant.verb}`)),
|
|
137
|
+
].join(", ")}
|
|
138
|
+
</span>
|
|
139
|
+
</span>
|
|
140
|
+
{onRevoke ? (
|
|
141
|
+
<span className="flex flex-wrap justify-end gap-1">
|
|
142
|
+
{group.grants.map((grant) => (
|
|
143
|
+
<button
|
|
144
|
+
key={grant.id}
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() => onRevoke(grant.id)}
|
|
147
|
+
aria-label={i18n.t("node.revokeVerbFor", {
|
|
148
|
+
verb: i18n.t(`node.verb.${grant.verb}`),
|
|
149
|
+
recipient: label,
|
|
150
|
+
})}
|
|
151
|
+
className="rounded px-2 py-1 text-xs text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
152
|
+
>
|
|
153
|
+
{i18n.t("node.revokeVerb", { verb: i18n.t(`node.verb.${grant.verb}`) })}
|
|
154
|
+
</button>
|
|
155
|
+
))}
|
|
156
|
+
</span>
|
|
157
|
+
) : null}
|
|
158
|
+
</li>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function AccessSummary({
|
|
163
|
+
grants,
|
|
164
|
+
ownerId,
|
|
165
|
+
ownerIds,
|
|
166
|
+
onRevoke,
|
|
167
|
+
}: {
|
|
168
|
+
grants: ResourceGrant[];
|
|
169
|
+
ownerId?: string;
|
|
170
|
+
ownerIds?: string[];
|
|
171
|
+
onRevoke?(grantId: string): void;
|
|
172
|
+
}) {
|
|
173
|
+
const i18n = useI18n();
|
|
174
|
+
const groups = groupGrants(grants, ownerIds ?? (ownerId ? [ownerId] : []));
|
|
175
|
+
const [open, setOpen] = useState(false);
|
|
176
|
+
const [position, setPosition] = useState({ top: 8, left: 8 });
|
|
177
|
+
const detailsId = useId();
|
|
178
|
+
const organizationDescriptionId = useId();
|
|
179
|
+
if (groups.length === 0) return null;
|
|
180
|
+
const visible = groups.slice(0, 3);
|
|
181
|
+
const overflow = groups.length - visible.length;
|
|
182
|
+
const toggle = (element: HTMLButtonElement) => {
|
|
183
|
+
setOpen((current) => {
|
|
184
|
+
if (!current) {
|
|
185
|
+
const rect = element.getBoundingClientRect();
|
|
186
|
+
const panelHeight = Math.min(320, window.innerHeight - 16);
|
|
187
|
+
const below = window.innerHeight - rect.bottom;
|
|
188
|
+
setPosition({
|
|
189
|
+
top: below >= panelHeight ? rect.bottom + 4 : Math.max(8, rect.top - panelHeight - 4),
|
|
190
|
+
left: Math.max(8, Math.min(rect.left, window.innerWidth - 328)),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return !current;
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div className="relative flex w-fit items-center -space-x-2" data-access-summary="">
|
|
199
|
+
{visible.map((group, position) => (
|
|
200
|
+
<AccessCircle
|
|
201
|
+
key={group.key}
|
|
202
|
+
group={group}
|
|
203
|
+
position={position}
|
|
204
|
+
count={groups.length}
|
|
205
|
+
expanded={open}
|
|
206
|
+
controls={detailsId}
|
|
207
|
+
activate={toggle}
|
|
208
|
+
/>
|
|
209
|
+
))}
|
|
210
|
+
{overflow > 0 ? (
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
aria-label={i18n.t("node.accessDetails", { count: groups.length })}
|
|
214
|
+
aria-expanded={open}
|
|
215
|
+
aria-controls={detailsId}
|
|
216
|
+
onClick={(event) => toggle(event.currentTarget)}
|
|
217
|
+
className="relative flex size-7 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground ring-2 ring-background outline-none transition hover:z-10 hover:scale-105 focus-visible:z-10 focus-visible:ring-ring"
|
|
218
|
+
>
|
|
219
|
+
+{overflow}
|
|
220
|
+
</button>
|
|
221
|
+
) : null}
|
|
222
|
+
{groups.some((group) => group.principal.type === "organization") ? (
|
|
223
|
+
<span id={organizationDescriptionId} className="sr-only">
|
|
224
|
+
{i18n.t("node.organizationAccess")}
|
|
225
|
+
</span>
|
|
226
|
+
) : null}
|
|
227
|
+
{open
|
|
228
|
+
? createPortal(
|
|
229
|
+
<div
|
|
230
|
+
id={detailsId}
|
|
231
|
+
data-access-details=""
|
|
232
|
+
className="fixed z-50 max-h-[calc(100dvh-1rem)] w-80 overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md"
|
|
233
|
+
style={position}
|
|
234
|
+
>
|
|
235
|
+
<ul className="space-y-3">
|
|
236
|
+
{groups.map((group) => (
|
|
237
|
+
<AccessDetail key={group.key} group={group} {...(onRevoke ? { onRevoke } : {})} />
|
|
238
|
+
))}
|
|
239
|
+
</ul>
|
|
240
|
+
</div>,
|
|
241
|
+
document.querySelector('[role="dialog"]') ?? document.body,
|
|
242
|
+
)
|
|
243
|
+
: null}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The compact access view for a resource the reader can manage.
|
|
250
|
+
*
|
|
251
|
+
* `node_grant_list` is itself authorized: a reader who may see the node but may not inspect its
|
|
252
|
+
* grants gets no names here. Treating that refusal as an empty summary is deliberate; rendering an
|
|
253
|
+
* error or retry would disclose that there are grants to ask about (#484).
|
|
254
|
+
*/
|
|
255
|
+
export function ResourceAccessSummary({
|
|
256
|
+
resourceId,
|
|
257
|
+
ownerId,
|
|
258
|
+
}: {
|
|
259
|
+
resourceId: string;
|
|
260
|
+
ownerId: string;
|
|
261
|
+
}) {
|
|
262
|
+
const { data } = useIntelRouterContext();
|
|
263
|
+
const grants = useQuery({
|
|
264
|
+
queryKey: ["effective-node-grants", resourceId],
|
|
265
|
+
queryFn: () => data.listEffectiveAccess(resourceId),
|
|
266
|
+
retry: false,
|
|
267
|
+
// Time cannot change this answer locally. Share mutations invalidate the whole effective prefix
|
|
268
|
+
// because changing one folder also changes every descendant summary already on screen.
|
|
269
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
270
|
+
});
|
|
271
|
+
if (!grants.data) return null;
|
|
272
|
+
return (
|
|
273
|
+
<AccessSummary
|
|
274
|
+
grants={grants.data.items}
|
|
275
|
+
ownerIds={
|
|
276
|
+
grants.data.ownerIds.includes(ownerId)
|
|
277
|
+
? grants.data.ownerIds
|
|
278
|
+
: [ownerId, ...grants.data.ownerIds]
|
|
279
|
+
}
|
|
280
|
+
/>
|
|
281
|
+
);
|
|
282
|
+
}
|
package/src/archive/archive.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Flow } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
3
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import { ArchiveRestore, Trash2 } from "lucide-react";
|
|
4
|
+
import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
|
|
5
5
|
import { useState } from "react";
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
6
7
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
7
8
|
import { kindIcons } from "@/kind-icon.ts";
|
|
8
9
|
import { Modal } from "@/modal/modal.tsx";
|
|
@@ -22,6 +23,7 @@ interface ArchivedEntry {
|
|
|
22
23
|
// Both sides of the tree can be purged (#457) — each through its own door, because node and flow
|
|
23
24
|
// stay separate everywhere else (ADR-0004).
|
|
24
25
|
purge(): Promise<{ purged: true; title: string }>;
|
|
26
|
+
previewPurge?(): Promise<{ inboundLinks: number; totalItems: number }>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function Archive() {
|
|
@@ -38,8 +40,10 @@ export function Archive() {
|
|
|
38
40
|
data.listFlows({ archivedOnly: true }),
|
|
39
41
|
]);
|
|
40
42
|
const entries: ArchivedEntry[] = [
|
|
41
|
-
...nodes.items.flatMap((node) =>
|
|
42
|
-
|
|
43
|
+
...nodes.items.flatMap((node) => {
|
|
44
|
+
// Stable across reloads: after D1 commits, only this key can reach an R2 cleanup receipt.
|
|
45
|
+
const purgeKey = `purge-${node.id}`;
|
|
46
|
+
return node.archivedAt
|
|
43
47
|
? [
|
|
44
48
|
{
|
|
45
49
|
id: node.id,
|
|
@@ -54,11 +58,12 @@ export function Archive() {
|
|
|
54
58
|
archived: false,
|
|
55
59
|
idempotencyKey: crypto.randomUUID(),
|
|
56
60
|
}),
|
|
57
|
-
purge: () => data.purgeNode({ nodeId: node.id }),
|
|
61
|
+
purge: () => data.purgeNode({ nodeId: node.id, idempotencyKey: purgeKey }),
|
|
62
|
+
previewPurge: () => data.previewNodePurge({ nodeId: node.id }),
|
|
58
63
|
} satisfies ArchivedEntry,
|
|
59
64
|
]
|
|
60
|
-
: []
|
|
61
|
-
),
|
|
65
|
+
: [];
|
|
66
|
+
}),
|
|
62
67
|
...flows.items.flatMap((flow: Flow) =>
|
|
63
68
|
flow.archivedAt
|
|
64
69
|
? [
|
|
@@ -108,6 +113,12 @@ export function Archive() {
|
|
|
108
113
|
// that it does not come back. The state lives here because the row underneath disappears the
|
|
109
114
|
// moment the purge goes through.
|
|
110
115
|
const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
|
|
116
|
+
const preview = useQuery({
|
|
117
|
+
queryKey: ["purge-preview", confirming?.id],
|
|
118
|
+
queryFn: async () => await confirming?.previewPurge?.(),
|
|
119
|
+
enabled: Boolean(confirming?.previewPurge),
|
|
120
|
+
retry: false,
|
|
121
|
+
});
|
|
111
122
|
const purge = useMutation({
|
|
112
123
|
mutationFn: async (entry: ArchivedEntry) => await entry.purge(),
|
|
113
124
|
onSuccess: async () => {
|
|
@@ -173,21 +184,43 @@ export function Archive() {
|
|
|
173
184
|
{i18n.t("archive.archivedAt", { when: dateTime.at(entry.archivedAt) })}
|
|
174
185
|
</span>
|
|
175
186
|
</span>
|
|
176
|
-
{/*
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
{/* Both actions stay permanently visible: the archive is the one place where getting
|
|
188
|
+
an item back must never depend on discovering a hover-only control. */}
|
|
189
|
+
<TooltipProvider delayDuration={300}>
|
|
190
|
+
<Tooltip>
|
|
191
|
+
{/* The wrapper keeps the tooltip available while the button is disabled. */}
|
|
192
|
+
<TooltipTrigger asChild>
|
|
193
|
+
<span className="inline-flex shrink-0">
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
disabled={restore.isPending}
|
|
197
|
+
onClick={() => restore.mutate(entry)}
|
|
198
|
+
aria-label={i18n.t(
|
|
199
|
+
restore.isPending && restore.variables?.id === entry.id
|
|
200
|
+
? "archive.restoring"
|
|
201
|
+
: "archive.restore",
|
|
202
|
+
{ title: entry.title },
|
|
203
|
+
)}
|
|
204
|
+
aria-busy={restore.isPending && restore.variables?.id === entry.id}
|
|
205
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
206
|
+
>
|
|
207
|
+
{restore.isPending && restore.variables?.id === entry.id ? (
|
|
208
|
+
<LoaderCircle aria-hidden="true" className="size-4 animate-spin" />
|
|
209
|
+
) : (
|
|
210
|
+
<ArchiveRestore aria-hidden="true" className="size-4" />
|
|
211
|
+
)}
|
|
212
|
+
</button>
|
|
213
|
+
</span>
|
|
214
|
+
</TooltipTrigger>
|
|
215
|
+
<TooltipContent>
|
|
216
|
+
{i18n.t(
|
|
217
|
+
restore.isPending && restore.variables?.id === entry.id
|
|
218
|
+
? "archive.restoringAction"
|
|
219
|
+
: "archive.restoreAction",
|
|
220
|
+
)}
|
|
221
|
+
</TooltipContent>
|
|
222
|
+
</Tooltip>
|
|
223
|
+
</TooltipProvider>
|
|
191
224
|
<button
|
|
192
225
|
type="button"
|
|
193
226
|
disabled={purge.isPending}
|
|
@@ -206,6 +239,29 @@ export function Archive() {
|
|
|
206
239
|
<p className="text-sm text-muted-foreground">
|
|
207
240
|
{i18n.t("archive.purge.body", { title: confirming.title })}
|
|
208
241
|
</p>
|
|
242
|
+
{confirming.previewPurge && preview.data ? (
|
|
243
|
+
<div className="mt-2 space-y-2 text-sm text-muted-foreground">
|
|
244
|
+
<p>{i18n.t("archive.purge.items", { count: preview.data.totalItems })}</p>
|
|
245
|
+
<p>
|
|
246
|
+
{preview.data.inboundLinks === 0
|
|
247
|
+
? i18n.t("archive.purge.links.none")
|
|
248
|
+
: preview.data.inboundLinks === 1
|
|
249
|
+
? i18n.t("archive.purge.links.one")
|
|
250
|
+
: i18n.t("archive.purge.links.many", { count: preview.data.inboundLinks })}
|
|
251
|
+
</p>
|
|
252
|
+
</div>
|
|
253
|
+
) : null}
|
|
254
|
+
{confirming.previewPurge && preview.isPending ? (
|
|
255
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
256
|
+
{i18n.t("archive.purge.previewLoading")}
|
|
257
|
+
</p>
|
|
258
|
+
) : null}
|
|
259
|
+
{confirming.previewPurge && preview.isError ? (
|
|
260
|
+
<p role="alert" className="mt-2 text-sm text-destructive">
|
|
261
|
+
{i18n.t("archive.purge.previewFailed")}
|
|
262
|
+
</p>
|
|
263
|
+
) : null}
|
|
264
|
+
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("archive.purge.grants")}</p>
|
|
209
265
|
<div className="mt-6 flex justify-end gap-2">
|
|
210
266
|
<button
|
|
211
267
|
type="button"
|
|
@@ -216,7 +272,10 @@ export function Archive() {
|
|
|
216
272
|
</button>
|
|
217
273
|
<button
|
|
218
274
|
type="button"
|
|
219
|
-
disabled={
|
|
275
|
+
disabled={
|
|
276
|
+
purge.isPending ||
|
|
277
|
+
(Boolean(confirming.previewPurge) && (preview.isPending || preview.isError))
|
|
278
|
+
}
|
|
220
279
|
onClick={() => purge.mutate(confirming)}
|
|
221
280
|
className="inline-flex h-8 items-center gap-2 rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
222
281
|
>
|
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
NodeTable,
|
|
40
40
|
NodeVersionList,
|
|
41
41
|
PurgeNodeInput,
|
|
42
|
+
PurgeNodePreview,
|
|
43
|
+
PurgeNodePreviewInput,
|
|
42
44
|
PurgeNodeResult,
|
|
43
45
|
ReindexResult,
|
|
44
46
|
ResolveNodeLinksInput,
|
|
@@ -50,6 +52,7 @@ import {
|
|
|
50
52
|
UpdateNodeInput,
|
|
51
53
|
} from "@anchrd/intel-contract/node";
|
|
52
54
|
import {
|
|
55
|
+
ResourceAccessList,
|
|
53
56
|
ResourceGrantList,
|
|
54
57
|
RevokeGrantInput,
|
|
55
58
|
RevokeGrantResult,
|
|
@@ -334,8 +337,16 @@ export function createIntelDataProvider(
|
|
|
334
337
|
const parsed = PurgeNodeInput.parse(input);
|
|
335
338
|
return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}`, PurgeNodeResult, {
|
|
336
339
|
method: "DELETE",
|
|
340
|
+
body: JSON.stringify(parsed),
|
|
337
341
|
});
|
|
338
342
|
},
|
|
343
|
+
async previewNodePurge(input) {
|
|
344
|
+
const parsed = PurgeNodePreviewInput.parse(input);
|
|
345
|
+
return await request(
|
|
346
|
+
`/nodes/${encodeURIComponent(parsed.nodeId)}/purge-preview`,
|
|
347
|
+
PurgeNodePreview,
|
|
348
|
+
);
|
|
349
|
+
},
|
|
339
350
|
async purgeFlow(input) {
|
|
340
351
|
const parsed = PurgeFlowInput.parse(input);
|
|
341
352
|
return await request(`/flows/${encodeURIComponent(parsed.flowId)}`, PurgeFlowResult, {
|
|
@@ -351,6 +362,12 @@ export function createIntelDataProvider(
|
|
|
351
362
|
async listGrants(resourceId) {
|
|
352
363
|
return await request(`/nodes/${encodeURIComponent(resourceId)}/grants`, ResourceGrantList);
|
|
353
364
|
},
|
|
365
|
+
async listEffectiveAccess(resourceId) {
|
|
366
|
+
return await request(
|
|
367
|
+
`/nodes/${encodeURIComponent(resourceId)}/effective-access`,
|
|
368
|
+
ResourceAccessList,
|
|
369
|
+
);
|
|
370
|
+
},
|
|
354
371
|
async shareNode(input) {
|
|
355
372
|
const parsed = ShareInput.parse(input);
|
|
356
373
|
return await request(`/nodes/${encodeURIComponent(parsed.resourceId)}/grants`, ShareResult, {
|
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
NodeTable,
|
|
41
41
|
NodeVersionList,
|
|
42
42
|
PurgeNodeInput,
|
|
43
|
+
PurgeNodePreviewInput,
|
|
43
44
|
PurgeNodeResult,
|
|
44
45
|
ReindexResult,
|
|
45
46
|
ResolveNodeLinksInput,
|
|
@@ -132,9 +133,15 @@ export interface IntelDataProvider {
|
|
|
132
133
|
// ⚠️ The one way across this seam after which nothing is really left (#457). The title comes back
|
|
133
134
|
// because nothing can look it up afterwards.
|
|
134
135
|
purgeNode(input: PurgeNodeInput): Promise<PurgeNodeResult>;
|
|
136
|
+
previewNodePurge(
|
|
137
|
+
input: PurgeNodePreviewInput,
|
|
138
|
+
): Promise<import("@anchrd/intel-contract/node").PurgeNodePreview>;
|
|
135
139
|
purgeFlow(input: PurgeFlowInput): Promise<PurgeFlowResult>;
|
|
136
140
|
searchNodes(input: SearchInput): Promise<SearchResult>;
|
|
137
141
|
listGrants(resourceId: string): Promise<ResourceGrantList>;
|
|
142
|
+
listEffectiveAccess(
|
|
143
|
+
resourceId: string,
|
|
144
|
+
): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
|
|
138
145
|
// The grant, and what the grant does not cover: the documents the flows in this folder read that
|
|
139
146
|
// the new principal still cannot. A warning, never a refusal (ADR-0004 §4).
|
|
140
147
|
shareNode(input: ShareInput): Promise<ShareResult>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import { useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { ResourceAccessSummary } from "@/access-summary/access-summary.tsx";
|
|
3
4
|
import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
|
|
4
5
|
import {
|
|
5
6
|
Table,
|
|
@@ -84,6 +85,9 @@ export function FolderContents({ folderId }: { folderId: string }) {
|
|
|
84
85
|
<TableHead className="h-9 px-4 text-right text-xs tracking-wide uppercase">
|
|
85
86
|
{i18n.t("node.changed")}
|
|
86
87
|
</TableHead>
|
|
88
|
+
<TableHead className="h-9 px-4 text-xs tracking-wide uppercase">
|
|
89
|
+
{i18n.t("node.access")}
|
|
90
|
+
</TableHead>
|
|
87
91
|
{/* A `<th>` with nothing in it leaves the column unnamed for anyone reading the table
|
|
88
92
|
by its headers. The word is there; only the eye is spared it. */}
|
|
89
93
|
<TableHead className="h-9 w-14">
|
|
@@ -130,6 +134,14 @@ export function FolderContents({ folderId }: { folderId: string }) {
|
|
|
130
134
|
<TableCell className="px-4 text-right text-sm text-muted-foreground tabular-nums">
|
|
131
135
|
{dateTime.on(changed)}
|
|
132
136
|
</TableCell>
|
|
137
|
+
<TableCell className="px-4">
|
|
138
|
+
<ResourceAccessSummary
|
|
139
|
+
resourceId={
|
|
140
|
+
entry.type === "flow" ? (entry.flow.parentId ?? folderId) : entry.id
|
|
141
|
+
}
|
|
142
|
+
ownerId={entry.type === "flow" ? entry.flow.ownerId : entry.node.ownerId}
|
|
143
|
+
/>
|
|
144
|
+
</TableCell>
|
|
133
145
|
{/* ⚠️ The same menu as the title line of the open thing, not a shorter one built
|
|
134
146
|
for here. #58 collected every action in one place so that place is always the
|
|
135
147
|
same; a folder listing offering three of the six would be a second place. */}
|
package/src/i18n/de.json
CHANGED
|
@@ -85,15 +85,24 @@
|
|
|
85
85
|
"archive.description": "Alles, was du archiviert hast, das Neueste zuerst. Beim Wiederherstellen kommt ein Eintrag dorthin zurück, wo er lag.",
|
|
86
86
|
"archive.restore": "{title} wiederherstellen",
|
|
87
87
|
"archive.restoreAction": "Wiederherstellen",
|
|
88
|
+
"archive.restoring": "{title} wird wiederhergestellt",
|
|
89
|
+
"archive.restoringAction": "Wird wiederhergestellt",
|
|
88
90
|
"archive.empty": "Es ist nichts archiviert.",
|
|
89
91
|
"archive.failed": "Das Archiv konnte nicht gelesen werden. Prüfe deinen Zugriff und versuche es erneut.",
|
|
90
92
|
"archive.restoreFailed": "Es wurde nicht wiederhergestellt. Vielleicht hat jemand anderes es geändert — lade neu und versuche es erneut.",
|
|
91
93
|
"archive.purge": "{title} endgültig löschen",
|
|
92
94
|
"archive.purge.title": "Endgültig löschen?",
|
|
93
95
|
"archive.purge.body": "„{title}\u201c und alles, was dazugehört — jede Version, der Inhalt und die Einträge im Suchindex — ist danach weg. Das lässt sich nicht rückgängig machen.",
|
|
96
|
+
"archive.purge.links.none": "Keine anderen Dokumente verweisen darauf.",
|
|
97
|
+
"archive.purge.links.one": "Ein anderes Dokument verweist darauf; dieser Verweis wird brechen.",
|
|
98
|
+
"archive.purge.links.many": "{count} andere Dokumente verweisen darauf; diese Verweise werden brechen.",
|
|
99
|
+
"archive.purge.grants": "Die Freigaben werden ebenfalls gelöscht.",
|
|
100
|
+
"archive.purge.items": "Damit werden insgesamt {count} Elemente endgültig gelöscht.",
|
|
101
|
+
"archive.purge.previewLoading": "Verweise werden geprüft …",
|
|
102
|
+
"archive.purge.previewFailed": "Die Verweise konnten nicht geprüft werden. Lade neu und versuche es vor dem Löschen erneut.",
|
|
94
103
|
"archive.purge.confirm": "Endgültig löschen",
|
|
95
104
|
"archive.purge.cancel": "Abbrechen",
|
|
96
|
-
"archive.purgeFailed": "Es konnte nicht gelöscht werden.
|
|
105
|
+
"archive.purgeFailed": "Es konnte nicht vollständig gelöscht werden. Lade neu und versuche es erneut.",
|
|
97
106
|
"archive.archivedAt": "Archiviert {when}",
|
|
98
107
|
"resource.conflict": "Nicht geändert: Jemand anderes hat diesen Eintrag zuerst geändert. Lade ihn neu und versuche es erneut.",
|
|
99
108
|
"resource.forbidden": "Nicht geändert: Du darfst diesen Eintrag nicht ändern.",
|
|
@@ -119,7 +128,6 @@
|
|
|
119
128
|
"node.share": "Freigeben",
|
|
120
129
|
"node.shareAction": "Zugriff geben",
|
|
121
130
|
"node.email": "E-Mail-Adresse",
|
|
122
|
-
"node.emailHint": "Intel kann nicht prüfen, ob hinter dieser Adresse ein Konto steht. Die Freigabe greift, sobald sich jemand damit anmeldet — auch nach einer späteren Registrierung.",
|
|
123
131
|
"node.shareWith": "Wer Zugriff bekommt",
|
|
124
132
|
"node.shareWithEmail": "Eine E-Mail-Adresse",
|
|
125
133
|
"node.shareWithOrganization": "Alle in der Organisation",
|
|
@@ -137,8 +145,13 @@
|
|
|
137
145
|
"node.verbHint.execute": "Die Flows hier drin starten",
|
|
138
146
|
"node.verbHint.share": "Anderen Zugriff geben",
|
|
139
147
|
"node.organization": "Alle in der Organisation",
|
|
148
|
+
"node.owner": "Besitzer",
|
|
149
|
+
"node.access": "Zugriff",
|
|
150
|
+
"node.organizationAccess": "Alle in der Organisation haben Zugriff",
|
|
151
|
+
"node.accessDetails": "Zugriffsdetails anzeigen ({count})",
|
|
152
|
+
"node.revokeVerb": "{verb} entziehen",
|
|
153
|
+
"node.revokeVerbFor": "{verb} für {recipient} entziehen",
|
|
140
154
|
"node.revokeShare": "Zugriff entziehen",
|
|
141
|
-
"node.noGrants": "Außer der Besitzerin oder dem Besitzer hat noch niemand Zugriff.",
|
|
142
155
|
"node.shareFailed": "Der Zugriff wurde nicht geändert. Prüfe deine Berechtigung und versuche es erneut.",
|
|
143
156
|
"node.shareUnreadable": "Flows in diesem Ordner lesen Dokumente, die diese Freigabe nicht abdeckt: {titles}.",
|
|
144
157
|
"node.shareUnreadableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
|
package/src/i18n/en.json
CHANGED
|
@@ -85,15 +85,24 @@
|
|
|
85
85
|
"archive.description": "Everything you archived, newest first. Restoring puts an entry back where it was filed.",
|
|
86
86
|
"archive.restore": "Restore {title}",
|
|
87
87
|
"archive.restoreAction": "Restore",
|
|
88
|
+
"archive.restoring": "Restoring {title}",
|
|
89
|
+
"archive.restoringAction": "Restoring",
|
|
88
90
|
"archive.empty": "Nothing is archived.",
|
|
89
91
|
"archive.failed": "The archive could not be read. Check your access and try again.",
|
|
90
92
|
"archive.restoreFailed": "It was not restored. Somebody else may have changed it — reload and try again.",
|
|
91
93
|
"archive.purge": "Delete {title} for good",
|
|
92
94
|
"archive.purge.title": "Delete this for good?",
|
|
93
95
|
"archive.purge.body": "“{title}” and everything belonging to it — every version, its content and its entries in the search index — will be gone. This cannot be undone.",
|
|
96
|
+
"archive.purge.links.none": "No other documents link to it.",
|
|
97
|
+
"archive.purge.links.one": "One other document links to it and that link will break.",
|
|
98
|
+
"archive.purge.links.many": "{count} other documents link to it and those links will break.",
|
|
99
|
+
"archive.purge.grants": "Its shares will also be deleted.",
|
|
100
|
+
"archive.purge.items": "This permanently deletes {count} items in total.",
|
|
101
|
+
"archive.purge.previewLoading": "Checking links…",
|
|
102
|
+
"archive.purge.previewFailed": "The links could not be checked. Reload and try again before deleting.",
|
|
94
103
|
"archive.purge.confirm": "Delete for good",
|
|
95
104
|
"archive.purge.cancel": "Cancel",
|
|
96
|
-
"archive.purgeFailed": "It could not be deleted.
|
|
105
|
+
"archive.purgeFailed": "It could not be deleted completely. Reload and try again.",
|
|
97
106
|
"archive.archivedAt": "Archived {when}",
|
|
98
107
|
"resource.conflict": "It was not changed: somebody else changed this entry first. Reload it and try again.",
|
|
99
108
|
"resource.forbidden": "It was not changed: you may not change this entry.",
|
|
@@ -119,7 +128,6 @@
|
|
|
119
128
|
"node.share": "Share",
|
|
120
129
|
"node.shareAction": "Grant access",
|
|
121
130
|
"node.email": "Email address",
|
|
122
|
-
"node.emailHint": "Intel cannot check whether an account exists behind this address. The grant takes effect as soon as somebody signs in with it — including after they register.",
|
|
123
131
|
"node.shareWith": "Who gets access",
|
|
124
132
|
"node.shareWithEmail": "One email address",
|
|
125
133
|
"node.shareWithOrganization": "Everyone in the organization",
|
|
@@ -137,8 +145,13 @@
|
|
|
137
145
|
"node.verbHint.execute": "Start the flows in here",
|
|
138
146
|
"node.verbHint.share": "Give others access",
|
|
139
147
|
"node.organization": "Everyone in the organization",
|
|
148
|
+
"node.owner": "Owner",
|
|
149
|
+
"node.access": "Access",
|
|
150
|
+
"node.organizationAccess": "Everyone in the organization has access",
|
|
151
|
+
"node.accessDetails": "Show access details ({count})",
|
|
152
|
+
"node.revokeVerb": "Revoke {verb}",
|
|
153
|
+
"node.revokeVerbFor": "Revoke {verb} for {recipient}",
|
|
140
154
|
"node.revokeShare": "Revoke access",
|
|
141
|
-
"node.noGrants": "Nobody outside the owner has access yet.",
|
|
142
155
|
"node.shareFailed": "Access was not changed. Check your permission and try again.",
|
|
143
156
|
"node.shareUnreadable": "Flows in this folder read documents this grant does not cover: {titles}.",
|
|
144
157
|
"node.shareUnreadableMore": "{count} more are out of reach too, and you cannot see them.",
|
package/src/i18n/es.json
CHANGED
|
@@ -85,15 +85,24 @@
|
|
|
85
85
|
"archive.description": "Todo lo que has archivado, lo más reciente primero. Al restaurar, una entrada vuelve al sitio donde estaba.",
|
|
86
86
|
"archive.restore": "Restaurar {title}",
|
|
87
87
|
"archive.restoreAction": "Restaurar",
|
|
88
|
+
"archive.restoring": "Restaurando {title}",
|
|
89
|
+
"archive.restoringAction": "Restaurando",
|
|
88
90
|
"archive.empty": "No hay nada archivado.",
|
|
89
91
|
"archive.failed": "El archivo no se ha podido leer. Comprueba tu acceso e inténtalo de nuevo.",
|
|
90
92
|
"archive.restoreFailed": "No se ha restaurado. Puede que otra persona lo haya cambiado — recarga e inténtalo de nuevo.",
|
|
91
93
|
"archive.purge": "Eliminar «{title}» definitivamente",
|
|
92
94
|
"archive.purge.title": "¿Eliminar definitivamente?",
|
|
93
95
|
"archive.purge.body": "«{title}» y todo lo que le pertenece — cada versión, su contenido y sus entradas en el índice de búsqueda — desaparecerá. Esto no se puede deshacer.",
|
|
96
|
+
"archive.purge.links.none": "Ningún otro documento contiene un enlace a este elemento.",
|
|
97
|
+
"archive.purge.links.one": "Otro documento contiene un enlace a este elemento y ese enlace dejará de funcionar.",
|
|
98
|
+
"archive.purge.links.many": "Otros {count} documentos contienen enlaces a este elemento y dejarán de funcionar.",
|
|
99
|
+
"archive.purge.grants": "Sus permisos compartidos también se eliminarán.",
|
|
100
|
+
"archive.purge.items": "Esto elimina definitivamente {count} elementos en total.",
|
|
101
|
+
"archive.purge.previewLoading": "Comprobando enlaces…",
|
|
102
|
+
"archive.purge.previewFailed": "No se pudieron comprobar los enlaces. Recarga e inténtalo de nuevo antes de eliminar.",
|
|
94
103
|
"archive.purge.confirm": "Eliminar definitivamente",
|
|
95
104
|
"archive.purge.cancel": "Cancelar",
|
|
96
|
-
"archive.purgeFailed": "No se pudo eliminar
|
|
105
|
+
"archive.purgeFailed": "No se pudo eliminar por completo. Recarga e inténtalo de nuevo.",
|
|
97
106
|
"archive.archivedAt": "Archivado {when}",
|
|
98
107
|
"resource.conflict": "No se ha cambiado: otra persona ha cambiado esta entrada antes. Recárgala e inténtalo de nuevo.",
|
|
99
108
|
"resource.forbidden": "No se ha cambiado: no puedes cambiar esta entrada.",
|
|
@@ -119,7 +128,6 @@
|
|
|
119
128
|
"node.share": "Compartir",
|
|
120
129
|
"node.shareAction": "Conceder acceso",
|
|
121
130
|
"node.email": "Dirección de correo",
|
|
122
|
-
"node.emailHint": "Intel no puede comprobar si existe una cuenta detrás de esta dirección. El permiso surte efecto en cuanto alguien inicie sesión con ella, incluso si se registra más tarde.",
|
|
123
131
|
"node.shareWith": "Quién obtiene acceso",
|
|
124
132
|
"node.shareWithEmail": "Una dirección de correo",
|
|
125
133
|
"node.shareWithOrganization": "Todos en la organización",
|
|
@@ -137,8 +145,13 @@
|
|
|
137
145
|
"node.verbHint.execute": "Iniciar los flujos que hay aquí dentro",
|
|
138
146
|
"node.verbHint.share": "Dar acceso a otras personas",
|
|
139
147
|
"node.organization": "Todas las personas de la organización",
|
|
148
|
+
"node.owner": "Propietario",
|
|
149
|
+
"node.access": "Acceso",
|
|
150
|
+
"node.organizationAccess": "Toda la organización tiene acceso",
|
|
151
|
+
"node.accessDetails": "Mostrar detalles de acceso ({count})",
|
|
152
|
+
"node.revokeVerb": "Retirar {verb}",
|
|
153
|
+
"node.revokeVerbFor": "Retirar {verb} a {recipient}",
|
|
140
154
|
"node.revokeShare": "Retirar el acceso",
|
|
141
|
-
"node.noGrants": "Fuera de quien lo posee, todavía no tiene acceso nadie.",
|
|
142
155
|
"node.shareFailed": "El acceso no se ha cambiado. Comprueba tu permiso e inténtalo de nuevo.",
|
|
143
156
|
"node.shareUnreadable": "Los flujos de esta carpeta leen documentos que este acceso no cubre: {titles}.",
|
|
144
157
|
"node.shareUnreadableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Flow, FlowValidation } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
|
|
4
4
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
5
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
6
6
|
import {
|
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
Pencil,
|
|
16
16
|
Share2,
|
|
17
17
|
ShieldCheck,
|
|
18
|
-
Trash2,
|
|
19
18
|
} from "lucide-react";
|
|
20
19
|
import type * as React from "react";
|
|
21
20
|
import { useState } from "react";
|
|
21
|
+
import { AccessSummary } from "@/access-summary/access-summary.tsx";
|
|
22
22
|
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
23
23
|
import {
|
|
24
24
|
DropdownMenu,
|
|
@@ -38,7 +38,6 @@ import { Modal } from "@/modal/modal.tsx";
|
|
|
38
38
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
39
39
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
40
40
|
import { useDateTime } from "@/time/time-context.tsx";
|
|
41
|
-
import { useUserName } from "@/user-name/user-name.ts";
|
|
42
41
|
|
|
43
42
|
/**
|
|
44
43
|
* What a resource's own actions are, at the place the resource stands (#24).
|
|
@@ -638,54 +637,6 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
|
638
637
|
// flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
|
|
639
638
|
// server's answer, not this component's: `execute` never appears on a document, because a document
|
|
640
639
|
// has nothing to run.
|
|
641
|
-
/**
|
|
642
|
-
* One row of the grant list.
|
|
643
|
-
*
|
|
644
|
-
* ⚠️ It is a component rather than a line inside the `map` because of `useUserName`, and that hook
|
|
645
|
-
* is the point: until #431 a `user` grant printed its raw id — `2C9lEhT82upO82abPjHFAvs24a7Vdd3J`
|
|
646
|
-
* beside the word "Read" — which tells the reader nothing about who they let in and puts an
|
|
647
|
-
* identifier on a screen that has no reason to carry one. `user-name.ts` already had the rule
|
|
648
|
-
* ("never fall back to the id, show nothing instead", #258); this row was the last place breaking it.
|
|
649
|
-
*
|
|
650
|
-
* ⚠️ Intel can resolve exactly ONE id today: the signed-in person's. A foreign one is not "a name we
|
|
651
|
-
* could probably guess" but genuinely unanswerable — Intel has no user directory (#261) — so what
|
|
652
|
-
* stands there is what is true: an account, unnamed. The day #261 lands, `useUserName` starts
|
|
653
|
-
* answering and this row needs no change.
|
|
654
|
-
*/
|
|
655
|
-
function GrantRow({ grant, onRevoke }: { grant: ResourceGrant; onRevoke(grantId: string): void }) {
|
|
656
|
-
const i18n = useI18n();
|
|
657
|
-
const userName = useUserName(grant.principal.type === "user" ? grant.principal.id : null);
|
|
658
|
-
// ⚠️ An `email` grant keeps showing the ADDRESS, even where a name is known, and that is a
|
|
659
|
-
// decision rather than an oversight (#431, review finding). The grant is bound to the address —
|
|
660
|
-
// `db-grants.ts` matches `lower(principal_id)` against whoever signs in — not to an account.
|
|
661
|
-
// Putting a name there would claim a binding that does not exist, and it would be wrong the moment
|
|
662
|
-
// somebody else verifies that address.
|
|
663
|
-
const who =
|
|
664
|
-
grant.principal.type === "email"
|
|
665
|
-
? grant.principal.email
|
|
666
|
-
: grant.principal.type === "user"
|
|
667
|
-
? (userName ?? i18n.t("node.someUser"))
|
|
668
|
-
: i18n.t("node.organization");
|
|
669
|
-
return (
|
|
670
|
-
<li className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm">
|
|
671
|
-
<span className="min-w-0 truncate">
|
|
672
|
-
{who}
|
|
673
|
-
<span className="ml-2 text-xs text-muted-foreground">
|
|
674
|
-
{i18n.t(`node.verb.${grant.verb}`)}
|
|
675
|
-
</span>
|
|
676
|
-
</span>
|
|
677
|
-
<button
|
|
678
|
-
type="button"
|
|
679
|
-
onClick={() => onRevoke(grant.id)}
|
|
680
|
-
aria-label={i18n.t("node.revokeShare")}
|
|
681
|
-
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
682
|
-
>
|
|
683
|
-
<Trash2 aria-hidden="true" className="size-4" />
|
|
684
|
-
</button>
|
|
685
|
-
</li>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
640
|
/**
|
|
690
641
|
* Which of the three principals the dialog can SET (#431).
|
|
691
642
|
*
|
|
@@ -753,7 +704,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
753
704
|
// before, and the user would read a permission picture that is not the one in force. Of all the
|
|
754
705
|
// things to be silently wrong about, access is the worst.
|
|
755
706
|
onSettled: async () => {
|
|
756
|
-
await
|
|
707
|
+
await Promise.all([
|
|
708
|
+
queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
|
|
709
|
+
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
710
|
+
]);
|
|
757
711
|
},
|
|
758
712
|
});
|
|
759
713
|
const revoke = useMutation({
|
|
@@ -764,7 +718,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
764
718
|
idempotencyKey: crypto.randomUUID(),
|
|
765
719
|
}),
|
|
766
720
|
onSettled: async () => {
|
|
767
|
-
await
|
|
721
|
+
await Promise.all([
|
|
722
|
+
queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
|
|
723
|
+
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
724
|
+
]);
|
|
768
725
|
},
|
|
769
726
|
});
|
|
770
727
|
|
|
@@ -820,21 +777,17 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
820
777
|
<p className="mt-1 text-xs text-muted-foreground">{i18n.t("node.shareUnreadableHint")}</p>
|
|
821
778
|
</div>
|
|
822
779
|
)}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
<
|
|
826
|
-
|
|
827
|
-
grant={grant}
|
|
780
|
+
{grants.data && grants.data.items.length > 0 ? (
|
|
781
|
+
<div className="mb-5">
|
|
782
|
+
<AccessSummary
|
|
783
|
+
grants={grants.data.items}
|
|
828
784
|
onRevoke={(grantId) => {
|
|
829
785
|
share.reset();
|
|
830
786
|
revoke.mutate(grantId);
|
|
831
787
|
}}
|
|
832
788
|
/>
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
<li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
|
|
836
|
-
)}
|
|
837
|
-
</ul>
|
|
789
|
+
</div>
|
|
790
|
+
) : null}
|
|
838
791
|
<form
|
|
839
792
|
onSubmit={(event) => {
|
|
840
793
|
event.preventDefault();
|
|
@@ -860,7 +813,7 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
860
813
|
name="share-principal"
|
|
861
814
|
checked={principalKind === kind}
|
|
862
815
|
onChange={() => setPrincipalKind(kind)}
|
|
863
|
-
aria-describedby={kind === "
|
|
816
|
+
aria-describedby={kind === "organization" ? "share-organization-hint" : undefined}
|
|
864
817
|
className="size-4 border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
865
818
|
/>
|
|
866
819
|
<span>
|
|
@@ -882,19 +835,9 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
882
835
|
required
|
|
883
836
|
value={email}
|
|
884
837
|
onChange={(event) => setEmail(event.target.value)}
|
|
885
|
-
aria-describedby="share-email-hint"
|
|
886
838
|
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
887
839
|
/>
|
|
888
840
|
</label>
|
|
889
|
-
{/* ⚠️ What this says is what Intel knows, and no more (#431). It cannot check whether an
|
|
890
|
-
address belongs to anybody — Gate owns identity and offers Intel no lookup (#261) —
|
|
891
|
-
and a dialog that stayed silent about that let a typo become a grant that is valid,
|
|
892
|
-
permanent and completely without effect, with nobody on either side to notice. Naming
|
|
893
|
-
the uncertainty is the honest half of what the ticket asks for; the other half needs
|
|
894
|
-
Gate. */}
|
|
895
|
-
<p id="share-email-hint" className="mt-1 text-xs text-muted-foreground">
|
|
896
|
-
{i18n.t("node.emailHint")}
|
|
897
|
-
</p>
|
|
898
841
|
</div>
|
|
899
842
|
) : (
|
|
900
843
|
/* ⚠️ `aria-live` on the CONTAINER, which is already mounted whenever this branch is on
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type * as React from "react";
|
|
2
|
+
import { ResourceAccessSummary } from "@/access-summary/access-summary.tsx";
|
|
2
3
|
import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -33,6 +34,9 @@ export function TitleRow({
|
|
|
33
34
|
<div className="min-w-0">
|
|
34
35
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3">
|
|
35
36
|
<h2 className="truncate text-lg font-semibold">{title}</h2>
|
|
37
|
+
{target.type === "node" && target.node.kind === "folder" ? (
|
|
38
|
+
<ResourceAccessSummary resourceId={target.node.id} ownerId={target.node.ownerId} />
|
|
39
|
+
) : null}
|
|
36
40
|
<span data-slot="title-meta" className="text-sm text-muted-foreground" />
|
|
37
41
|
</div>
|
|
38
42
|
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
|