@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.
- package/README.md +33 -0
- package/package.json +2 -2
- package/src/app/app-tree/app-tree.tsx +45 -5
- package/src/app/header-search/header-search.tsx +2 -1
- package/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/src/app/user-footer/user-footer.tsx +19 -0
- package/src/archive/archive.tsx +2 -1
- package/src/data/intel-data-provider/intel-data-provider.ts +43 -29
- package/src/data/intel-data-provider/intel-data-provider.types.ts +40 -24
- package/src/data/query-client.ts +18 -0
- package/src/data/request-refusal/request-refusal.ts +51 -0
- package/src/document-link/document-link.tsx +1 -1
- package/src/entry-picker/entry-picker.tsx +1 -1
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +11 -3
- package/src/flows/node-icon/node-icon.ts +1 -1
- package/src/flows/node-palette/node-palette.tsx +1 -1
- package/src/flows/node-palette/node-palette.types.ts +1 -1
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/i18n/de.json +23 -2
- package/src/i18n/en.json +23 -2
- package/src/i18n/es.json +23 -2
- package/src/main.tsx +3 -2
- package/src/node-editor/node-editor.tsx +15 -2
- package/src/node-graph/graph-notice.tsx +1 -1
- package/src/node-graph/node-graph.ts +1 -1
- package/src/node-graph/node-graph.tsx +1 -1
- package/src/node-table/node-table.tsx +3 -3
- package/src/nodes/nodes.tsx +21 -1
- package/src/resource-menu/resource-menu.tsx +206 -45
- package/src/save-button/save-button.tsx +11 -1
- package/src/tools/tools.tsx +147 -44
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
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:
|
|
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
|
-
{
|
|
643
|
-
<
|
|
644
|
-
{i18n.t("node.
|
|
645
|
-
|
|
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
|
-
<
|
|
764
|
+
<GrantRow
|
|
668
765
|
key={grant.id}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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>
|
package/src/tools/tools.tsx
CHANGED
|
@@ -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 {
|
|
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
|
*
|