@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.23.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.16.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
+ }
@@ -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
- node.archivedAt
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
- {/* ⚠️ Both icons stand PERMANENTLY, not only on hover a deliberate exception to the
177
- row convention (#457). There are two of them, both rare, and this list is the only
178
- place they occur: the wall of icons anchrd/gate#237 was written against does not
179
- arise here. The way back keeps its text; the purge is a bare icon, so that the two
180
- do not weigh the same. */}
181
- <button
182
- type="button"
183
- disabled={restore.isPending}
184
- onClick={() => restore.mutate(entry)}
185
- aria-label={i18n.t("archive.restore", { title: entry.title })}
186
- className="inline-flex h-8 shrink-0 items-center gap-2 rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
187
- >
188
- <ArchiveRestore aria-hidden="true" className="size-4" />
189
- {i18n.t("archive.restoreAction")}
190
- </button>
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={purge.isPending}
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. Ein Ordner muss zuerst leer sein, und was ein publizierter Flow noch benutzt, kann gar nicht weg.",
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. A folder has to be empty first, and a node a published flow still uses cannot go at all.",
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. Una carpeta debe estar vacía primero, y lo que un flujo publicado todavía usa no puede eliminarse.",
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 { ResourceGrant, ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
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 queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] });
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 queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] });
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
- <ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
824
- {grants.data?.items.map((grant) => (
825
- <GrantRow
826
- key={grant.id}
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
- {grants.data?.items.length === 0 && (
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 === "email" ? "share-email-hint" : "share-organization-hint"}
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}