@anchrd/intel-ui 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app/action-slot/action-slot.tsx +4 -0
- package/src/app/app-tree/app-tree.tsx +35 -122
- package/src/app/app.tsx +15 -4
- package/src/app/tree-move/tree-move.tsx +135 -1
- package/src/components/ui/table.tsx +82 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +19 -3
- package/src/data/intel-data-provider/intel-data-provider.types.ts +7 -1
- package/src/entry-picker/entry-picker.tsx +166 -0
- package/src/flows/flows.tsx +183 -183
- package/src/flows/node-icon/node-icon.ts +13 -7
- package/src/flows/node-palette/node-palette.tsx +63 -23
- package/src/folder-contents/folder-contents.tsx +106 -0
- package/src/i18n/en.json +36 -19
- package/src/knowledge/knowledge.tsx +47 -38
- package/src/knowledge-table/knowledge-table.tsx +23 -11
- package/src/resource-menu/resource-menu.tsx +110 -65
- package/src/title-row/title-row.tsx +49 -0
- package/src/tools/tools.tsx +57 -38
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
ContextPolicy,
|
|
3
2
|
Flow,
|
|
3
|
+
FlowValidation,
|
|
4
4
|
KnowledgeNode,
|
|
5
5
|
ResourceVerb,
|
|
6
6
|
UnreadableKnowledge,
|
|
7
7
|
} from "@anchrd/intel-contract";
|
|
8
8
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
9
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Archive,
|
|
12
|
+
CornerLeftUp,
|
|
13
|
+
Ellipsis,
|
|
14
|
+
Link2,
|
|
15
|
+
Pencil,
|
|
16
|
+
Share2,
|
|
17
|
+
ShieldCheck,
|
|
18
|
+
Trash2,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import type * as React from "react";
|
|
11
21
|
import { useState } from "react";
|
|
22
|
+
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
12
23
|
import {
|
|
13
24
|
DropdownMenu,
|
|
14
25
|
DropdownMenuContent,
|
|
15
26
|
DropdownMenuItem,
|
|
16
|
-
DropdownMenuRadioGroup,
|
|
17
|
-
DropdownMenuRadioItem,
|
|
18
27
|
DropdownMenuSeparator,
|
|
19
|
-
DropdownMenuSub,
|
|
20
|
-
DropdownMenuSubContent,
|
|
21
|
-
DropdownMenuSubTrigger,
|
|
22
28
|
DropdownMenuTrigger,
|
|
23
29
|
} from "@/components/ui/dropdown-menu";
|
|
30
|
+
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
31
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
24
32
|
import { Modal } from "@/modal/modal.tsx";
|
|
25
33
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
26
34
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
@@ -28,9 +36,11 @@ import { selectedFrom } from "@/router/selection-search.ts";
|
|
|
28
36
|
/**
|
|
29
37
|
* What a resource's own actions are, at the place the resource stands (#24).
|
|
30
38
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
39
|
+
* ⚠️ Since #58 there is exactly one such place: the title line of the open document, table,
|
|
40
|
+
* attachment, folder or flow. The tree row used to carry the same menu, which put ten buttons into a
|
|
41
|
+
* 240px column and made the sidebar something to read buttons in rather than titles. Everything the
|
|
42
|
+
* row menu could do is reached here — including moving, which is why this component owns the folder
|
|
43
|
+
* picker rather than being handed one.
|
|
34
44
|
*
|
|
35
45
|
* ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode, a flow
|
|
36
46
|
* is shared through the folder it is filed in rather than on its own (ADR-0004 §2). A greyed-out row
|
|
@@ -40,6 +50,21 @@ export type ResourceTarget =
|
|
|
40
50
|
| { type: "knowledge"; node: KnowledgeNode }
|
|
41
51
|
| { type: "flow"; flow: Flow };
|
|
42
52
|
|
|
53
|
+
// The same thing as a tree row, because that is what a move works on: a row with a place in the
|
|
54
|
+
// shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
|
|
55
|
+
// and never a second source of truth.
|
|
56
|
+
function entryOf(target: ResourceTarget): TreeEntry {
|
|
57
|
+
return target.type === "flow"
|
|
58
|
+
? flowEntry(target.flow)
|
|
59
|
+
: {
|
|
60
|
+
type: "knowledge",
|
|
61
|
+
id: target.node.id,
|
|
62
|
+
title: target.node.title,
|
|
63
|
+
kind: target.node.kind,
|
|
64
|
+
node: target.node,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
function idOf(target: ResourceTarget): string {
|
|
44
69
|
return target.type === "knowledge" ? target.node.id : target.flow.id;
|
|
45
70
|
}
|
|
@@ -72,24 +97,21 @@ export function resourceErrorKey(error: unknown): string {
|
|
|
72
97
|
}
|
|
73
98
|
}
|
|
74
99
|
|
|
75
|
-
const contextPolicies: readonly ContextPolicy[] = ["pinned", "relevant", "explicit"];
|
|
76
|
-
|
|
77
100
|
/**
|
|
78
101
|
* The three-dot menu.
|
|
79
102
|
*
|
|
80
|
-
* `variant` is only how the trigger is painted.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
103
|
+
* `variant` is only how the trigger is painted. `title` is the one in use: a plain icon button at
|
|
104
|
+
* the end of a title line, put there by `TitleRow` and by nobody else, so it is always the last
|
|
105
|
+
* thing in that line (#53). `row` is the sidebar form — hidden until the row is hovered or something
|
|
106
|
+
* in it takes focus — and is unused since #58; it is kept because the trigger's two paintings are
|
|
107
|
+
* the only difference between the two places, and re-deriving it would be the harder half.
|
|
84
108
|
*/
|
|
85
109
|
export function ResourceMenu({
|
|
86
110
|
target,
|
|
87
111
|
variant,
|
|
88
|
-
onMove,
|
|
89
112
|
}: {
|
|
90
113
|
target: ResourceTarget;
|
|
91
114
|
variant: "row" | "title";
|
|
92
|
-
onMove?: (() => void) | undefined;
|
|
93
115
|
}) {
|
|
94
116
|
const { data, i18n } = useIntelRouterContext();
|
|
95
117
|
const queryClient = useQueryClient();
|
|
@@ -98,6 +120,18 @@ export function ResourceMenu({
|
|
|
98
120
|
const [sharing, setSharing] = useState(false);
|
|
99
121
|
const [linksOpen, setLinksOpen] = useState(false);
|
|
100
122
|
const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
123
|
+
// ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
|
|
124
|
+
// the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
|
|
125
|
+
// the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
|
|
126
|
+
const move = useTreeMove();
|
|
127
|
+
// ⚠️ Not a query. A validation is a snapshot taken when somebody asks (#72, ADR-0003): cached
|
|
128
|
+
// under a key it would be re-served later as if it still held, and the one thing it must never
|
|
129
|
+
// claim is that a moment which has passed is still true.
|
|
130
|
+
const [validation, setValidation] = useState<FlowValidation | null>(null);
|
|
131
|
+
const validate = useMutation({
|
|
132
|
+
mutationFn: () => data.validateFlow(id),
|
|
133
|
+
onSuccess: setValidation,
|
|
134
|
+
});
|
|
101
135
|
|
|
102
136
|
const id = idOf(target);
|
|
103
137
|
const title = titleOf(target);
|
|
@@ -168,31 +202,17 @@ export function ResourceMenu({
|
|
|
168
202
|
},
|
|
169
203
|
});
|
|
170
204
|
|
|
171
|
-
const setContextPolicy = useMutation({
|
|
172
|
-
mutationFn: async (contextPolicy: ContextPolicy) => {
|
|
173
|
-
if (target.type === "flow") throw new Error("A flow has no retrieval mode");
|
|
174
|
-
await data.updateKnowledge({
|
|
175
|
-
nodeId: target.node.id,
|
|
176
|
-
baseUpdatedAt: target.node.updatedAt,
|
|
177
|
-
contextPolicy,
|
|
178
|
-
idempotencyKey: crypto.randomUUID(),
|
|
179
|
-
});
|
|
180
|
-
},
|
|
181
|
-
onSuccess: refresh,
|
|
182
|
-
});
|
|
183
|
-
|
|
184
205
|
const failure = rename.isError
|
|
185
206
|
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
186
207
|
: archive.isError
|
|
187
208
|
? archive.error
|
|
188
|
-
:
|
|
189
|
-
? setContextPolicy.error
|
|
190
|
-
: undefined;
|
|
209
|
+
: undefined;
|
|
191
210
|
|
|
192
211
|
return (
|
|
193
212
|
<>
|
|
194
213
|
<DropdownMenu>
|
|
195
214
|
<DropdownMenuTrigger
|
|
215
|
+
data-resource-menu=""
|
|
196
216
|
aria-label={i18n.t("resource.menu", { title })}
|
|
197
217
|
className={
|
|
198
218
|
variant === "row"
|
|
@@ -207,33 +227,19 @@ export function ResourceMenu({
|
|
|
207
227
|
<Pencil aria-hidden="true" />
|
|
208
228
|
{i18n.t("resource.rename")}
|
|
209
229
|
</DropdownMenuItem>
|
|
210
|
-
{
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
230
|
+
<DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
|
|
231
|
+
<CornerLeftUp aria-hidden="true" />
|
|
232
|
+
{i18n.t("tree.move.action")}
|
|
233
|
+
</DropdownMenuItem>
|
|
234
|
+
{/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
|
|
235
|
+
which is why it is in the menu and not in the title line (#72). */}
|
|
236
|
+
{node === null ? (
|
|
237
|
+
<DropdownMenuItem onSelect={() => validate.mutate()}>
|
|
238
|
+
<ShieldCheck aria-hidden="true" />
|
|
239
|
+
{i18n.t("flows.validate")}
|
|
214
240
|
</DropdownMenuItem>
|
|
215
241
|
) : null}
|
|
216
242
|
{retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
|
|
217
|
-
{/* ⚠️ A submenu with a checked value, not an embedded `select`. A form control inside a
|
|
218
|
-
menu takes the keyboard away from the menu that contains it, and the current value is
|
|
219
|
-
then only readable by opening a second widget. */}
|
|
220
|
-
{retrievable && node ? (
|
|
221
|
-
<DropdownMenuSub>
|
|
222
|
-
<DropdownMenuSubTrigger>{i18n.t("knowledge.contextPolicy")}</DropdownMenuSubTrigger>
|
|
223
|
-
<DropdownMenuSubContent>
|
|
224
|
-
<DropdownMenuRadioGroup
|
|
225
|
-
value={node.contextPolicy}
|
|
226
|
-
onValueChange={(value) => setContextPolicy.mutate(value as ContextPolicy)}
|
|
227
|
-
>
|
|
228
|
-
{contextPolicies.map((policy) => (
|
|
229
|
-
<DropdownMenuRadioItem key={policy} value={policy}>
|
|
230
|
-
{i18n.t(`knowledge.context.${policy}`)}
|
|
231
|
-
</DropdownMenuRadioItem>
|
|
232
|
-
))}
|
|
233
|
-
</DropdownMenuRadioGroup>
|
|
234
|
-
</DropdownMenuSubContent>
|
|
235
|
-
</DropdownMenuSub>
|
|
236
|
-
) : null}
|
|
237
243
|
{linkable ? (
|
|
238
244
|
<DropdownMenuItem onSelect={() => setLinksOpen(true)}>
|
|
239
245
|
<Link2 aria-hidden="true" />
|
|
@@ -264,15 +270,42 @@ export function ResourceMenu({
|
|
|
264
270
|
</DropdownMenuContent>
|
|
265
271
|
</DropdownMenu>
|
|
266
272
|
{/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
|
|
267
|
-
the screen instead, where it is read
|
|
273
|
+
the screen instead, where it is read whatever the title line asked for. */}
|
|
268
274
|
{failure !== undefined && failure !== null ? (
|
|
269
|
-
<
|
|
270
|
-
role="alert"
|
|
271
|
-
className="fixed inset-x-0 bottom-5 z-50 mx-auto w-fit max-w-md rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
272
|
-
>
|
|
273
|
-
{i18n.t(resourceErrorKey(failure))}
|
|
274
|
-
</p>
|
|
275
|
+
<MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
|
|
275
276
|
) : null}
|
|
277
|
+
{/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
|
|
278
|
+
`resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
|
|
279
|
+
among the changes above, and one shared sentence would leave the reader guessing. */}
|
|
280
|
+
{move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
|
|
281
|
+
{/* ⚠️ Every reason at once, not the first one. Somebody with two missing tools should not have
|
|
282
|
+
to ask twice — and a success says so out loud, because an empty menu after a click reads
|
|
283
|
+
as "nothing happened" rather than "nothing is in the way". */}
|
|
284
|
+
{validation ? (
|
|
285
|
+
<Modal title={i18n.t("flows.validate")} close={() => setValidation(null)}>
|
|
286
|
+
{validation.problems.length === 0 ? (
|
|
287
|
+
<p className="text-sm">{i18n.t("flows.validateReady")}</p>
|
|
288
|
+
) : (
|
|
289
|
+
<ul className="space-y-2">
|
|
290
|
+
{validation.problems.map((problem) => (
|
|
291
|
+
<li key={problem.code} className="rounded-md border p-3 text-sm">
|
|
292
|
+
<span className="block font-medium">
|
|
293
|
+
{i18n.t(`flows.problem.${problem.code}`)}
|
|
294
|
+
</span>
|
|
295
|
+
<span className="mt-1 block text-xs text-muted-foreground">{problem.detail}</span>
|
|
296
|
+
</li>
|
|
297
|
+
))}
|
|
298
|
+
</ul>
|
|
299
|
+
)}
|
|
300
|
+
{/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
|
|
301
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
302
|
+
{i18n.t("flows.validateWhen", {
|
|
303
|
+
when: new Date(validation.checkedAt).toLocaleString(),
|
|
304
|
+
})}
|
|
305
|
+
</p>
|
|
306
|
+
</Modal>
|
|
307
|
+
) : null}
|
|
308
|
+
{move.dialog}
|
|
276
309
|
{renaming ? (
|
|
277
310
|
<RenameDialog
|
|
278
311
|
title={title}
|
|
@@ -291,6 +324,18 @@ export function ResourceMenu({
|
|
|
291
324
|
);
|
|
292
325
|
}
|
|
293
326
|
|
|
327
|
+
// One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
|
|
328
|
+
function MenuFailure({ children }: { children: React.ReactNode }) {
|
|
329
|
+
return (
|
|
330
|
+
<p
|
|
331
|
+
role="alert"
|
|
332
|
+
className="fixed inset-x-0 bottom-5 z-50 mx-auto w-fit max-w-md rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
333
|
+
>
|
|
334
|
+
{children}
|
|
335
|
+
</p>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
294
339
|
function RenameDialog({
|
|
295
340
|
title,
|
|
296
341
|
pending,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one line an open thing gets: its name on the left, everything that acts on it on the right
|
|
6
|
+
* (#53).
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ The order of the right-hand group belongs to this component, not to its callers. Whatever they
|
|
9
|
+
* pass as `children` is rendered before the menu and the menu is appended last, always — so a screen
|
|
10
|
+
* that grows a new button cannot push the three dots to the left, and there is no second place where
|
|
11
|
+
* the rule could be forgotten. That is the whole point: the menu holds rename, move, share and
|
|
12
|
+
* archive, and a position one has to look for is a position one stops using.
|
|
13
|
+
*
|
|
14
|
+
* The two slots are for what belongs to this thing but lives further down the tree, where its state
|
|
15
|
+
* is. `title-meta` is a quiet word beside the name (a table's row count); `title-actions` is a
|
|
16
|
+
* button in the group (saving a document, exporting a table). Both are filled through `ActionSlot`
|
|
17
|
+
* and both sit before the menu for the same reason `children` do.
|
|
18
|
+
*/
|
|
19
|
+
export function TitleRow({
|
|
20
|
+
title,
|
|
21
|
+
description,
|
|
22
|
+
target,
|
|
23
|
+
children,
|
|
24
|
+
}: {
|
|
25
|
+
title: string;
|
|
26
|
+
description?: string | null;
|
|
27
|
+
target: ResourceTarget;
|
|
28
|
+
children?: React.ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex items-start justify-between gap-5 border-b px-6 py-4">
|
|
32
|
+
<div className="min-w-0">
|
|
33
|
+
<div className="flex min-w-0 items-baseline gap-3">
|
|
34
|
+
<h2 className="truncate text-lg font-semibold">{title}</h2>
|
|
35
|
+
<span data-slot="title-meta" className="shrink-0 text-sm text-muted-foreground" />
|
|
36
|
+
</div>
|
|
37
|
+
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
|
|
38
|
+
</div>
|
|
39
|
+
{/* ⚠️ Document order is tab order here — nothing carries a `tabIndex`. The menu is therefore
|
|
40
|
+
reached last by the keyboard for the same reason it stands last on screen, and the two
|
|
41
|
+
cannot drift apart without someone rewriting this line. */}
|
|
42
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
43
|
+
<div data-slot="title-actions" className="flex items-center gap-2" />
|
|
44
|
+
{children}
|
|
45
|
+
<ResourceMenu target={target} variant="title" />
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/tools/tools.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { ToolCapability } from "@anchrd/intel-contract";
|
|
2
2
|
import { useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { useRouterState } from "@tanstack/react-router";
|
|
4
|
-
import { AlertTriangle, ChevronRight,
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import { Button, buttonVariants } from "@/components/ui/button";
|
|
4
|
+
import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Wrench } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
8
7
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
9
8
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
10
9
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -20,6 +19,23 @@ function toolOrigin(name: string): string | null {
|
|
|
20
19
|
return boundary > 0 ? name.slice(0, boundary) : null;
|
|
21
20
|
}
|
|
22
21
|
|
|
22
|
+
// Signed in to Intel is signed in to the portal, so the sign-in is attempted at most once per
|
|
23
|
+
// visit. A second attempt after a refusal would be a redirect loop with an unchanging answer, and
|
|
24
|
+
// the marker in the URL only survives until the next navigation — the browser session remembers it
|
|
25
|
+
// instead (#60).
|
|
26
|
+
const SignInAttemptKey = "intel.portal-sign-in-attempted";
|
|
27
|
+
|
|
28
|
+
// Reading `sessionStorage` throws outright in a few privacy modes, so the guard is a try, not a
|
|
29
|
+
// feature check. Losing the note costs one extra redirect, and the marker the refusal leaves in the
|
|
30
|
+
// URL still ends the walk — it must never cost the screen.
|
|
31
|
+
function attemptStore(): Storage | null {
|
|
32
|
+
try {
|
|
33
|
+
return typeof window === "undefined" ? null : window.sessionStorage;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
// The catalog is one live `tools/list` with the signed-in user's own portal token. Nothing here is
|
|
24
40
|
// stored, mirrored or administered — the screen shows what the portal answers and nothing else.
|
|
25
41
|
export function Tools() {
|
|
@@ -28,36 +44,34 @@ export function Tools() {
|
|
|
28
44
|
// A hit from the header search arrives as `?select=<tool name>`, and the row it names opens with
|
|
29
45
|
// the list. Nothing else about the row changes: it is still only a disclosure.
|
|
30
46
|
const requested = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
31
|
-
// The
|
|
32
|
-
// the value
|
|
33
|
-
const
|
|
47
|
+
// The silent sign-in returns here with a marker when the portal refused. Only its presence is
|
|
48
|
+
// used: the value comes from outside and could carry provider detail, so it is never rendered.
|
|
49
|
+
const refused =
|
|
34
50
|
typeof window !== "undefined" &&
|
|
35
51
|
new URLSearchParams(window.location.search).has("connectError");
|
|
52
|
+
const [signingIn, setSigningIn] = React.useState(false);
|
|
53
|
+
|
|
54
|
+
// ⚠️ Nobody is asked to start this. The portal sign-in is a browser redirect, so the screen
|
|
55
|
+
// begins it itself the moment the catalog says there is no portal session yet — that is the whole
|
|
56
|
+
// of what replaced the "Connect the portal" button.
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (!catalog.data) return;
|
|
59
|
+
const attempts = attemptStore();
|
|
60
|
+
if (catalog.data.portalConnected) {
|
|
61
|
+
// An answered sign-in frees the next one: when the token later expires beyond renewal, the
|
|
62
|
+
// same silent walk runs again rather than stopping at a screen.
|
|
63
|
+
attempts?.removeItem(SignInAttemptKey);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (refused || attempts?.getItem(SignInAttemptKey)) return;
|
|
67
|
+
attempts?.setItem(SignInAttemptKey, "1");
|
|
68
|
+
setSigningIn(true);
|
|
69
|
+
data.startPortalSignIn("/tools");
|
|
70
|
+
}, [catalog.data, data, refused]);
|
|
36
71
|
|
|
37
72
|
return (
|
|
38
73
|
<div className="min-h-full p-8">
|
|
39
|
-
{catalog.data?.portalConnected ? (
|
|
40
|
-
<ActionSlot>
|
|
41
|
-
<a
|
|
42
|
-
href={data.portalConnectUrl("/tools")}
|
|
43
|
-
className={buttonVariants({ variant: "outline", size: "sm" })}
|
|
44
|
-
>
|
|
45
|
-
<LogIn aria-hidden="true" />
|
|
46
|
-
{i18n.t("tools.reconnect")}
|
|
47
|
-
</a>
|
|
48
|
-
</ActionSlot>
|
|
49
|
-
) : null}
|
|
50
|
-
|
|
51
74
|
<div className="mx-auto w-full max-w-4xl space-y-5">
|
|
52
|
-
{connectFailed && (
|
|
53
|
-
<p
|
|
54
|
-
role="alert"
|
|
55
|
-
className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
|
|
56
|
-
>
|
|
57
|
-
{i18n.t("tools.connectFailed")}
|
|
58
|
-
</p>
|
|
59
|
-
)}
|
|
60
|
-
|
|
61
75
|
{catalog.isPending ? (
|
|
62
76
|
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
63
77
|
) : catalog.isError ? (
|
|
@@ -74,16 +88,21 @@ export function Tools() {
|
|
|
74
88
|
</Button>
|
|
75
89
|
</Notice>
|
|
76
90
|
) : !catalog.data.portalConnected ? (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
signingIn ? (
|
|
92
|
+
<p className="text-sm text-muted-foreground">{i18n.t("tools.signingIn")}</p>
|
|
93
|
+
) : (
|
|
94
|
+
// The end of the silent walk for somebody no Access policy carries. It is a sentence
|
|
95
|
+
// about access, not an invitation to connect: there is nothing they could click that
|
|
96
|
+
// would change the answer, and what the portal replied is not repeated here.
|
|
97
|
+
<Notice
|
|
98
|
+
alert
|
|
99
|
+
icon={
|
|
100
|
+
<ShieldOff aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
|
|
101
|
+
}
|
|
102
|
+
title={i18n.t("tools.noAccess")}
|
|
103
|
+
help={i18n.t("tools.noAccessHelp")}
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
87
106
|
) : catalog.data.items.length === 0 ? (
|
|
88
107
|
<Notice
|
|
89
108
|
icon={<Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
|