@anchrd/intel-ui 0.8.5 → 0.8.7
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 +3 -2
- package/scripts/lint-tokens.mjs +187 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +6 -4
- package/src/data/sign-in/sign-in.ts +97 -0
- package/src/data/sign-in/sign-in.types.ts +15 -0
- package/src/folder-contents/folder-contents.tsx +1 -1
- package/src/i18n/en.json +4 -0
- package/src/main.tsx +35 -9
- package/src/resource-menu/resource-menu.tsx +2 -2
- package/src/sign-in-refused/sign-in-refused.tsx +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
|
+
"scripts",
|
|
16
17
|
"!src/**/*.unit.ts",
|
|
17
18
|
"!src/**/*.unit.tsx",
|
|
18
19
|
"index.html",
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
"scripts": {
|
|
24
25
|
"build": "vite build",
|
|
25
26
|
"dev": "vite",
|
|
26
|
-
"lint:tokens": "
|
|
27
|
+
"lint:tokens": "node scripts/lint-tokens.mjs src",
|
|
27
28
|
"test": "vitest run",
|
|
28
29
|
"typecheck": "tsc --noEmit"
|
|
29
30
|
},
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Semantic tokens only: no hex colours, no Tailwind palette classes. The check this replaces was a
|
|
3
|
+
// single grep, and it could not tell `#101` — a ticket number in a comment — from `#101` the
|
|
4
|
+
// colour, because nothing separates them but where they stand (issue 104).
|
|
5
|
+
//
|
|
6
|
+
// ⚠️ The fix is to look at less, not to match less. Comments are stripped and everything else is
|
|
7
|
+
// checked with exactly the patterns the grep used, so no colour that used to be caught slips
|
|
8
|
+
// through. A colour named in a comment paints nothing; a colour in code paints something.
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ Stripping comments needs to know what a comment is. `//` inside a string is a URL, not a
|
|
11
|
+
// comment, and getting that wrong would blind the check to real code — the failure mode this file
|
|
12
|
+
// exists to avoid. Hence the small scanner rather than a regex over the raw text.
|
|
13
|
+
|
|
14
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
15
|
+
import { join, relative } from "node:path";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
|
|
18
|
+
const ROOT = process.argv[2] ?? "src";
|
|
19
|
+
|
|
20
|
+
// Exactly the patterns the shell version used.
|
|
21
|
+
const FORBIDDEN = [
|
|
22
|
+
{ name: "hex colour", pattern: /#[0-9a-fA-F]{3,8}\b/ },
|
|
23
|
+
{
|
|
24
|
+
name: "palette class",
|
|
25
|
+
pattern:
|
|
26
|
+
/\b(bg|text|border|ring|shadow|fill|stroke)-(white|black|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)(-\d{2,3})?\b/,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// Test files may spell anything out — they are where a rule gets stated, including by counter
|
|
31
|
+
// example. Same exclusion the shell version carried.
|
|
32
|
+
const SKIP_FILE = /\.unit\.tsx?$/;
|
|
33
|
+
const CHECK_FILE = /\.(tsx?|css)$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The file with its comments blanked out, line structure intact so line numbers still mean
|
|
37
|
+
* something. Strings keep their contents: a hex colour in a string is exactly what must be caught.
|
|
38
|
+
*/
|
|
39
|
+
function withoutComments(source, isCss) {
|
|
40
|
+
let out = "";
|
|
41
|
+
let i = 0;
|
|
42
|
+
// "code" | "line" | "block" | quote character for a string
|
|
43
|
+
let state = "code";
|
|
44
|
+
while (i < source.length) {
|
|
45
|
+
const char = source[i];
|
|
46
|
+
const next = source[i + 1];
|
|
47
|
+
if (state === "code") {
|
|
48
|
+
// A regex literal would be the third thing that can hold a `/`, but none in this package
|
|
49
|
+
// contains `//` or `/*`, and treating one as a comment would only ever blind the check to
|
|
50
|
+
// something inside a regex — never let a colour through.
|
|
51
|
+
if (!isCss && char === "/" && next === "/") {
|
|
52
|
+
state = "line";
|
|
53
|
+
out += " ";
|
|
54
|
+
i += 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (char === "/" && next === "*") {
|
|
58
|
+
state = "block";
|
|
59
|
+
out += " ";
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
64
|
+
state = char;
|
|
65
|
+
out += char;
|
|
66
|
+
i += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
out += char;
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (state === "line") {
|
|
74
|
+
if (char === "\n") {
|
|
75
|
+
state = "code";
|
|
76
|
+
out += char;
|
|
77
|
+
} else {
|
|
78
|
+
out += " ";
|
|
79
|
+
}
|
|
80
|
+
i += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (state === "block") {
|
|
84
|
+
if (char === "*" && next === "/") {
|
|
85
|
+
state = "code";
|
|
86
|
+
out += " ";
|
|
87
|
+
i += 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out += char === "\n" ? char : " ";
|
|
91
|
+
i += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Inside a string: kept as it stands, escapes skipped so `\"` does not end it early.
|
|
95
|
+
if (char === "\\") {
|
|
96
|
+
out += source.slice(i, i + 2);
|
|
97
|
+
i += 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (char === state) state = "code";
|
|
101
|
+
out += char;
|
|
102
|
+
i += 1;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The checker checked, on every run.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ This is here because of how the shell version could have failed: a pattern that matches
|
|
111
|
+
* nothing, or an exclusion that swallows the whole tree, reports success. `Checked 0 files` and
|
|
112
|
+
* `checked everything, all clean` look identical from the outside, and the second one is what
|
|
113
|
+
* everybody reads. Stating the cases the check exists for — and the ones it must stay quiet about —
|
|
114
|
+
* means a rule that stops working says so instead of going green.
|
|
115
|
+
*/
|
|
116
|
+
function selfTest() {
|
|
117
|
+
const mustCatch = [
|
|
118
|
+
['const c = "#fff";', "a three-digit hex in a string"],
|
|
119
|
+
['const c = "#a1b2c3";', "a six-digit hex"],
|
|
120
|
+
['const c = "#11223344";', "an eight-digit hex with alpha"],
|
|
121
|
+
[" color: #fff;", "a hex in CSS"],
|
|
122
|
+
['<div className="bg-red-500" />', "a palette class"],
|
|
123
|
+
['<div className="text-white" />', "a palette colour without a number"],
|
|
124
|
+
];
|
|
125
|
+
const mustAllow = [
|
|
126
|
+
["// the folder table, since (#101)", "a ticket number in a line comment"],
|
|
127
|
+
["/* archived like a document (#109) */", "a ticket number in a block comment"],
|
|
128
|
+
["{/* two lists say what they show (#99) */}", "a ticket number in a JSX comment"],
|
|
129
|
+
['<div className="bg-background text-muted-foreground" />', "semantic tokens"],
|
|
130
|
+
];
|
|
131
|
+
// ⚠️ Not in either list, and deliberately: a URL fragment that happens to read like a colour —
|
|
132
|
+
// `https://example.com/#fff` — is still reported. The grep did the same, so nothing regressed
|
|
133
|
+
// here, and the one way to stop it would be to make the colour pattern narrower. That trade goes
|
|
134
|
+
// the wrong way: a false alarm costs one rewritten line, a missed colour costs the rule. The
|
|
135
|
+
// first attempt at this file listed it as "must allow" and this check refused it, which is the
|
|
136
|
+
// check doing its job.
|
|
137
|
+
const failures = [];
|
|
138
|
+
for (const [line, what] of mustCatch) {
|
|
139
|
+
const code = withoutComments(line, line.startsWith(" color:"));
|
|
140
|
+
if (!FORBIDDEN.some(({ pattern }) => pattern.test(code))) {
|
|
141
|
+
failures.push(`stopped catching ${what}: ${line}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const [line, what] of mustAllow) {
|
|
145
|
+
const code = withoutComments(line, false);
|
|
146
|
+
if (FORBIDDEN.some(({ pattern }) => pattern.test(code))) {
|
|
147
|
+
failures.push(`started refusing ${what}: ${line}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (failures.length > 0) {
|
|
151
|
+
console.error("The token check is no longer checking what it is for:\n");
|
|
152
|
+
for (const failure of failures) console.error(` ${failure}`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function* files(dir) {
|
|
158
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
159
|
+
const path = join(dir, entry.name);
|
|
160
|
+
if (entry.isDirectory()) yield* files(path);
|
|
161
|
+
else if (CHECK_FILE.test(entry.name) && !SKIP_FILE.test(entry.name)) yield path;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
selfTest();
|
|
166
|
+
|
|
167
|
+
const findings = [];
|
|
168
|
+
for await (const path of files(ROOT)) {
|
|
169
|
+
const source = await readFile(path, "utf8");
|
|
170
|
+
const code = withoutComments(source, path.endsWith(".css"));
|
|
171
|
+
code.split("\n").forEach((line, index) => {
|
|
172
|
+
for (const { name, pattern } of FORBIDDEN) {
|
|
173
|
+
const hit = pattern.exec(line);
|
|
174
|
+
if (hit) findings.push(`${relative(".", path)}:${index + 1} ${name} ${hit[0]}`);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (findings.length > 0) {
|
|
180
|
+
console.error(
|
|
181
|
+
`Use semantic tokens instead. ${findings.length} hard-coded value${findings.length === 1 ? "" : "s"}:\n`,
|
|
182
|
+
);
|
|
183
|
+
for (const finding of findings) console.error(` ${finding}`);
|
|
184
|
+
console.error("\nA ticket reference in a comment is fine — comments are not checked.");
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
console.log(`No hard-coded colours or palette classes in ${ROOT}.`);
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
UpdateKnowledgeNodeInput,
|
|
51
51
|
} from "@anchrd/intel-contract";
|
|
52
52
|
import type { z } from "zod";
|
|
53
|
+
import { createBrowserSignIn } from "@/data/sign-in/sign-in.ts";
|
|
53
54
|
import type { IntelDataProvider, TreeEntry } from "./intel-data-provider.types.ts";
|
|
54
55
|
|
|
55
56
|
// ⚠️ The refusal's `code`, not only its prose. A view that has to tell four different refusals
|
|
@@ -104,13 +105,14 @@ export function createIntelDataProvider(
|
|
|
104
105
|
((url: string) => {
|
|
105
106
|
if (typeof window !== "undefined") window.location.assign(url);
|
|
106
107
|
});
|
|
108
|
+
// ⚠️ The default goes through `createBrowserSignIn` rather than redirecting straight away, and it
|
|
109
|
+
// has to: this is the code path that produced the endless reload (#118). A caller that supplies
|
|
110
|
+
// its own `onUnauthorized` takes the guard on as well — `main.tsx` does, because it also has to
|
|
111
|
+
// put something on the screen once the redirect is refused.
|
|
107
112
|
const unauthorized =
|
|
108
113
|
deps.onUnauthorized ??
|
|
109
114
|
(() => {
|
|
110
|
-
if (typeof window !== "undefined")
|
|
111
|
-
const returnTo = `${window.location.pathname}${window.location.search}`;
|
|
112
|
-
window.location.assign(loginPath(returnTo));
|
|
113
|
-
}
|
|
115
|
+
if (typeof window !== "undefined") createBrowserSignIn(loginPath).attempt();
|
|
114
116
|
});
|
|
115
117
|
|
|
116
118
|
async function response(path: string, init: RequestInit = {}): Promise<Response> {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Sending somebody to sign in is the right answer to a 401 — but only ONCE. If the surface still
|
|
2
|
+
// answers 401 after a successful sign-in, redirecting again produces a page that reloads forever:
|
|
3
|
+
// Gate signs the person in, the interface asks, the answer is 401, and round it goes. From the
|
|
4
|
+
// outside it looks like a broken deployment; from the inside every single step succeeded, which is
|
|
5
|
+
// what makes it expensive to find (#118).
|
|
6
|
+
//
|
|
7
|
+
// It happens whenever a token is valid to Gate but not accepted by `/api/v1` — a session that
|
|
8
|
+
// expires between the two calls, a bearer for a different audience, a service key that no longer
|
|
9
|
+
// holds. The same fix landed in anchrd/agents first; this is its counterpart here.
|
|
10
|
+
|
|
11
|
+
import type { SignIn, SignInDeps } from "./sign-in.types.ts";
|
|
12
|
+
|
|
13
|
+
const Marker = "intel:last-sign-in";
|
|
14
|
+
|
|
15
|
+
// Long enough to cover Gate's redirect chain, short enough that somebody who genuinely returns
|
|
16
|
+
// hours later gets a fresh attempt rather than an error page.
|
|
17
|
+
const LoopWindowMs = 30_000;
|
|
18
|
+
|
|
19
|
+
export function createSignIn(deps: SignInDeps): SignIn {
|
|
20
|
+
return {
|
|
21
|
+
attempt() {
|
|
22
|
+
// ⚠️ Every unusable marker counts as "no recent attempt", never as "loop" — missing, damaged,
|
|
23
|
+
// and dated in the future all mean the same thing here. The last one is not hypothetical: a
|
|
24
|
+
// clock that jumps backwards (time zone, NTP) leaves a marker ahead of `now`, and reading a
|
|
25
|
+
// negative age as "just tried" would lock somebody out over the wrong wall clock. Refusing
|
|
26
|
+
// costs an error page; allowing costs one extra reload, so the doubt goes to allowing.
|
|
27
|
+
const last = Number(deps.read(Marker) ?? 0);
|
|
28
|
+
const age = deps.now() - last;
|
|
29
|
+
if (Number.isFinite(last) && last > 0 && age >= 0 && age < LoopWindowMs) return false;
|
|
30
|
+
|
|
31
|
+
deps.write(Marker, String(deps.now()));
|
|
32
|
+
deps.go(deps.loginPath(deps.currentPath()));
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The same thing wired to the browser.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ `sessionStorage`, not `localStorage`: the window is about one sign-in journey, and a marker
|
|
42
|
+
* that outlives the tab would meet the next visit as if it were still mid-loop. Both throw rather
|
|
43
|
+
* than return null when storage is blocked, so both accesses are guarded — see `attempt`.
|
|
44
|
+
*/
|
|
45
|
+
export function createBrowserSignIn(loginPath: (returnTo?: string) => string): SignIn {
|
|
46
|
+
return createSignIn({
|
|
47
|
+
now: () => Date.now(),
|
|
48
|
+
read: (key) => {
|
|
49
|
+
try {
|
|
50
|
+
return window.sessionStorage.getItem(key);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
write: (key, value) => {
|
|
56
|
+
try {
|
|
57
|
+
window.sessionStorage.setItem(key, value);
|
|
58
|
+
} catch {
|
|
59
|
+
// Nothing to do: without a marker the next 401 redirects once more. One extra reload beats
|
|
60
|
+
// an error page for somebody who could have been let in.
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
go: (url) => window.location.assign(url),
|
|
64
|
+
currentPath: () => `${window.location.pathname}${window.location.search}`,
|
|
65
|
+
loginPath,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What to do with a 401, across all of them at once (#130).
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ The reason this is a guard and not a call to `attempt()` at each 401: a first load without a
|
|
73
|
+
* session fires several requests together — session, tree, flows — and every one of them answers
|
|
74
|
+
* 401. The first starts the redirect; the rest arrive while the browser is still on this page,
|
|
75
|
+
* find the marker the first one just wrote, and read it as "we already tried, this is a loop". It
|
|
76
|
+
* is not — it is this very sign-in, still in flight.
|
|
77
|
+
*
|
|
78
|
+
* Without `leaving`, the interface refuses itself before it can leave, and nobody reaches the
|
|
79
|
+
* login at all. That is worse than the loop this whole mechanism exists to prevent: a loop still
|
|
80
|
+
* eventually shows a login, a self-refusal never does.
|
|
81
|
+
*/
|
|
82
|
+
export function createSignInGuard(signIn: SignIn, onRefused: () => void): () => void {
|
|
83
|
+
let leaving = false;
|
|
84
|
+
// Latches for its own reason: once the surface has turned down a freshly issued credential,
|
|
85
|
+
// later 401s say nothing new, and repainting for each would flicker the screen once per request
|
|
86
|
+
// the page had in flight.
|
|
87
|
+
let refused = false;
|
|
88
|
+
return () => {
|
|
89
|
+
if (leaving || refused) return;
|
|
90
|
+
if (signIn.attempt()) {
|
|
91
|
+
leaving = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
refused = true;
|
|
95
|
+
onRefused();
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SignInDeps {
|
|
2
|
+
now(): number;
|
|
3
|
+
read(key: string): string | null;
|
|
4
|
+
write(key: string, value: string): void;
|
|
5
|
+
go(url: string): void;
|
|
6
|
+
currentPath(): string;
|
|
7
|
+
loginPath(returnTo?: string): string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SignIn {
|
|
11
|
+
// True when it sent the person away, false when it refused to because it just did. A caller that
|
|
12
|
+
// gets `false` must show something rather than wait — the redirect that would have explained
|
|
13
|
+
// itself is exactly what is not happening.
|
|
14
|
+
attempt(): boolean;
|
|
15
|
+
}
|
|
@@ -33,7 +33,7 @@ function targetOf(entry: TreeEntry): ResourceTarget {
|
|
|
33
33
|
* that now includes the ones this screen's own menu makes: `ResourceMenu` invalidates
|
|
34
34
|
* `["tree", parentId]`, which is this level.
|
|
35
35
|
*
|
|
36
|
-
* ⚠️ There is no "Kind" column since
|
|
36
|
+
* ⚠️ There is no "Kind" column since #101. The icon says what a row is, in the same picture the
|
|
37
37
|
* sidebar draws two centimetres to its left, and `sr-only` says it in words for everyone who does
|
|
38
38
|
* not read pictures. A column repeating the icon in prose costs the width the title needs.
|
|
39
39
|
*/
|
package/src/i18n/en.json
CHANGED
|
@@ -259,6 +259,10 @@
|
|
|
259
259
|
"runs.outcome.failed": "failed",
|
|
260
260
|
"auth.signOut": "Sign out",
|
|
261
261
|
"auth.signOutFailed": "Signing out failed. Check your connection and try again.",
|
|
262
|
+
"signIn.refusedTitle": "Signed in, but not accepted",
|
|
263
|
+
"signIn.refusedBody": "Gate signed you in, and this installation did not accept the credential. You have not been sent back to the login, because that is where the loop starts.",
|
|
264
|
+
"signIn.refusedLikely": "Most often the session expired between the two steps, or the credential was issued for a different installation. Reloading is worth one try; after that, whoever runs this installation has to look.",
|
|
265
|
+
"signIn.refusedRetry": "Try again",
|
|
262
266
|
"common.title": "Title",
|
|
263
267
|
"common.create": "Create",
|
|
264
268
|
"common.save": "Save",
|
package/src/main.tsx
CHANGED
|
@@ -2,9 +2,14 @@ import { QueryClient, 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";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createIntelDataProvider,
|
|
7
|
+
loginPath,
|
|
8
|
+
} from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
9
|
+
import { createBrowserSignIn, createSignInGuard } from "@/data/sign-in/sign-in.ts";
|
|
6
10
|
import { createI18n } from "@/i18n/i18n.ts";
|
|
7
11
|
import { createIntelRouter } from "@/router/router.tsx";
|
|
12
|
+
import { SignInRefused } from "@/sign-in-refused/sign-in-refused.tsx";
|
|
8
13
|
import { SystemThemeQuery, watchSystemTheme } from "@/theme/theme.ts";
|
|
9
14
|
import "./styles.css";
|
|
10
15
|
import "./theme/custom.css";
|
|
@@ -13,14 +18,35 @@ watchSystemTheme(document.documentElement, window.matchMedia(SystemThemeQuery));
|
|
|
13
18
|
|
|
14
19
|
const root = document.getElementById("root");
|
|
15
20
|
if (!root) throw new Error("Missing #root element");
|
|
16
|
-
const data = createIntelDataProvider();
|
|
17
21
|
const i18n = createI18n();
|
|
22
|
+
const signIn = createBrowserSignIn(loginPath);
|
|
23
|
+
const mounted = createRoot(root);
|
|
24
|
+
const queryClient = new QueryClient();
|
|
25
|
+
|
|
26
|
+
// The shell is painted from a function because a refused sign-in has to replace it, and that
|
|
27
|
+
// refusal arrives from inside a fetch rather than from React.
|
|
28
|
+
let refused = false;
|
|
29
|
+
|
|
30
|
+
const data = createIntelDataProvider({
|
|
31
|
+
onUnauthorized: createSignInGuard(signIn, () => {
|
|
32
|
+
refused = true;
|
|
33
|
+
paint();
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
18
36
|
const router = createIntelRouter({ data, i18n });
|
|
19
37
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
function paint(): void {
|
|
39
|
+
mounted.render(
|
|
40
|
+
<StrictMode>
|
|
41
|
+
{refused ? (
|
|
42
|
+
<SignInRefused i18n={i18n} />
|
|
43
|
+
) : (
|
|
44
|
+
<QueryClientProvider client={queryClient}>
|
|
45
|
+
<RouterProvider router={router} />
|
|
46
|
+
</QueryClientProvider>
|
|
47
|
+
)}
|
|
48
|
+
</StrictMode>,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
paint();
|
|
@@ -105,7 +105,7 @@ export function resourceErrorKey(error: unknown): string {
|
|
|
105
105
|
* `variant` is only how the trigger is painted, never which entries it holds. `title` is a plain
|
|
106
106
|
* icon button at the end of a title line, put there by `TitleRow` and by nobody else, so it is
|
|
107
107
|
* always the last thing in that line (#53). `row` is the form for a row in a list — quiet until the
|
|
108
|
-
* row is hovered or something in it takes focus — and since
|
|
108
|
+
* row is hovered or something in it takes focus — and since #101 that list is the folder table.
|
|
109
109
|
*
|
|
110
110
|
* ⚠️ `row` needs a `group/row` on the row it sits in, or it stays invisible to the mouse: the
|
|
111
111
|
* reveal is `group-hover/row`, and a group that is not there never hovers. Below `md` and under
|
|
@@ -279,7 +279,7 @@ export function ResourceMenu({
|
|
|
279
279
|
{i18n.t("knowledge.share")}
|
|
280
280
|
</DropdownMenuItem>
|
|
281
281
|
) : null}
|
|
282
|
-
{/* A flow is archived like a document (
|
|
282
|
+
{/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
|
|
283
283
|
loses is reach — it leaves the tree, cannot be started, and stops resolving as another
|
|
284
284
|
flow's callee. */}
|
|
285
285
|
<DropdownMenuSeparator />
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ShieldAlert } from "lucide-react";
|
|
2
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What stands on the screen when signing in worked and the surface still says no (#118).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ It exists because the honest alternative is worse. "Not signed in" would be a lie — Gate did
|
|
8
|
+
* sign the person in — and it is the lie that sends them back to the login they just came from,
|
|
9
|
+
* which is the loop. This names the state instead: signed in, credential refused, here is what it
|
|
10
|
+
* is usually.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ No automatic retry, and the button reloads rather than going to the login. Whatever refused
|
|
13
|
+
* the credential a second ago will refuse it again, and a page that keeps trying by itself is the
|
|
14
|
+
* behaviour being fixed. Retrying is the reader's decision, and it costs them one click.
|
|
15
|
+
*/
|
|
16
|
+
export function SignInRefused({ i18n }: { i18n: I18n }) {
|
|
17
|
+
return (
|
|
18
|
+
<div role="alert" className="grid min-h-dvh place-items-center bg-background p-8">
|
|
19
|
+
<div className="max-w-prose text-center">
|
|
20
|
+
<ShieldAlert aria-hidden="true" className="mx-auto mb-4 size-8 text-muted-foreground" />
|
|
21
|
+
<h1 className="text-lg font-medium text-foreground">{i18n.t("signIn.refusedTitle")}</h1>
|
|
22
|
+
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("signIn.refusedBody")}</p>
|
|
23
|
+
<p className="mt-4 text-sm text-muted-foreground">{i18n.t("signIn.refusedLikely")}</p>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={() => window.location.reload()}
|
|
27
|
+
className="mt-6 rounded-md border px-4 py-2 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
28
|
+
>
|
|
29
|
+
{i18n.t("signIn.refusedRetry")}
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|