@anchrd/intel-ui 0.19.0 → 0.21.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.
@@ -1,10 +1,6 @@
1
- import type {
2
- Flow,
3
- FlowValidation,
4
- Node,
5
- ResourceVerb,
6
- UnreadableNodes,
7
- } from "@anchrd/intel-contract";
1
+ import type { Flow, FlowValidation } from "@anchrd/intel-contract/flow";
2
+ import type { Node } from "@anchrd/intel-contract/node";
3
+ import type { ResourceGrant, ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
8
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
5
  import { useNavigate, useRouterState } from "@tanstack/react-router";
10
6
  import {
@@ -33,13 +29,14 @@ import {
33
29
  DropdownMenuSubTrigger,
34
30
  DropdownMenuTrigger,
35
31
  } from "@/components/ui/dropdown-menu";
36
- import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
32
+ import { flowEntry, IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
37
33
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
38
34
  import { useI18n } from "@/i18n/i18n-context.tsx";
39
35
  import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
40
36
  import { Modal } from "@/modal/modal.tsx";
41
37
  import { useIntelRouterContext } from "@/router/router-context.ts";
42
38
  import { selectedFrom } from "@/router/selection-search.ts";
39
+ import { useUserName } from "@/user-name/user-name.ts";
43
40
 
44
41
  /**
45
42
  * What a resource's own actions are, at the place the resource stands (#24).
@@ -580,10 +577,76 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
580
577
  // flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
581
578
  // server's answer, not this component's: `execute` never appears on a document, because a document
582
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
+
583
645
  function SharePanel({ node, close }: { node: Node; close(): void }) {
584
646
  const { data } = useIntelRouterContext();
585
647
  const i18n = useI18n();
586
648
  const queryClient = useQueryClient();
649
+ const [principalKind, setPrincipalKind] = useState<SharePrincipalKind>("email");
587
650
  const [email, setEmail] = useState("");
588
651
  const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
589
652
  // What the grant just made does not cover. It survives the form being cleared, because it is the
@@ -604,7 +667,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
604
667
  last = (
605
668
  await data.shareNode({
606
669
  resourceId: node.id,
607
- principal: { type: "email", email },
670
+ principal:
671
+ principalKind === "organization"
672
+ ? { type: "organization" }
673
+ : { type: "email", email },
608
674
  verb,
609
675
  expiresAt: null,
610
676
  idempotencyKey: crypto.randomUUID(),
@@ -615,6 +681,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
615
681
  },
616
682
  onSuccess: (result) => {
617
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");
618
688
  setUnreadable(result);
619
689
  },
620
690
  // ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
@@ -637,12 +707,39 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
637
707
  },
638
708
  });
639
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
+
640
730
  return (
641
731
  <Modal title={i18n.t("node.share")} close={close}>
642
- {(share.isError || revoke.isError || grants.isError) && (
643
- <p role="alert" className="mb-4 text-sm text-destructive">
644
- {i18n.t("node.shareFailed")}
645
- </p>
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
+ )
646
743
  )}
647
744
  {/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
648
745
  given. A node reference across the folder edge is a possible failure, not a way around
@@ -664,29 +761,14 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
664
761
  )}
665
762
  <ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
666
763
  {grants.data?.items.map((grant) => (
667
- <li
764
+ <GrantRow
668
765
  key={grant.id}
669
- className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
670
- >
671
- <span className="min-w-0 truncate">
672
- {grant.principal.type === "email"
673
- ? grant.principal.email
674
- : grant.principal.type === "user"
675
- ? grant.principal.id
676
- : i18n.t("node.organization")}
677
- <span className="ml-2 text-xs text-muted-foreground">
678
- {i18n.t(`node.verb.${grant.verb}`)}
679
- </span>
680
- </span>
681
- <button
682
- type="button"
683
- onClick={() => revoke.mutate(grant.id)}
684
- aria-label={i18n.t("node.revokeShare")}
685
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
686
- >
687
- <Trash2 aria-hidden="true" className="size-4" />
688
- </button>
689
- </li>
766
+ grant={grant}
767
+ onRevoke={(grantId) => {
768
+ share.reset();
769
+ revoke.mutate(grantId);
770
+ }}
771
+ />
690
772
  ))}
691
773
  {grants.data?.items.length === 0 && (
692
774
  <li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
@@ -695,20 +777,99 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
695
777
  <form
696
778
  onSubmit={(event) => {
697
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();
698
786
  share.mutate();
699
787
  }}
700
788
  className="space-y-4 border-t pt-5"
701
789
  >
702
- <label className="block text-sm font-medium">
703
- {i18n.t("node.email")}
704
- <input
705
- type="email"
706
- required
707
- value={email}
708
- onChange={(event) => setEmail(event.target.value)}
709
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
710
- />
711
- </label>
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
+ )}
712
873
  <fieldset className="space-y-2">
713
874
  <legend className="text-sm font-medium">{i18n.t("node.verbs")}</legend>
714
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>
@@ -1,7 +1,15 @@
1
- import { serverOf, type ToolCapability, type ToolServer } from "@anchrd/intel-contract";
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 { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Timer, Wrench } from "lucide-react";
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 refused = connectError !== null;
113
- const accessDenied =
114
- connectError === "portal_sign_in_refused" || connectError === "permission_required";
115
- const [signingIn, setSigningIn] = React.useState(false);
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 (refused || attempts?.getItem(SignInAttemptKey)) return;
185
+ if (answer !== "none" || attempts?.getItem(SignInAttemptKey)) {
186
+ setWalk("stopped");
187
+ return;
188
+ }
130
189
  attempts?.setItem(SignInAttemptKey, "1");
131
- setSigningIn(true);
190
+ setWalk("running");
132
191
  data.startPortalSignIn("/tools");
133
- }, [catalog.data, data, refused]);
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
- signingIn ? (
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
- // Every other marker is machinery, not policy: the sign-in broke before anyone could
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
  *