@anchrd/intel-ui 0.20.0 → 0.22.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/README.md +33 -0
- package/package.json +2 -2
- package/src/app/app-tree/app-tree.tsx +35 -4
- package/src/data/query-client.ts +18 -0
- package/src/data/request-refusal/request-refusal.ts +51 -0
- package/src/flows/flows.tsx +8 -1
- package/src/i18n/de.json +16 -3
- package/src/i18n/en.json +16 -3
- package/src/i18n/es.json +16 -3
- package/src/main.tsx +3 -2
- package/src/node-editor/node-editor.tsx +14 -1
- package/src/nodes/nodes.tsx +20 -0
- package/src/resource-menu/resource-menu.tsx +204 -39
- package/src/save-button/save-button.tsx +11 -1
- package/src/tools/tools.tsx +146 -43
package/README.md
CHANGED
|
@@ -43,6 +43,39 @@ Point `intel.json` at your files:
|
|
|
43
43
|
Missing assets, non-SVG branding and incomplete language catalogs fail the build rather than
|
|
44
44
|
silently producing a half-branded application.
|
|
45
45
|
|
|
46
|
+
## What an empty screen means
|
|
47
|
+
|
|
48
|
+
Most "the UI is broken" reports are one of four situations, and the application distinguishes them
|
|
49
|
+
rather than showing one blank page for all of them. The full explanation of the two permission
|
|
50
|
+
layers is in the
|
|
51
|
+
[`@anchrd/intel-api` README](https://www.npmjs.com/package/@anchrd/intel-api); what follows is what
|
|
52
|
+
each one looks like here.
|
|
53
|
+
|
|
54
|
+
| On screen | What it is |
|
|
55
|
+
|---|---|
|
|
56
|
+
| "You do not have permission to see this." | The Gate capability is missing — `nodes.read` and friends |
|
|
57
|
+
| An empty tree, no error | Nothing has been shared with this person yet |
|
|
58
|
+
| "This is not available to you. It may not exist, or it may no longer be shared with you." | A node that was reachable and is not any more, or never existed — Intel deliberately does not say which |
|
|
59
|
+
| Skeletons that never resolve | Should no longer happen: a refusal ends the loading state on the first answer, and no button offers a retry that cannot change anything |
|
|
60
|
+
|
|
61
|
+
A node somebody shares appears in the recipient's sidebar on its own, at the top level, without
|
|
62
|
+
naming the folders above it. Nobody needs to be sent a link.
|
|
63
|
+
|
|
64
|
+
The sharing dialog grants to an email address or to the whole organization. It never shows a raw
|
|
65
|
+
user id: where Intel cannot name an account it says so. ⚠️ Choosing **execute for the whole
|
|
66
|
+
organization** on a folder makes that folder a library that flows from anywhere may call into, and
|
|
67
|
+
withdrawing it is refused while a caller remains — the dialog says so before the click.
|
|
68
|
+
|
|
69
|
+
The Tools screen tells four situations apart — refused, expired, broken, and started-but-never-
|
|
70
|
+
answered — and offers a second attempt everywhere a second attempt could change the answer.
|
|
71
|
+
|
|
72
|
+
The editor answers "Nothing to save" rather than "Saved" when the write produced no new version.
|
|
73
|
+
The two are different outcomes and only one of them means the text is safe to walk away from.
|
|
74
|
+
|
|
75
|
+
⚠️ The editor opens **empty** on content it cannot parse. Documents written over HTTP or MCP have to
|
|
76
|
+
carry the BlockNote media type to be editable in the browser; see the
|
|
77
|
+
[`@anchrd/intel-contract` README](https://www.npmjs.com/package/@anchrd/intel-contract).
|
|
78
|
+
|
|
46
79
|
## What is inside
|
|
47
80
|
|
|
48
81
|
Vite, React, TanStack Router and Query, with shadcn components. The node editor uses BlockNote
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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.15.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from "@/components/ui/sidebar";
|
|
36
36
|
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
37
37
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
38
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
38
39
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
39
40
|
import { type ImportKind, useBundleImport } from "@/import-bundle/import-bundle.tsx";
|
|
40
41
|
import { kindIcons } from "@/kind-icon.ts";
|
|
@@ -337,6 +338,19 @@ export function AppTree() {
|
|
|
337
338
|
</SidebarMenu>
|
|
338
339
|
);
|
|
339
340
|
}
|
|
341
|
+
// ⚠️ A refusal, not a failure (#430). While this branch did not exist, a reader whose share had
|
|
342
|
+
// been revoked — or who holds no `intel.nodes.read` at all — watched the three skeleton bars
|
|
343
|
+
// above for as long as the retries lasted, and the retries are paused whenever the tab is not
|
|
344
|
+
// in front. Neither of those two cases is worth trying again, so what stands here is a sentence
|
|
345
|
+
// and no button.
|
|
346
|
+
const refusal = refusalOf(level.error);
|
|
347
|
+
if (refusal) {
|
|
348
|
+
return (
|
|
349
|
+
<p role="status" className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
350
|
+
{i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
|
|
351
|
+
</p>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
340
354
|
if (level.isError) {
|
|
341
355
|
return (
|
|
342
356
|
<div role="alert" className="space-y-2 px-2 py-1.5 text-sm">
|
|
@@ -376,15 +390,16 @@ export function AppTree() {
|
|
|
376
390
|
}
|
|
377
391
|
return (
|
|
378
392
|
<SidebarMenu aria-label={label}>
|
|
379
|
-
{entries.map((entry) => renderRow(entry,
|
|
393
|
+
{entries.map((entry) => renderRow(entry, parent, ancestors))}
|
|
380
394
|
</SidebarMenu>
|
|
381
395
|
);
|
|
382
396
|
}
|
|
383
397
|
|
|
384
|
-
// ⚠️
|
|
398
|
+
// ⚠️ A level under a FLOW is derived: its rows are that flow's calls, read out of its graph
|
|
385
399
|
// (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
|
|
386
400
|
// dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
|
|
387
|
-
function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string
|
|
401
|
+
function renderRow(entry: TreeEntry, parent: Level, ancestors: ReadonlySet<string>) {
|
|
402
|
+
const derived = parent.type === "flow";
|
|
388
403
|
const isFolder = entry.kind === "folder";
|
|
389
404
|
// ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
|
|
390
405
|
// from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
|
|
@@ -400,7 +415,23 @@ export function AppTree() {
|
|
|
400
415
|
const area = entry.type === "flow" ? "/flows" : "/nodes";
|
|
401
416
|
const isActive = location.select === entry.id && location.pathname === area;
|
|
402
417
|
// A row's plus files into that row's place: inside a folder, beside anything else.
|
|
403
|
-
|
|
418
|
+
//
|
|
419
|
+
// ⚠️ "Beside it" is the level this row is shown IN, not the parent its own record names. Since
|
|
420
|
+
// #429 the root level also carries rows whose `parentId` points at a folder this reader may not
|
|
421
|
+
// see — a shared document is filed inside the sharer's folder, and only the share reaches down
|
|
422
|
+
// to it. Filing beside such a row through that id would try to write into a folder the reader
|
|
423
|
+
// cannot even open, and the refusal would arrive as a bare "could not be created".
|
|
424
|
+
//
|
|
425
|
+
// ⚠️ A derived level is the exception, because it has no folder to be filed in: what a flow
|
|
426
|
+
// calls is read from its graph, so the record's own parent is the only answer available there.
|
|
427
|
+
//
|
|
428
|
+
// ⚠️ It is the only one, not a safe one. A called flow may be a LIBRARY flow — `execute` without
|
|
429
|
+
// `read`, which ADR-0004 §2/§3 allows on purpose — and its folder is then one this reader may
|
|
430
|
+
// not open at all. The plus there can still be refused, exactly as the plus on any read-only
|
|
431
|
+
// folder row elsewhere in the tree has always been able to be. That is the older question of
|
|
432
|
+
// what a plus should do where writing is not allowed, and it is not this ticket's: what #429
|
|
433
|
+
// changed is a plus pointing at a folder that is not even the level being shown.
|
|
434
|
+
const target = isFolder ? entry.id : derived ? parentOf(entry) : parent.id;
|
|
404
435
|
// Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
|
|
405
436
|
// default cursor while a drag is in progress, which is the answer "not here" without a word.
|
|
406
437
|
const drop =
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { retryRequest } from "@/data/request-refusal/request-refusal.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one query client the application runs on.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ It exists as a function rather than as the literal `new QueryClient()` that stood in `main.tsx`
|
|
8
|
+
* so the policy in it can be exercised by a test (#430). A default nothing renders under is a
|
|
9
|
+
* default nothing checks, and this one decides how long a screen stays on "Loading…".
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ Flat, and without a `.unit.ts` of its own on purpose: the only thing to assert here is that the
|
|
12
|
+
* returned client carries `retryRequest`, and what that predicate DOES is already covered next door
|
|
13
|
+
* in `request-refusal.unit.ts` — twice over, because two screen tests render against this very
|
|
14
|
+
* client. A test that restated the wiring would be the "pure wiring" the repository rules exclude.
|
|
15
|
+
*/
|
|
16
|
+
export function createIntelQueryClient(): QueryClient {
|
|
17
|
+
return new QueryClient({ defaultOptions: { queries: { retry: retryRequest } } });
|
|
18
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a failed request already settles, as opposed to what it might answer differently next time.
|
|
5
|
+
*
|
|
6
|
+
* - `not-available` is Intel's `404`. It is deliberately the same answer for "there is no such node"
|
|
7
|
+
* and "you may not reach this node" — `requireVisible` in `packages/api/src/nodes/nodes.ts`, held
|
|
8
|
+
* in place by "answers 404 for a foreign version and for an unreadable node" in `nodes.int.ts`.
|
|
9
|
+
* A screen must therefore not guess between them either: one sentence covers both, and that is why
|
|
10
|
+
* `no-permission` is a separate value rather than prose that could be shown for a `404` by mistake.
|
|
11
|
+
* - `no-permission` is a `403`. The server said the word itself, so repeating it reveals nothing.
|
|
12
|
+
* - `null` is everything else: a timeout, a `502`, a network that came back. Those are worth trying
|
|
13
|
+
* again, and the screen keeps its retry button for them.
|
|
14
|
+
*/
|
|
15
|
+
export type Refusal = "not-available" | "no-permission";
|
|
16
|
+
|
|
17
|
+
/** The HTTP status the data provider recorded, or `null` where the failure carried none. */
|
|
18
|
+
export function statusOf(error: unknown): number | null {
|
|
19
|
+
return error instanceof IntelRequestError ? error.status : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function refusalOf(error: unknown): Refusal | null {
|
|
23
|
+
switch (statusOf(error)) {
|
|
24
|
+
case 403:
|
|
25
|
+
return "no-permission";
|
|
26
|
+
case 404:
|
|
27
|
+
return "not-available";
|
|
28
|
+
default:
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The retry policy every query in Intel runs under.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ A refusal is deterministic, and repeating it is what left the screen on "Loading…" after a
|
|
37
|
+
* share was revoked (#430). React Query's default is three retries with a growing delay, so a `404`
|
|
38
|
+
* kept the reader in front of a moving loading state for seconds — and a retry is **paused while
|
|
39
|
+
* the tab is unfocused** (`packages/ui/CLAUDE.md`, #212), so "seconds" has no upper bound whenever
|
|
40
|
+
* the person looked somewhere else. The answer was there the whole time.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ `408` and `429` are the two `4xx` that DO change on their own — a timeout and a rate limit both
|
|
43
|
+
* say "not now" rather than "not you" — so they keep the retries. Everything else in the range is
|
|
44
|
+
* about this request as it was made, and making it again cannot alter that.
|
|
45
|
+
*/
|
|
46
|
+
export function retryRequest(failureCount: number, error: unknown): boolean {
|
|
47
|
+
const status = statusOf(error);
|
|
48
|
+
const deterministic =
|
|
49
|
+
status !== null && status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
50
|
+
return !deterministic && failureCount < 3;
|
|
51
|
+
}
|
package/src/flows/flows.tsx
CHANGED
|
@@ -759,7 +759,14 @@ function FlowTitle({
|
|
|
759
759
|
<TooltipContent>{publishLabel}</TooltipContent>
|
|
760
760
|
</Tooltip>
|
|
761
761
|
</TooltipProvider>
|
|
762
|
-
|
|
762
|
+
{/* A draft that was never saved has no version, and the button says so rather than claiming
|
|
763
|
+
one (#432) — the same distinction the publish button next to it already draws. */}
|
|
764
|
+
<SaveButton
|
|
765
|
+
dirty={dirty && canMutate}
|
|
766
|
+
saving={saving}
|
|
767
|
+
stored={flow.currentVersionId !== null}
|
|
768
|
+
onSave={onSave}
|
|
769
|
+
/>
|
|
763
770
|
</TitleRow>
|
|
764
771
|
);
|
|
765
772
|
}
|
package/src/i18n/de.json
CHANGED
|
@@ -112,6 +112,14 @@
|
|
|
112
112
|
"node.share": "Freigeben",
|
|
113
113
|
"node.shareAction": "Zugriff geben",
|
|
114
114
|
"node.email": "E-Mail-Adresse",
|
|
115
|
+
"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.",
|
|
116
|
+
"node.shareWith": "Wer Zugriff bekommt",
|
|
117
|
+
"node.shareWithEmail": "Eine E-Mail-Adresse",
|
|
118
|
+
"node.shareWithOrganization": "Alle in der Organisation",
|
|
119
|
+
"node.shareOrganizationHint": "Jedes angemeldete Mitglied der Organisation bekommt diesen Zugriff, auch wer später dazukommt.",
|
|
120
|
+
"node.shareLibraryWarning": "Ausführen für die ganze Organisation macht diesen Ordner zu einer Bibliothek: Flows von überall im Baum dürfen dann hineinrufen. Das Zurücknehmen wird abgelehnt, solange einer davon es noch tut.",
|
|
121
|
+
"node.revokeInUse": "Dieser Zugriff lässt sich noch nicht zurücknehmen, weil Flows außerhalb dieses Ordners noch hineinrufen.",
|
|
122
|
+
"node.someUser": "Ein Benutzerkonto",
|
|
115
123
|
"node.verbs": "Was diese Freigabe erlaubt",
|
|
116
124
|
"node.verb.read": "Lesen",
|
|
117
125
|
"node.verb.write": "Schreiben",
|
|
@@ -158,7 +166,7 @@
|
|
|
158
166
|
"node.saveError": "Dieses Dokument konnte nicht gespeichert werden. Lade neu und versuche es erneut.",
|
|
159
167
|
"tools.signingIn": "Du wirst am Firmenportal angemeldet …",
|
|
160
168
|
"tools.noAccess": "Kein Zugriff auf das Firmenportal",
|
|
161
|
-
"tools.noAccessHelp": "Dein
|
|
169
|
+
"tools.noAccessHelp": "Dein Konto darf das MCP-Portal der Firma nicht erreichen, deshalb gibt es hier nichts zu zeigen. Diese Erlaubnis wird nicht in Intel vergeben — bitte die Stelle darum, die in deiner Organisation die Konten verwaltet.",
|
|
162
170
|
"tools.empty": "Keine Werkzeuge für dich verfügbar",
|
|
163
171
|
"tools.emptyHelp": "Das Portal hat geantwortet, und es bietet deinem Konto keine Werkzeuge an. Welche Server es gibt und wer sie erreicht, entscheidet eine Administration im Portal.",
|
|
164
172
|
"tools.unreachable": "Portal nicht erreichbar",
|
|
@@ -167,7 +175,9 @@
|
|
|
167
175
|
"tools.connectExpiredHelp": "Die Verbindung zum MCP-Portal der Firma muss innerhalb weniger Minuten abgeschlossen sein, und diese lief ab, während die Freigabe-Seite offen stand. Es ist nichts schiefgegangen und nichts wurde verweigert — starte erneut und bestätige die Server, wenn die Seite erscheint.",
|
|
168
176
|
"tools.connectRetry": "Erneut starten",
|
|
169
177
|
"tools.connectFailed": "Anmeldung am Portal fehlgeschlagen",
|
|
170
|
-
"tools.connectFailedHelp": "Die Anmeldung am MCP-Portal der Firma brach ab, bevor sie beantwortet werden konnte. Das ist ein technischer Fehler, keine Entscheidung über deinen Zugriff —
|
|
178
|
+
"tools.connectFailedHelp": "Die Anmeldung am MCP-Portal der Firma brach ab, bevor sie beantwortet werden konnte. Das ist ein technischer Fehler, keine Entscheidung über deinen Zugriff — starte sie erneut, und sag der Administration Bescheid, wenn es bleibt.",
|
|
179
|
+
"tools.connectUnknown": "Intel weiß nicht, ob du das Portal erreichst",
|
|
180
|
+
"tools.connectUnknownHelp": "In dieser Browsersitzung wurde eine Anmeldung am MCP-Portal der Firma gestartet, die nie mit einer Antwort zurückkam. Deshalb steht hier weder, dass du zugelassen wurdest, noch, dass du abgelehnt wurdest. Starte sie erneut; wenn es dabei bleibt, sag der Administration Bescheid.",
|
|
171
181
|
"tools.origin": "Herkunft",
|
|
172
182
|
"tools.originUnlisted": "Nicht von einem gelisteten Server",
|
|
173
183
|
"tools.originUnlistedHelp": "Die Serverliste des Portals nennt für diese keinen Server. Seine eigenen Verwaltungswerkzeuge liegen hier — das Portal listet sich nicht selbst — und ebenso alles von einem Server, den die Liste nicht erwähnt hat.",
|
|
@@ -302,6 +312,7 @@
|
|
|
302
312
|
"common.save": "Speichern",
|
|
303
313
|
"common.saving": "Wird gespeichert …",
|
|
304
314
|
"common.saved": "Gespeichert",
|
|
315
|
+
"common.saveNothing": "Nichts zu speichern",
|
|
305
316
|
"common.saveDirty": "Ungespeicherte Änderungen speichern",
|
|
306
317
|
"common.unsavedTitle": "Ungespeicherte Änderungen",
|
|
307
318
|
"common.unsavedBody": "Hier gibt es Änderungen, die nicht gespeichert wurden. Wer jetzt geht, verwirft sie.",
|
|
@@ -310,5 +321,7 @@
|
|
|
310
321
|
"common.loading": "Wird geladen …",
|
|
311
322
|
"common.retry": "Erneut versuchen",
|
|
312
323
|
"common.close": "Schließen",
|
|
313
|
-
"common.unavailable": "Intel ist derzeit nicht verfügbar."
|
|
324
|
+
"common.unavailable": "Intel ist derzeit nicht verfügbar.",
|
|
325
|
+
"common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
|
|
326
|
+
"common.noPermission": "Dir fehlt die Berechtigung, das zu sehen."
|
|
314
327
|
}
|
package/src/i18n/en.json
CHANGED
|
@@ -112,6 +112,14 @@
|
|
|
112
112
|
"node.share": "Share",
|
|
113
113
|
"node.shareAction": "Grant access",
|
|
114
114
|
"node.email": "Email address",
|
|
115
|
+
"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.",
|
|
116
|
+
"node.shareWith": "Who gets access",
|
|
117
|
+
"node.shareWithEmail": "One email address",
|
|
118
|
+
"node.shareWithOrganization": "Everyone in the organization",
|
|
119
|
+
"node.shareOrganizationHint": "Every signed-in member of the organization gets this access, including people who join later.",
|
|
120
|
+
"node.shareLibraryWarning": "Run for the whole organization makes this folder a library: flows anywhere in the tree may then call into it. Taking it back is refused while any of them still does.",
|
|
121
|
+
"node.revokeInUse": "This access cannot be withdrawn yet, because flows outside this folder still call into it.",
|
|
122
|
+
"node.someUser": "A user account",
|
|
115
123
|
"node.verbs": "What this grant allows",
|
|
116
124
|
"node.verb.read": "Read",
|
|
117
125
|
"node.verb.write": "Write",
|
|
@@ -158,7 +166,7 @@
|
|
|
158
166
|
"node.saveError": "This document could not be saved. Reload and try again.",
|
|
159
167
|
"tools.signingIn": "Signing you in to the company portal…",
|
|
160
168
|
"tools.noAccess": "No access to the company portal",
|
|
161
|
-
"tools.noAccessHelp": "Your
|
|
169
|
+
"tools.noAccessHelp": "Your account is not permitted to reach the company MCP portal, so there is nothing to show here. That permission is not given in Intel — ask whoever administers the accounts of your organisation to grant it.",
|
|
162
170
|
"tools.empty": "No tools available to you",
|
|
163
171
|
"tools.emptyHelp": "The portal answered, and it offers your account no tools. An administrator decides in the portal which servers exist and who may reach them.",
|
|
164
172
|
"tools.unreachable": "Portal not reachable",
|
|
@@ -167,7 +175,9 @@
|
|
|
167
175
|
"tools.connectExpiredHelp": "The connection to the company MCP portal has to be completed within a few minutes, and this one ran out while the authorization screen was open. Nothing went wrong and nothing was refused — start it again and confirm the servers when the screen appears.",
|
|
168
176
|
"tools.connectRetry": "Start again",
|
|
169
177
|
"tools.connectFailed": "Portal sign-in failed",
|
|
170
|
-
"tools.connectFailedHelp": "The sign-in to the company MCP portal broke before it could be answered. This is a technical fault, not a decision about your access —
|
|
178
|
+
"tools.connectFailedHelp": "The sign-in to the company MCP portal broke before it could be answered. This is a technical fault, not a decision about your access — start it again, and tell an administrator if it stays.",
|
|
179
|
+
"tools.connectUnknown": "Intel does not know whether you reach the portal",
|
|
180
|
+
"tools.connectUnknownHelp": "A sign-in to the company MCP portal was started in this browser session and never came back with an answer, so nothing here says you were allowed or refused. Start it again; if it keeps ending here, tell an administrator.",
|
|
171
181
|
"tools.origin": "Origin",
|
|
172
182
|
"tools.originUnlisted": "Not from a listed server",
|
|
173
183
|
"tools.originUnlistedHelp": "The portal's server list names no server for these. Its own management tools sit here — the portal does not list itself — and so does anything from a server the list did not mention.",
|
|
@@ -302,6 +312,7 @@
|
|
|
302
312
|
"common.save": "Save",
|
|
303
313
|
"common.saving": "Saving…",
|
|
304
314
|
"common.saved": "Saved",
|
|
315
|
+
"common.saveNothing": "Nothing to save",
|
|
305
316
|
"common.saveDirty": "Save unsaved changes",
|
|
306
317
|
"common.unsavedTitle": "Unsaved changes",
|
|
307
318
|
"common.unsavedBody": "This has changes that have not been saved. Leaving now discards them.",
|
|
@@ -310,5 +321,7 @@
|
|
|
310
321
|
"common.loading": "Loading…",
|
|
311
322
|
"common.retry": "Try again",
|
|
312
323
|
"common.close": "Close",
|
|
313
|
-
"common.unavailable": "Intel is currently unavailable."
|
|
324
|
+
"common.unavailable": "Intel is currently unavailable.",
|
|
325
|
+
"common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
|
|
326
|
+
"common.noPermission": "You do not have permission to see this."
|
|
314
327
|
}
|
package/src/i18n/es.json
CHANGED
|
@@ -112,6 +112,14 @@
|
|
|
112
112
|
"node.share": "Compartir",
|
|
113
113
|
"node.shareAction": "Conceder acceso",
|
|
114
114
|
"node.email": "Dirección de correo",
|
|
115
|
+
"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.",
|
|
116
|
+
"node.shareWith": "Quién obtiene acceso",
|
|
117
|
+
"node.shareWithEmail": "Una dirección de correo",
|
|
118
|
+
"node.shareWithOrganization": "Todos en la organización",
|
|
119
|
+
"node.shareOrganizationHint": "Todos los miembros de la organización que hayan iniciado sesión obtienen este acceso, incluidos quienes se incorporen más tarde.",
|
|
120
|
+
"node.shareLibraryWarning": "Ejecutar para toda la organización convierte esta carpeta en una biblioteca: los flujos de cualquier parte del árbol podrán invocarla. Retirarlo se rechaza mientras alguno siga haciéndolo.",
|
|
121
|
+
"node.revokeInUse": "Este acceso todavía no se puede retirar, porque hay flujos fuera de esta carpeta que siguen invocándola.",
|
|
122
|
+
"node.someUser": "Una cuenta de usuario",
|
|
115
123
|
"node.verbs": "Qué permite este acceso",
|
|
116
124
|
"node.verb.read": "Leer",
|
|
117
125
|
"node.verb.write": "Escribir",
|
|
@@ -158,7 +166,7 @@
|
|
|
158
166
|
"node.saveError": "Este documento no se ha podido guardar. Recarga e inténtalo de nuevo.",
|
|
159
167
|
"tools.signingIn": "Iniciando tu sesión en el portal de la empresa…",
|
|
160
168
|
"tools.noAccess": "Sin acceso al portal de la empresa",
|
|
161
|
-
"tools.noAccessHelp": "Tu cuenta
|
|
169
|
+
"tools.noAccessHelp": "Tu cuenta no tiene permiso para llegar al portal MCP de la empresa, así que aquí no hay nada que mostrar. Ese permiso no se concede en Intel — pídeselo a quien administre las cuentas de tu organización.",
|
|
162
170
|
"tools.empty": "No tienes herramientas disponibles",
|
|
163
171
|
"tools.emptyHelp": "El portal ha respondido, y no ofrece ninguna herramienta a tu cuenta. Qué servidores existen y quién llega a ellos lo decide la administración en el portal.",
|
|
164
172
|
"tools.unreachable": "Portal no accesible",
|
|
@@ -167,7 +175,9 @@
|
|
|
167
175
|
"tools.connectExpiredHelp": "La conexión con el portal MCP de la empresa tiene que completarse en unos minutos, y esta se ha agotado mientras la pantalla de autorización estaba abierta. No ha fallado nada ni se ha denegado nada — vuelve a empezar y confirma los servidores cuando aparezca la pantalla.",
|
|
168
176
|
"tools.connectRetry": "Empezar de nuevo",
|
|
169
177
|
"tools.connectFailed": "El inicio de sesión en el portal ha fallado",
|
|
170
|
-
"tools.connectFailedHelp": "El inicio de sesión en el portal MCP de la empresa se ha interrumpido antes de poder responderse. Es un fallo técnico, no una decisión sobre tu acceso —
|
|
178
|
+
"tools.connectFailedHelp": "El inicio de sesión en el portal MCP de la empresa se ha interrumpido antes de poder responderse. Es un fallo técnico, no una decisión sobre tu acceso — vuelve a empezar y avisa a la administración si persiste.",
|
|
179
|
+
"tools.connectUnknown": "Intel no sabe si llegas al portal",
|
|
180
|
+
"tools.connectUnknownHelp": "En esta sesión del navegador se inició un acceso al portal MCP de la empresa que nunca volvió con una respuesta, así que aquí no dice ni que se te haya permitido ni que se te haya denegado. Vuelve a empezar; si sigue acabando aquí, avisa a la administración.",
|
|
171
181
|
"tools.origin": "Origen",
|
|
172
182
|
"tools.originUnlisted": "No procede de un servidor listado",
|
|
173
183
|
"tools.originUnlistedHelp": "La lista de servidores del portal no nombra ningún servidor para estas. Aquí están sus propias herramientas de gestión — el portal no se lista a sí mismo — y también todo lo que venga de un servidor que la lista no mencionó.",
|
|
@@ -302,6 +312,7 @@
|
|
|
302
312
|
"common.save": "Guardar",
|
|
303
313
|
"common.saving": "Guardando…",
|
|
304
314
|
"common.saved": "Guardado",
|
|
315
|
+
"common.saveNothing": "Nada que guardar",
|
|
305
316
|
"common.saveDirty": "Guardar los cambios sin guardar",
|
|
306
317
|
"common.unsavedTitle": "Cambios sin guardar",
|
|
307
318
|
"common.unsavedBody": "Esto tiene cambios que no se han guardado. Si sales ahora, se descartan.",
|
|
@@ -310,5 +321,7 @@
|
|
|
310
321
|
"common.loading": "Cargando…",
|
|
311
322
|
"common.retry": "Intentarlo de nuevo",
|
|
312
323
|
"common.close": "Cerrar",
|
|
313
|
-
"common.unavailable": "Intel no está disponible en este momento."
|
|
324
|
+
"common.unavailable": "Intel no está disponible en este momento.",
|
|
325
|
+
"common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
|
|
326
|
+
"common.noPermission": "No tienes permiso para ver esto."
|
|
314
327
|
}
|
package/src/main.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
2
2
|
import { RouterProvider } from "@tanstack/react-router";
|
|
3
3
|
import { StrictMode } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createIntelDataProvider,
|
|
7
7
|
loginPath,
|
|
8
8
|
} from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
9
|
+
import { createIntelQueryClient } from "@/data/query-client.ts";
|
|
9
10
|
import { createBrowserSignIn, createSignInGuard } from "@/data/sign-in/sign-in.ts";
|
|
10
11
|
import { I18nProvider } from "@/i18n/i18n-context.tsx";
|
|
11
12
|
import { createLanguages } from "@/i18n/i18n-languages/i18n-languages.ts";
|
|
@@ -32,7 +33,7 @@ if (!root) throw new Error("Missing #root element");
|
|
|
32
33
|
const languages = createLanguages({ store: window.localStorage });
|
|
33
34
|
const signIn = createBrowserSignIn(loginPath);
|
|
34
35
|
const mounted = createRoot(root);
|
|
35
|
-
const queryClient =
|
|
36
|
+
const queryClient = createIntelQueryClient();
|
|
36
37
|
|
|
37
38
|
// The shell is painted from a function because a refused sign-in has to replace it, and that
|
|
38
39
|
// refusal arrives from inside a fetch rather than from React.
|
|
@@ -120,6 +120,11 @@ export function NodeEditor({
|
|
|
120
120
|
mediaType: BlockNoteMediaType,
|
|
121
121
|
idempotencyKey: crypto.randomUUID(),
|
|
122
122
|
});
|
|
123
|
+
// ⚠️ An answer that carries no version did not save anything (#432). Letting it through would
|
|
124
|
+
// move `savedSnapshot` forward, drop the unsaved warning and leave the reader free to close
|
|
125
|
+
// the tab — over a document the server still reports as empty. It belongs on the error path,
|
|
126
|
+
// which is the one place the screen already says that the writing failed.
|
|
127
|
+
if (!node.version) throw new Error("save_without_version");
|
|
123
128
|
// ⚠️ The snapshot of what was SENT, not of what stands in the editor when the answer comes
|
|
124
129
|
// back. Saving crosses the network, and typing during it is normal — taking the state at
|
|
125
130
|
// arrival would call that typing saved and lose it at the next navigation.
|
|
@@ -162,7 +167,15 @@ export function NodeEditor({
|
|
|
162
167
|
because this is where `dirty` lives — moving the state up to the screen would mean the
|
|
163
168
|
screen tracking every keystroke in an editor it does not own. */}
|
|
164
169
|
<ActionSlot name="title-actions">
|
|
165
|
-
|
|
170
|
+
{/* ⚠️ `stored` comes from the document the screen holds, not from a flag this component
|
|
171
|
+
sets on success: it turns true when the answer that carried a version has reached the
|
|
172
|
+
query cache, and never a moment earlier (#432). */}
|
|
173
|
+
<SaveButton
|
|
174
|
+
dirty={dirty}
|
|
175
|
+
saving={save.isPending}
|
|
176
|
+
stored={document.version !== null}
|
|
177
|
+
onSave={() => save.mutate()}
|
|
178
|
+
/>
|
|
166
179
|
</ActionSlot>
|
|
167
180
|
<UnsavedChangesGuard dirty={dirty} />
|
|
168
181
|
{save.isError && (
|
package/src/nodes/nodes.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { lazy, Suspense, useState } from "react";
|
|
|
6
6
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
7
7
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
8
8
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
9
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
9
10
|
import { FolderContents } from "@/folder-contents/folder-contents.tsx";
|
|
10
11
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
11
12
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
@@ -39,6 +40,10 @@ export function Nodes() {
|
|
|
39
40
|
enabled: Boolean(selectedId),
|
|
40
41
|
});
|
|
41
42
|
const selected = document.data?.node ?? null;
|
|
43
|
+
// ⚠️ Off the ERROR, not off the absence of data: a query that has not answered is not a query that
|
|
44
|
+
// answered "no" (`packages/ui/CLAUDE.md`). While the request is in flight there is no refusal to
|
|
45
|
+
// report, and the loading line below stays.
|
|
46
|
+
const refusal = refusalOf(document.error);
|
|
42
47
|
// A folder shows its contents as a graph, and so does the root of the tree (#19). A document has
|
|
43
48
|
// nothing to draw — which is why the switch is not offered on one rather than offered and empty.
|
|
44
49
|
const graphable = selectedId === null || selected?.kind === "folder";
|
|
@@ -80,6 +85,21 @@ export function Nodes() {
|
|
|
80
85
|
</div>
|
|
81
86
|
) : document.isPending ? (
|
|
82
87
|
<p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
88
|
+
) : refusal ? (
|
|
89
|
+
/* ⚠️ A refusal is not a failure, and the difference is the whole of #430. Somebody whose
|
|
90
|
+
share was revoked was left in front of "Loading…" — the worst answer of all, because it
|
|
91
|
+
says the program is still working. They get a sentence and no retry button: the answer
|
|
92
|
+
was final, and a button that changes nothing is an invitation to keep waiting.
|
|
93
|
+
|
|
94
|
+
⚠️ One sentence for `404`, whichever of its two reasons applies. Intel answers the same
|
|
95
|
+
status for "no such node" and "not for you" on purpose — `requireVisible` in
|
|
96
|
+
`packages/api/src/nodes/nodes.ts` — and a screen that told them apart would undo that
|
|
97
|
+
from the other side. */
|
|
98
|
+
<div role="status" className="grid flex-1 place-items-center p-8 text-center text-sm">
|
|
99
|
+
<p className="max-w-sm text-muted-foreground">
|
|
100
|
+
{i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
83
103
|
) : document.isError || !selected ? (
|
|
84
104
|
<div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
|
|
85
105
|
<div className="space-y-3">
|
|
@@ -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 { ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
|
|
3
|
+
import type { ResourceGrant, 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 {
|
|
@@ -29,13 +29,14 @@ import {
|
|
|
29
29
|
DropdownMenuSubTrigger,
|
|
30
30
|
DropdownMenuTrigger,
|
|
31
31
|
} from "@/components/ui/dropdown-menu";
|
|
32
|
-
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
32
|
+
import { flowEntry, IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
33
33
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
34
34
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
35
35
|
import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
|
|
36
36
|
import { Modal } from "@/modal/modal.tsx";
|
|
37
37
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
38
38
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
39
|
+
import { useUserName } from "@/user-name/user-name.ts";
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* What a resource's own actions are, at the place the resource stands (#24).
|
|
@@ -576,10 +577,76 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
|
576
577
|
// flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
|
|
577
578
|
// server's answer, not this component's: `execute` never appears on a document, because a document
|
|
578
579
|
// has nothing to run.
|
|
580
|
+
/**
|
|
581
|
+
* One row of the grant list.
|
|
582
|
+
*
|
|
583
|
+
* ⚠️ It is a component rather than a line inside the `map` because of `useUserName`, and that hook
|
|
584
|
+
* is the point: until #431 a `user` grant printed its raw id — `2C9lEhT82upO82abPjHFAvs24a7Vdd3J`
|
|
585
|
+
* beside the word "Read" — which tells the reader nothing about who they let in and puts an
|
|
586
|
+
* identifier on a screen that has no reason to carry one. `user-name.ts` already had the rule
|
|
587
|
+
* ("never fall back to the id, show nothing instead", #258); this row was the last place breaking it.
|
|
588
|
+
*
|
|
589
|
+
* ⚠️ Intel can resolve exactly ONE id today: the signed-in person's. A foreign one is not "a name we
|
|
590
|
+
* could probably guess" but genuinely unanswerable — Intel has no user directory (#261) — so what
|
|
591
|
+
* stands there is what is true: an account, unnamed. The day #261 lands, `useUserName` starts
|
|
592
|
+
* answering and this row needs no change.
|
|
593
|
+
*/
|
|
594
|
+
function GrantRow({ grant, onRevoke }: { grant: ResourceGrant; onRevoke(grantId: string): void }) {
|
|
595
|
+
const i18n = useI18n();
|
|
596
|
+
const userName = useUserName(grant.principal.type === "user" ? grant.principal.id : null);
|
|
597
|
+
// ⚠️ An `email` grant keeps showing the ADDRESS, even where a name is known, and that is a
|
|
598
|
+
// decision rather than an oversight (#431, review finding). The grant is bound to the address —
|
|
599
|
+
// `db-grants.ts` matches `lower(principal_id)` against whoever signs in — not to an account.
|
|
600
|
+
// Putting a name there would claim a binding that does not exist, and it would be wrong the moment
|
|
601
|
+
// somebody else verifies that address.
|
|
602
|
+
const who =
|
|
603
|
+
grant.principal.type === "email"
|
|
604
|
+
? grant.principal.email
|
|
605
|
+
: grant.principal.type === "user"
|
|
606
|
+
? (userName ?? i18n.t("node.someUser"))
|
|
607
|
+
: i18n.t("node.organization");
|
|
608
|
+
return (
|
|
609
|
+
<li className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm">
|
|
610
|
+
<span className="min-w-0 truncate">
|
|
611
|
+
{who}
|
|
612
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
613
|
+
{i18n.t(`node.verb.${grant.verb}`)}
|
|
614
|
+
</span>
|
|
615
|
+
</span>
|
|
616
|
+
<button
|
|
617
|
+
type="button"
|
|
618
|
+
onClick={() => onRevoke(grant.id)}
|
|
619
|
+
aria-label={i18n.t("node.revokeShare")}
|
|
620
|
+
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
621
|
+
>
|
|
622
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
623
|
+
</button>
|
|
624
|
+
</li>
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Which of the three principals the dialog can SET (#431).
|
|
630
|
+
*
|
|
631
|
+
* ⚠️ `user` is deliberately absent, and it is a decision rather than an omission. Setting one means
|
|
632
|
+
* naming a Gate user id, and Intel has no way to turn a person into an id or an id into a person
|
|
633
|
+
* (#261) — so the only offer this dialog could make is a field for a raw ULID, which is worse than
|
|
634
|
+
* the address it would replace: unverifiable when typed and unreadable afterwards. It stays reachable
|
|
635
|
+
* over the API and over MCP, where the caller already holds the id. The day #261 lands it becomes a
|
|
636
|
+
* picker, not a text field.
|
|
637
|
+
*
|
|
638
|
+
* ⚠️ `organization` IS here, and the reason is the asymmetry it removes: the dialog could already
|
|
639
|
+
* DRAW an organization grant ("Everyone in the organization") and not make one, and MCP could make
|
|
640
|
+
* one all along — a person had strictly less reach than a model on the same tree. It needs nothing
|
|
641
|
+
* Intel does not have, because there is nothing to name.
|
|
642
|
+
*/
|
|
643
|
+
type SharePrincipalKind = "email" | "organization";
|
|
644
|
+
|
|
579
645
|
function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
580
646
|
const { data } = useIntelRouterContext();
|
|
581
647
|
const i18n = useI18n();
|
|
582
648
|
const queryClient = useQueryClient();
|
|
649
|
+
const [principalKind, setPrincipalKind] = useState<SharePrincipalKind>("email");
|
|
583
650
|
const [email, setEmail] = useState("");
|
|
584
651
|
const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
|
|
585
652
|
// What the grant just made does not cover. It survives the form being cleared, because it is the
|
|
@@ -600,7 +667,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
600
667
|
last = (
|
|
601
668
|
await data.shareNode({
|
|
602
669
|
resourceId: node.id,
|
|
603
|
-
principal:
|
|
670
|
+
principal:
|
|
671
|
+
principalKind === "organization"
|
|
672
|
+
? { type: "organization" }
|
|
673
|
+
: { type: "email", email },
|
|
604
674
|
verb,
|
|
605
675
|
expiresAt: null,
|
|
606
676
|
idempotencyKey: crypto.randomUUID(),
|
|
@@ -611,6 +681,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
611
681
|
},
|
|
612
682
|
onSuccess: (result) => {
|
|
613
683
|
setEmail("");
|
|
684
|
+
// ⚠️ The choice is cleared with the address, not left standing. It survived a successful grant
|
|
685
|
+
// until the review of #431 caught it, and "everyone in the organization" is the one setting
|
|
686
|
+
// that must never be the quiet default for the NEXT grant somebody makes in the same dialog.
|
|
687
|
+
setPrincipalKind("email");
|
|
614
688
|
setUnreadable(result);
|
|
615
689
|
},
|
|
616
690
|
// ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
|
|
@@ -633,12 +707,39 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
633
707
|
},
|
|
634
708
|
});
|
|
635
709
|
|
|
710
|
+
/**
|
|
711
|
+
* The one refusal in this dialog that names something the reader has to act on.
|
|
712
|
+
*
|
|
713
|
+
* ⚠️ Taking back `organization` + `execute` on a folder is refused with `409
|
|
714
|
+
* folder_execute_in_use` while flows from outside still call into it (`nodes.ts`,
|
|
715
|
+
* `revokeGrant`) — and the refusal's `detail` is the only place the CALLERS are named. Rendering
|
|
716
|
+
* it as the generic "access was not changed" threw exactly that away and left the reader with a
|
|
717
|
+
* dialog that refuses and will not say why, for the one grant that is hardest to undo.
|
|
718
|
+
*
|
|
719
|
+
* ⚠️ The server's own words, on purpose. `callersDetail` names the flows this actor may see and
|
|
720
|
+
* only COUNTS the rest, so it is actionable without becoming a directory of the tree — a
|
|
721
|
+
* translated stand-in would either lose the names or need them separately, and the wire carries
|
|
722
|
+
* prose rather than a list (#448). The lead-in sentence above it is translated; this line is the
|
|
723
|
+
* evidence under it.
|
|
724
|
+
*/
|
|
725
|
+
const revokeInUse =
|
|
726
|
+
revoke.error instanceof IntelRequestError && revoke.error.code === "folder_execute_in_use"
|
|
727
|
+
? revoke.error.message
|
|
728
|
+
: null;
|
|
729
|
+
|
|
636
730
|
return (
|
|
637
731
|
<Modal title={i18n.t("node.share")} close={close}>
|
|
638
|
-
{
|
|
639
|
-
<
|
|
640
|
-
{i18n.t("node.
|
|
641
|
-
|
|
732
|
+
{revokeInUse ? (
|
|
733
|
+
<div role="alert" className="mb-4 space-y-1 text-sm text-destructive">
|
|
734
|
+
<p>{i18n.t("node.revokeInUse")}</p>
|
|
735
|
+
<p className="text-xs">{revokeInUse}</p>
|
|
736
|
+
</div>
|
|
737
|
+
) : (
|
|
738
|
+
(share.isError || revoke.isError || grants.isError) && (
|
|
739
|
+
<p role="alert" className="mb-4 text-sm text-destructive">
|
|
740
|
+
{i18n.t("node.shareFailed")}
|
|
741
|
+
</p>
|
|
742
|
+
)
|
|
642
743
|
)}
|
|
643
744
|
{/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
|
|
644
745
|
given. A node reference across the folder edge is a possible failure, not a way around
|
|
@@ -660,29 +761,14 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
660
761
|
)}
|
|
661
762
|
<ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
|
|
662
763
|
{grants.data?.items.map((grant) => (
|
|
663
|
-
<
|
|
764
|
+
<GrantRow
|
|
664
765
|
key={grant.id}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
? grant.principal.id
|
|
672
|
-
: i18n.t("node.organization")}
|
|
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={() => revoke.mutate(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>
|
|
766
|
+
grant={grant}
|
|
767
|
+
onRevoke={(grantId) => {
|
|
768
|
+
share.reset();
|
|
769
|
+
revoke.mutate(grantId);
|
|
770
|
+
}}
|
|
771
|
+
/>
|
|
686
772
|
))}
|
|
687
773
|
{grants.data?.items.length === 0 && (
|
|
688
774
|
<li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
|
|
@@ -691,20 +777,99 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
691
777
|
<form
|
|
692
778
|
onSubmit={(event) => {
|
|
693
779
|
event.preventDefault();
|
|
780
|
+
// ⚠️ The other mutation's error is cleared before this one starts, in both directions. A
|
|
781
|
+
// refused withdrawal keeps its `409` until something resets it, and `revokeInUse` takes
|
|
782
|
+
// precedence over the general sentence — so without this, a share that failed AFTER a
|
|
783
|
+
// refused withdrawal said nothing at all, and the reader was left reading a sentence
|
|
784
|
+
// about the wrong action. That is the exact failure mode #430 removed one level up.
|
|
785
|
+
revoke.reset();
|
|
694
786
|
share.mutate();
|
|
695
787
|
}}
|
|
696
788
|
className="space-y-4 border-t pt-5"
|
|
697
789
|
>
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
790
|
+
{/* ⚠️ Two of the three principals, and the missing one is a decision — see
|
|
791
|
+
`SharePrincipalKind`. Radios rather than a select: there are two, and a reader has to see
|
|
792
|
+
that granting to the whole organization is a thing this dialog does at all. */}
|
|
793
|
+
<fieldset className="space-y-2">
|
|
794
|
+
<legend className="text-sm font-medium">{i18n.t("node.shareWith")}</legend>
|
|
795
|
+
{(["email", "organization"] as const).map((kind) => (
|
|
796
|
+
<label key={kind} className="flex items-center gap-2 text-sm">
|
|
797
|
+
<input
|
|
798
|
+
type="radio"
|
|
799
|
+
name="share-principal"
|
|
800
|
+
checked={principalKind === kind}
|
|
801
|
+
onChange={() => setPrincipalKind(kind)}
|
|
802
|
+
aria-describedby={kind === "email" ? "share-email-hint" : "share-organization-hint"}
|
|
803
|
+
className="size-4 border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
804
|
+
/>
|
|
805
|
+
<span>
|
|
806
|
+
{i18n.t(kind === "email" ? "node.shareWithEmail" : "node.shareWithOrganization")}
|
|
807
|
+
</span>
|
|
808
|
+
</label>
|
|
809
|
+
))}
|
|
810
|
+
</fieldset>
|
|
811
|
+
{principalKind === "email" ? (
|
|
812
|
+
<div>
|
|
813
|
+
{/* ⚠️ The hint stands OUTSIDE the label and is tied on with `aria-describedby`. Inside
|
|
814
|
+
it, its words would become part of the field's accessible NAME — a screen reader
|
|
815
|
+
would announce the whole paragraph as the label of the input, and every query by
|
|
816
|
+
label would have to spell it out too. */}
|
|
817
|
+
<label className="block text-sm font-medium">
|
|
818
|
+
{i18n.t("node.email")}
|
|
819
|
+
<input
|
|
820
|
+
type="email"
|
|
821
|
+
required
|
|
822
|
+
value={email}
|
|
823
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
824
|
+
aria-describedby="share-email-hint"
|
|
825
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
826
|
+
/>
|
|
827
|
+
</label>
|
|
828
|
+
{/* ⚠️ What this says is what Intel knows, and no more (#431). It cannot check whether an
|
|
829
|
+
address belongs to anybody — Gate owns identity and offers Intel no lookup (#261) —
|
|
830
|
+
and a dialog that stayed silent about that let a typo become a grant that is valid,
|
|
831
|
+
permanent and completely without effect, with nobody on either side to notice. Naming
|
|
832
|
+
the uncertainty is the honest half of what the ticket asks for; the other half needs
|
|
833
|
+
Gate. */}
|
|
834
|
+
<p id="share-email-hint" className="mt-1 text-xs text-muted-foreground">
|
|
835
|
+
{i18n.t("node.emailHint")}
|
|
836
|
+
</p>
|
|
837
|
+
</div>
|
|
838
|
+
) : (
|
|
839
|
+
/* ⚠️ `aria-live` on the CONTAINER, which is already mounted whenever this branch is on
|
|
840
|
+
screen — not on the warning inside it. A live region has to exist before its content
|
|
841
|
+
changes, so putting it on a paragraph that appears at the same moment announces
|
|
842
|
+
nothing. The gap it closes: `Run` is ticked while the focus sits in the verb fieldset,
|
|
843
|
+
far from here, and the sentence about what is being made would otherwise arrive in
|
|
844
|
+
silence. (The other way in is covered already: `aria-describedby` on the radio reads
|
|
845
|
+
this whole box out when the radio takes focus.) */
|
|
846
|
+
<div
|
|
847
|
+
id="share-organization-hint"
|
|
848
|
+
aria-live="polite"
|
|
849
|
+
className="space-y-2 rounded-md border bg-muted p-3 text-xs"
|
|
850
|
+
>
|
|
851
|
+
<p className="text-muted-foreground">{i18n.t("node.shareOrganizationHint")}</p>
|
|
852
|
+
{/* ⚠️ `organization` + `execute` on a folder is not one grant among four: it is the
|
|
853
|
+
definition of a LIBRARY (ADR-0004 §3) — the one grant a flow may call across a folder
|
|
854
|
+
edge for, so flows from anywhere in the tree may then reach in. Two clicks in this
|
|
855
|
+
dialog now do what used to take an API call, and the reader has to be told what they
|
|
856
|
+
are about to make BEFORE they make it.
|
|
857
|
+
|
|
858
|
+
⚠️ The second sentence is the one that matters most, because it is about the way
|
|
859
|
+
back: `revokeGrant` refuses with `folder_execute_in_use` while callers remain, so
|
|
860
|
+
this is the single hardest grant in the product to undo. Naming the consequence after
|
|
861
|
+
the fact would be the "wrong sentence" class anchrd/core#111 collects.
|
|
862
|
+
|
|
863
|
+
It is a warning and not a block, deliberately: the library is a documented, wanted
|
|
864
|
+
shape, MCP has always been able to make one, and refusing it here would put a person
|
|
865
|
+
back below a model on the same tree — the very asymmetry this ticket removed. */}
|
|
866
|
+
{verbs.includes("execute") ? (
|
|
867
|
+
<p role="note" className="font-medium text-destructive">
|
|
868
|
+
{i18n.t("node.shareLibraryWarning")}
|
|
869
|
+
</p>
|
|
870
|
+
) : null}
|
|
871
|
+
</div>
|
|
872
|
+
)}
|
|
708
873
|
<fieldset className="space-y-2">
|
|
709
874
|
<legend className="text-sm font-medium">{i18n.t("node.verbs")}</legend>
|
|
710
875
|
{(grants.data?.applicableVerbs ?? []).map((verb) => (
|
|
@@ -14,18 +14,28 @@ import { Modal } from "@/modal/modal.tsx";
|
|
|
14
14
|
* ⚠️ The accessible name names the state as well — "Saved" and "Save unsaved changes" are two
|
|
15
15
|
* different pieces of information, and a screen reader gets neither the fill nor the dot. The
|
|
16
16
|
* visible word stays a prefix of it, so speech input still reaches the button by what it reads.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ At rest there are TWO states, not one, and `stored` is what tells them apart (#432). A
|
|
19
|
+
* document that has never been written has nothing to save either — and calling that "Saved" is the
|
|
20
|
+
* one sentence an editor must never say untruthfully: whoever reads it closes the tab. The button
|
|
21
|
+
* was already unclickable in that state, which is exactly why it went unnoticed for so long; the
|
|
22
|
+
* tooltip and the accessible name kept claiming a version that does not exist. "Nothing to save"
|
|
23
|
+
* says less and stays true in both readings.
|
|
17
24
|
*/
|
|
18
25
|
export function SaveButton({
|
|
19
26
|
dirty,
|
|
20
27
|
saving,
|
|
28
|
+
stored,
|
|
21
29
|
onSave,
|
|
22
30
|
}: {
|
|
23
31
|
dirty: boolean;
|
|
24
32
|
saving: boolean;
|
|
33
|
+
/** Whether what is being edited has content on the server at all. */
|
|
34
|
+
stored: boolean;
|
|
25
35
|
onSave(): void;
|
|
26
36
|
}) {
|
|
27
37
|
const i18n = useI18n();
|
|
28
|
-
const label = i18n.t(dirty ? "common.saveDirty" : "common.saved");
|
|
38
|
+
const label = i18n.t(dirty ? "common.saveDirty" : stored ? "common.saved" : "common.saveNothing");
|
|
29
39
|
return (
|
|
30
40
|
<TooltipProvider delayDuration={300}>
|
|
31
41
|
<Tooltip>
|
package/src/tools/tools.tsx
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { serverOf, type ToolCapability, type ToolServer } from "@anchrd/intel-contract/tool";
|
|
2
2
|
import { useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { useRouterState } from "@tanstack/react-router";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AlertTriangle,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
HelpCircle,
|
|
8
|
+
PlugZap,
|
|
9
|
+
ShieldOff,
|
|
10
|
+
Timer,
|
|
11
|
+
Wrench,
|
|
12
|
+
} from "lucide-react";
|
|
5
13
|
import * as React from "react";
|
|
6
14
|
import { Button } from "@/components/ui/button";
|
|
7
15
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
@@ -65,8 +73,36 @@ function groupByServer(
|
|
|
65
73
|
// visit. A second attempt after a refusal would be a redirect loop with an unchanging answer, and
|
|
66
74
|
// the marker in the URL only survives until the next navigation — the browser session remembers it
|
|
67
75
|
// instead (#60).
|
|
76
|
+
//
|
|
77
|
+
// ⚠️ It records that an attempt was STARTED, never that one was answered (#426). A sign-in that
|
|
78
|
+
// crashed on the way leaves exactly the same note as one that came back, so nothing may read it as
|
|
79
|
+
// a verdict about anybody: it only keeps the screen from walking in a circle on its own, and what
|
|
80
|
+
// the reader is told comes from the marker in the URL instead. That is the whole distinction — the
|
|
81
|
+
// note is never cleared to make a second attempt possible; the button is what makes one possible.
|
|
68
82
|
const SignInAttemptKey = "intel.portal-sign-in-attempted";
|
|
69
83
|
|
|
84
|
+
/**
|
|
85
|
+
* What came back from the silent walk — and "nothing" is one of the answers (#426).
|
|
86
|
+
*
|
|
87
|
+
* The marker in the URL is the only thing that ever says why the walk ended, and it is absent in two
|
|
88
|
+
* situations that look identical from here: the attempt never ran, or it broke before it could
|
|
89
|
+
* return. Neither is a decision about access, so neither may borrow the sentence that describes one
|
|
90
|
+
* — the rule `packages/ui/CLAUDE.md` keeps from #350: **a missing statement is a reason to say less,
|
|
91
|
+
* never to refuse more.** Saying "no access" for `none` sent a reader hunting through portal
|
|
92
|
+
* policies while the sign-in had answered 500 (#425).
|
|
93
|
+
*/
|
|
94
|
+
type PortalAnswer = "refused" | "expired" | "failed" | "none";
|
|
95
|
+
|
|
96
|
+
function portalAnswerOf(connectError: string | null): PortalAnswer {
|
|
97
|
+
if (connectError === null) return "none";
|
|
98
|
+
if (connectError === "portal_sign_in_expired") return "expired";
|
|
99
|
+
// The two codes Intel itself writes when somebody was actually turned away — by the portal, or by
|
|
100
|
+
// Gate before the portal was ever asked. Every other marker is machinery, not policy.
|
|
101
|
+
return connectError === "portal_sign_in_refused" || connectError === "permission_required"
|
|
102
|
+
? "refused"
|
|
103
|
+
: "failed";
|
|
104
|
+
}
|
|
105
|
+
|
|
70
106
|
// Reading `sessionStorage` throws outright in a few privacy modes, so the guard is a try, not a
|
|
71
107
|
// feature check. Losing the note costs one extra redirect, and the marker the refusal leaves in the
|
|
72
108
|
// URL still ends the walk — it must never cost the screen.
|
|
@@ -109,10 +145,30 @@ export function Tools() {
|
|
|
109
145
|
typeof window === "undefined"
|
|
110
146
|
? null
|
|
111
147
|
: new URLSearchParams(window.location.search).get("connectError");
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
148
|
+
const answer = portalAnswerOf(connectError);
|
|
149
|
+
// What the screen DID about that answer, which is a third thing beside the catalog and the marker.
|
|
150
|
+
//
|
|
151
|
+
// ⚠️ Only `none` starts undecided. `useEffect` runs after the paint, so a state that always began
|
|
152
|
+
// at `deciding` would push a "Loading" frame in front of all four notices — and three of them are
|
|
153
|
+
// already settled at the first render, because their answer stands in the URL. It would flash at
|
|
154
|
+
// exactly the readers who just arrived out of a redirect. `none` is the one that genuinely has to
|
|
155
|
+
// wait: its walk starts one tick after the catalog arrives, and "we do not know" in between is a
|
|
156
|
+
// sentence the screen takes back immediately.
|
|
157
|
+
const [walk, setWalk] = React.useState<"deciding" | "running" | "stopped">(
|
|
158
|
+
answer === "none" ? "deciding" : "stopped",
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const startSignIn = React.useCallback(() => {
|
|
162
|
+
// ⚠️ `setItem`, and the marker is not cleared anywhere on this path. Pressing the button STARTS
|
|
163
|
+
// an attempt, which is the only thing the marker ever claims — clearing it would buy the click
|
|
164
|
+
// nothing (no code in this path reads it) and cost the next visit: an attempt that dies without
|
|
165
|
+
// ever coming back with a marker would be followed by a silent redirect nobody asked for, on a
|
|
166
|
+
// screen that has just said it does not understand what is happening. The second attempt this
|
|
167
|
+
// browser session owes its reader is this button, never a re-armed guard.
|
|
168
|
+
attemptStore()?.setItem(SignInAttemptKey, "1");
|
|
169
|
+
setWalk("running");
|
|
170
|
+
data.startPortalSignIn("/tools");
|
|
171
|
+
}, [data]);
|
|
116
172
|
|
|
117
173
|
// ⚠️ Nobody is asked to start this. The portal sign-in is a browser redirect, so the screen
|
|
118
174
|
// begins it itself the moment the catalog says there is no portal session yet — that is the whole
|
|
@@ -126,11 +182,14 @@ export function Tools() {
|
|
|
126
182
|
attempts?.removeItem(SignInAttemptKey);
|
|
127
183
|
return;
|
|
128
184
|
}
|
|
129
|
-
if (
|
|
185
|
+
if (answer !== "none" || attempts?.getItem(SignInAttemptKey)) {
|
|
186
|
+
setWalk("stopped");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
130
189
|
attempts?.setItem(SignInAttemptKey, "1");
|
|
131
|
-
|
|
190
|
+
setWalk("running");
|
|
132
191
|
data.startPortalSignIn("/tools");
|
|
133
|
-
}, [catalog.data, data,
|
|
192
|
+
}, [catalog.data, data, answer]);
|
|
134
193
|
|
|
135
194
|
return (
|
|
136
195
|
<div className="min-h-full p-8">
|
|
@@ -151,43 +210,12 @@ export function Tools() {
|
|
|
151
210
|
</Button>
|
|
152
211
|
</Notice>
|
|
153
212
|
) : !catalog.data.portalConnected ? (
|
|
154
|
-
|
|
213
|
+
walk === "deciding" ? (
|
|
214
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
215
|
+
) : walk === "running" ? (
|
|
155
216
|
<p className="text-sm text-muted-foreground">{i18n.t("tools.signingIn")}</p>
|
|
156
|
-
) : connectError === "portal_sign_in_expired" ? (
|
|
157
|
-
// Ran out of time on the consent screen. Nothing is broken and nobody was refused, so
|
|
158
|
-
// neither of the other two sentences fits — and this one is the only case the reader
|
|
159
|
-
// can fix alone, by starting over (#95).
|
|
160
|
-
<Notice
|
|
161
|
-
icon={<Timer aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
|
|
162
|
-
title={i18n.t("tools.connectExpired")}
|
|
163
|
-
help={i18n.t("tools.connectExpiredHelp")}
|
|
164
|
-
>
|
|
165
|
-
<Button variant="outline" onClick={() => data.startPortalSignIn("/tools")}>
|
|
166
|
-
{i18n.t("tools.connectRetry")}
|
|
167
|
-
</Button>
|
|
168
|
-
</Notice>
|
|
169
|
-
) : accessDenied || !refused ? (
|
|
170
|
-
// The end of the silent walk for somebody no Access policy carries. It is a sentence
|
|
171
|
-
// about access, not an invitation to connect: there is nothing they could click that
|
|
172
|
-
// would change the answer, and what the portal replied is not repeated here.
|
|
173
|
-
<Notice
|
|
174
|
-
alert
|
|
175
|
-
icon={
|
|
176
|
-
<ShieldOff aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
|
|
177
|
-
}
|
|
178
|
-
title={i18n.t("tools.noAccess")}
|
|
179
|
-
help={i18n.t("tools.noAccessHelp")}
|
|
180
|
-
/>
|
|
181
217
|
) : (
|
|
182
|
-
|
|
183
|
-
// be allowed or refused. Saying "no access" here sent the last reader hunting through
|
|
184
|
-
// Access policies while the real fault sat in a failed client registration (#93).
|
|
185
|
-
<Notice
|
|
186
|
-
alert
|
|
187
|
-
icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-destructive" />}
|
|
188
|
-
title={i18n.t("tools.connectFailed")}
|
|
189
|
-
help={i18n.t("tools.connectFailedHelp")}
|
|
190
|
-
/>
|
|
218
|
+
<PortalNotice answer={answer} i18n={i18n} onRetry={startSignIn} />
|
|
191
219
|
)
|
|
192
220
|
) : catalog.data.items.length === 0 ? (
|
|
193
221
|
<Notice
|
|
@@ -212,6 +240,81 @@ export function Tools() {
|
|
|
212
240
|
);
|
|
213
241
|
}
|
|
214
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The end of the silent walk, in the words of whatever ended it — one sentence per answer, and no
|
|
245
|
+
* sentence shared between two of them (#426).
|
|
246
|
+
*
|
|
247
|
+
* ⚠️ Only `refused` is a statement about ACCESS, and only it is a dead end: there is nothing the
|
|
248
|
+
* reader could press that would change the answer, so nothing is offered. The other two are the
|
|
249
|
+
* machinery and the silence, and both are worth another attempt — which is the button the screen
|
|
250
|
+
* lacked entirely while the marker in `sessionStorage` made the first attempt the only one.
|
|
251
|
+
*/
|
|
252
|
+
function PortalNotice({
|
|
253
|
+
answer,
|
|
254
|
+
i18n,
|
|
255
|
+
onRetry,
|
|
256
|
+
}: {
|
|
257
|
+
answer: PortalAnswer;
|
|
258
|
+
i18n: I18n;
|
|
259
|
+
onRetry: () => void;
|
|
260
|
+
}) {
|
|
261
|
+
if (answer === "refused") {
|
|
262
|
+
return (
|
|
263
|
+
<Notice
|
|
264
|
+
alert
|
|
265
|
+
icon={<ShieldOff aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
|
|
266
|
+
title={i18n.t("tools.noAccess")}
|
|
267
|
+
help={i18n.t("tools.noAccessHelp")}
|
|
268
|
+
/>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
// Ran out of time on the consent screen. Nothing is broken and nobody was refused, so neither of
|
|
272
|
+
// the other sentences fits (#95).
|
|
273
|
+
if (answer === "expired") {
|
|
274
|
+
return (
|
|
275
|
+
<Notice
|
|
276
|
+
icon={<Timer aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
|
|
277
|
+
title={i18n.t("tools.connectExpired")}
|
|
278
|
+
help={i18n.t("tools.connectExpiredHelp")}
|
|
279
|
+
>
|
|
280
|
+
<Button variant="outline" onClick={onRetry}>
|
|
281
|
+
{i18n.t("tools.connectRetry")}
|
|
282
|
+
</Button>
|
|
283
|
+
</Notice>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
// A marker that is neither of the two access answers is machinery, not policy: the sign-in broke
|
|
287
|
+
// before anyone could be allowed or refused. Saying "no access" here sent the last reader hunting
|
|
288
|
+
// through Access policies while the real fault sat in a failed client registration (#93).
|
|
289
|
+
if (answer === "failed") {
|
|
290
|
+
return (
|
|
291
|
+
<Notice
|
|
292
|
+
alert
|
|
293
|
+
icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-destructive" />}
|
|
294
|
+
title={i18n.t("tools.connectFailed")}
|
|
295
|
+
help={i18n.t("tools.connectFailedHelp")}
|
|
296
|
+
>
|
|
297
|
+
<Button variant="outline" onClick={onRetry}>
|
|
298
|
+
{i18n.t("tools.connectRetry")}
|
|
299
|
+
</Button>
|
|
300
|
+
</Notice>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
// No marker at all, and an attempt already made in this browser session: it never came back. The
|
|
304
|
+
// screen says exactly that — not "no access", which is a decision nobody made here.
|
|
305
|
+
return (
|
|
306
|
+
<Notice
|
|
307
|
+
icon={<HelpCircle aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
|
|
308
|
+
title={i18n.t("tools.connectUnknown")}
|
|
309
|
+
help={i18n.t("tools.connectUnknownHelp")}
|
|
310
|
+
>
|
|
311
|
+
<Button variant="outline" onClick={onRetry}>
|
|
312
|
+
{i18n.t("tools.connectRetry")}
|
|
313
|
+
</Button>
|
|
314
|
+
</Notice>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
215
318
|
/**
|
|
216
319
|
* The catalog, under the portal's servers where there are any.
|
|
217
320
|
*
|