@checkstack/frontend 0.9.0 → 0.9.1
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/CHANGELOG.md +95 -0
- package/package.json +13 -13
- package/src/App.tsx +8 -7
- package/src/components/Sidebar.logic.test.ts +140 -0
- package/src/components/Sidebar.logic.ts +88 -0
- package/src/components/Sidebar.tsx +15 -36
- package/vite.config.ts +51 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,100 @@
|
|
|
1
1
|
# @checkstack/frontend
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 16f89bd: Disable production source maps by default in the frontend build. Source maps over
|
|
8
|
+
the bundled Monaco / VS Code (`@codingame/*`) editor stack roughly doubled the
|
|
9
|
+
build's time and peak memory and shipped several MB of `.js.map` into the image -
|
|
10
|
+
enough to OOM-thrash a CI runner (the Docker image build hung on the frontend
|
|
11
|
+
build step). They are now off by default; opt in locally with
|
|
12
|
+
`VITE_SOURCEMAP=true bun run --filter '@checkstack/frontend' build` when you need
|
|
13
|
+
to debug the production bundle.
|
|
14
|
+
|
|
15
|
+
Also fixes the `@module-federation/vite` `shared` typing (its published type omits
|
|
16
|
+
`eager` and types `requiredVersion` as `string`, narrower than the MF runtime),
|
|
17
|
+
so `vite.config.ts` no longer reports type errors.
|
|
18
|
+
|
|
19
|
+
- 56e7c75: Hide navigation, actions and links that the current user cannot use, so anonymous
|
|
20
|
+
and read-only users no longer see entries that lead to "Access Denied" or to
|
|
21
|
+
actions the server would reject.
|
|
22
|
+
|
|
23
|
+
- **Sidebar**: a nav entry can now declare a dynamic `nav.isVisible({ accessRules, isAuthenticated })` predicate (in addition to the static `accessRule`). A group whose every entry is filtered out is no longer rendered. The filtering/grouping logic is extracted to a pure, unit-tested helper.
|
|
24
|
+
- **Infrastructure**: its sidebar entry is shown only when the user can READ at least one contributed tab (queue, cache, …), instead of always (it previously had no static rule because tabs are contributed at runtime).
|
|
25
|
+
- **Notification Settings**: hidden from anonymous users - notifications are per-user, so an anonymous visitor can't have any.
|
|
26
|
+
- **Anomaly Mute / Suppress**: the "Mute" / "Mute all" controls (a per-user preference) are hidden from anonymous visitors; the "Suppress" control is gated on `anomalyAccess.feed.manage`. Both were previously always visible.
|
|
27
|
+
- **Dashboard**: the "Open Catalog" actions (which open the manage-only Catalog config page) are hidden from users without `catalogAccess.system.manage`, and the "View catalog" link is gated on `catalogAccess.system.read`.
|
|
28
|
+
- **Dashboard status signals**: the per-system status rows contributed by plugins (`SystemSignalsSlot`) now render as a LINK only when the user can open the target, and as plain text otherwise. `SystemSignal` gains an optional `accessRule`; the healthcheck, anomaly, and dependency fillers set it for their gated targets (check-history / assignments / dependency-map). Signals pointing at ungated pages (incident / maintenance / SLO detail) stay links.
|
|
29
|
+
- **Plugin Manager**: the "Install plugin" button (which opens the install-gated page) is hidden from users with only `plugin` view access.
|
|
30
|
+
- **Satellites**: the page is entirely manage-gated, but its route/sidebar entry was gated on `read`, so read-only users saw the nav item and hit "Access Denied" on click. The route and nav entry now require `satellite.manage`.
|
|
31
|
+
|
|
32
|
+
The `@checkstack/ai-backend` bump is only the regenerated bundled docs index
|
|
33
|
+
(the frontend routing guide gained the `nav.isVisible` section); no code change.
|
|
34
|
+
|
|
35
|
+
**BREAKING (`@checkstack/frontend-api`):** the `AccessApi` interface gains a
|
|
36
|
+
required `useIsAuthenticated()` method. Custom `AccessApi` implementations must
|
|
37
|
+
add it (it returns `{ loading, isAuthenticated }`). The built-in auth
|
|
38
|
+
implementation and the no-auth fallback already do. `NavEntry` also gains an
|
|
39
|
+
optional `isVisible` predicate (purely additive).
|
|
40
|
+
|
|
41
|
+
- 56e7c75: Fix frontend access checks to use FULLY-QUALIFIED access-rule ids, and resolve
|
|
42
|
+
the anonymous role on the frontend.
|
|
43
|
+
|
|
44
|
+
Granted access-rule ids are stored fully-qualified as `{pluginId}.{ruleId}` (e.g.
|
|
45
|
+
`incident.incident.read`) so two plugins defining the same short rule id never
|
|
46
|
+
collide. The frontend, however, was checking the UNqualified id (`incident.read`)
|
|
47
|
+
via `isAccessRuleSatisfied`, so every check failed for any user without the `*`
|
|
48
|
+
(admin) grant - masked in development because dev-auth grants `*`. This silently
|
|
49
|
+
broke ALL non-admin frontend gating (route guards, sidebar entries, and
|
|
50
|
+
`useAccess`-based button/link gating).
|
|
51
|
+
|
|
52
|
+
- **`@checkstack/common`**: `AccessRule` now carries a REQUIRED owning `pluginId`;
|
|
53
|
+
`access()` / `accessPair()` require and stamp it; `isAccessRuleSatisfied`
|
|
54
|
+
qualifies the rule (`{pluginId}.{id}`, plus the manage->read escalation) and
|
|
55
|
+
matches ONLY the qualified form. There is intentionally NO unqualified fallback
|
|
56
|
+
- matching a bare id would let one plugin's grant satisfy another plugin's
|
|
57
|
+
identically-named rule (a cross-plugin privilege-escalation flaw). Every plugin
|
|
58
|
+
that defines access rules now passes its own `pluginId`.
|
|
59
|
+
- **`@checkstack/backend`**: `pluginManager.getAllAccessRules()` no longer strips
|
|
60
|
+
the `pluginId` field (the rule `id` is already fully-qualified for the DB sync).
|
|
61
|
+
- **Route guard** (`@checkstack/frontend` / `@checkstack/frontend-api`) now
|
|
62
|
+
checks the FULL rule object (so it qualifies and escalates), not a bare id.
|
|
63
|
+
- **Anonymous role on the frontend**: the `accessRules` procedure is now
|
|
64
|
+
`public`, returning the configurable anonymous role's grants to unauthenticated
|
|
65
|
+
callers; `useAccessRules` fetches them for guests instead of returning an empty
|
|
66
|
+
set. So anonymous UI now reflects exactly what the anonymous role is allowed -
|
|
67
|
+
which an admin can change (`isPublic` is only the seeded default).
|
|
68
|
+
- Incident / maintenance / SLO detail routes are now read-gated (their read rule
|
|
69
|
+
is an `isPublic` default, so the anonymous role holds it unless an admin
|
|
70
|
+
revokes it); their dashboard status signals carry that rule and render as a
|
|
71
|
+
link only when the viewer may open it.
|
|
72
|
+
|
|
73
|
+
**BREAKING (`@checkstack/common`):** `AccessRule.pluginId` is now REQUIRED, and
|
|
74
|
+
`access()` / `accessPair()` require a `pluginId` option. `isAccessRuleSatisfied`
|
|
75
|
+
matches ONLY the fully-qualified `{pluginId}.{ruleId}` form - the previous
|
|
76
|
+
unqualified fallback is removed, because it was a cross-plugin
|
|
77
|
+
privilege-escalation flaw. Any code constructing an `AccessRule` or calling
|
|
78
|
+
`access()`/`accessPair()` must supply the owning `pluginId`.
|
|
79
|
+
|
|
80
|
+
Verified live against an anonymous caller: read pages resolve (qualified match),
|
|
81
|
+
manage actions are denied, manage->read escalation and `*` still work.
|
|
82
|
+
|
|
83
|
+
- Updated dependencies [0626782]
|
|
84
|
+
- Updated dependencies [56e7c75]
|
|
85
|
+
- Updated dependencies [56e7c75]
|
|
86
|
+
- @checkstack/auth-frontend@0.7.5
|
|
87
|
+
- @checkstack/frontend-api@0.9.0
|
|
88
|
+
- @checkstack/dependency-frontend@0.5.5
|
|
89
|
+
- @checkstack/ui@1.15.1
|
|
90
|
+
- @checkstack/common@0.15.0
|
|
91
|
+
- @checkstack/catalog-frontend@0.11.5
|
|
92
|
+
- @checkstack/announcement-frontend@0.4.5
|
|
93
|
+
- @checkstack/about-frontend@0.3.5
|
|
94
|
+
- @checkstack/command-frontend@0.3.5
|
|
95
|
+
- @checkstack/signal-common@0.2.9
|
|
96
|
+
- @checkstack/signal-frontend@0.2.4
|
|
97
|
+
|
|
3
98
|
## 0.9.0
|
|
4
99
|
|
|
5
100
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/frontend",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"checkstack": {
|
|
6
6
|
"type": "frontend"
|
|
@@ -20,17 +20,17 @@
|
|
|
20
20
|
"lint:code": "eslint . --max-warnings 0"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@checkstack/about-frontend": "0.3.
|
|
24
|
-
"@checkstack/announcement-frontend": "0.4.
|
|
25
|
-
"@checkstack/auth-frontend": "0.7.
|
|
26
|
-
"@checkstack/catalog-frontend": "0.11.
|
|
27
|
-
"@checkstack/command-frontend": "0.3.
|
|
28
|
-
"@checkstack/common": "0.
|
|
29
|
-
"@checkstack/dependency-frontend": "0.5.
|
|
30
|
-
"@checkstack/frontend-api": "0.
|
|
31
|
-
"@checkstack/signal-common": "0.2.
|
|
32
|
-
"@checkstack/signal-frontend": "0.2.
|
|
33
|
-
"@checkstack/ui": "1.15.
|
|
23
|
+
"@checkstack/about-frontend": "0.3.5",
|
|
24
|
+
"@checkstack/announcement-frontend": "0.4.5",
|
|
25
|
+
"@checkstack/auth-frontend": "0.7.5",
|
|
26
|
+
"@checkstack/catalog-frontend": "0.11.5",
|
|
27
|
+
"@checkstack/command-frontend": "0.3.5",
|
|
28
|
+
"@checkstack/common": "0.15.0",
|
|
29
|
+
"@checkstack/dependency-frontend": "0.5.5",
|
|
30
|
+
"@checkstack/frontend-api": "0.9.0",
|
|
31
|
+
"@checkstack/signal-common": "0.2.9",
|
|
32
|
+
"@checkstack/signal-frontend": "0.2.4",
|
|
33
|
+
"@checkstack/ui": "1.15.1",
|
|
34
34
|
"@module-federation/runtime": "^2.5",
|
|
35
35
|
"@module-federation/vite": "^1.16",
|
|
36
36
|
"@orpc/client": "^1.14.4",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"tailwindcss-animate": "^1.0.7"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@checkstack/scripts": "0.6.
|
|
52
|
+
"@checkstack/scripts": "0.6.1",
|
|
53
53
|
"@checkstack/tsconfig": "0.0.7",
|
|
54
54
|
"@types/react": "^19.0.0",
|
|
55
55
|
"@types/react-dom": "^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useNavigate,
|
|
8
8
|
} from "react-router-dom";
|
|
9
9
|
import { Menu } from "lucide-react";
|
|
10
|
+
import type { AccessRule } from "@checkstack/common";
|
|
10
11
|
import {
|
|
11
12
|
ApiProvider,
|
|
12
13
|
ApiRegistryBuilder,
|
|
@@ -121,15 +122,13 @@ function GlobalShortcuts() {
|
|
|
121
122
|
|
|
122
123
|
const RouteGuard: React.FC<{
|
|
123
124
|
children: React.ReactNode;
|
|
124
|
-
accessRule?:
|
|
125
|
+
accessRule?: AccessRule;
|
|
125
126
|
}> = ({ children, accessRule }) => {
|
|
126
127
|
const accessApi = useApi(accessApiRef);
|
|
127
|
-
//
|
|
128
|
-
//
|
|
128
|
+
// Pass the FULL rule so the check qualifies it (`{pluginId}.{id}`) against the
|
|
129
|
+
// user's granted ids and applies manage->read escalation.
|
|
129
130
|
const { allowed, loading } = accessRule
|
|
130
|
-
? accessApi.useAccess(
|
|
131
|
-
typeof accessApi.useAccess
|
|
132
|
-
>[0])
|
|
131
|
+
? accessApi.useAccess(accessRule)
|
|
133
132
|
: { allowed: true, loading: false };
|
|
134
133
|
|
|
135
134
|
if (loading) {
|
|
@@ -290,7 +289,9 @@ function AppWithApis() {
|
|
|
290
289
|
const registryBuilder = new ApiRegistryBuilder()
|
|
291
290
|
.register(loggerApiRef, new ConsoleLoggerApi())
|
|
292
291
|
.register(accessApiRef, {
|
|
293
|
-
|
|
292
|
+
// Default to allow all if no auth plugin present (mirrors useAccess).
|
|
293
|
+
useAccess: () => ({ loading: false, allowed: true }),
|
|
294
|
+
useIsAuthenticated: () => ({ loading: false, isAuthenticated: true }),
|
|
294
295
|
})
|
|
295
296
|
.registerFactory(fetchApiRef, (_registry) => {
|
|
296
297
|
return new CoreFetchApi(baseUrl);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AccessRule } from "@checkstack/common";
|
|
3
|
+
import { selectNavGroups, type NavLike } from "./Sidebar.logic";
|
|
4
|
+
|
|
5
|
+
const GROUP_ORDER = ["Workspace", "Configuration", "Documentation"] as const;
|
|
6
|
+
|
|
7
|
+
// Rules carry an owning pluginId; checks match the qualified `${pluginId}.${id}`.
|
|
8
|
+
const rule = (id: string): AccessRule =>
|
|
9
|
+
({ id, resource: id, level: "read", pluginId: "p" }) as unknown as AccessRule;
|
|
10
|
+
|
|
11
|
+
interface Route {
|
|
12
|
+
path: string;
|
|
13
|
+
nav?: NavLike;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const route = (path: string, nav?: Partial<NavLike>): Route => ({
|
|
17
|
+
path,
|
|
18
|
+
nav: nav ? { group: "Configuration", label: path, order: 0, ...nav } : undefined,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("selectNavGroups", () => {
|
|
22
|
+
test("drops routes without nav", () => {
|
|
23
|
+
const groups = selectNavGroups({
|
|
24
|
+
routes: [route("/a"), route("/b", { group: "Workspace" })],
|
|
25
|
+
accessRules: [],
|
|
26
|
+
isAuthenticated: true,
|
|
27
|
+
groupOrder: GROUP_ORDER,
|
|
28
|
+
});
|
|
29
|
+
expect(groups).toHaveLength(1);
|
|
30
|
+
expect(groups[0]?.items.map((r) => r.path)).toEqual(["/b"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("hides an entry whose accessRule the user lacks, and drops the now-empty group", () => {
|
|
34
|
+
const groups = selectNavGroups({
|
|
35
|
+
routes: [route("/secret", { group: "Configuration", accessRule: rule("x.manage") })],
|
|
36
|
+
accessRules: [], // anonymous / no grants
|
|
37
|
+
isAuthenticated: true,
|
|
38
|
+
groupOrder: GROUP_ORDER,
|
|
39
|
+
});
|
|
40
|
+
// The only Configuration entry is gated away -> the group is not returned.
|
|
41
|
+
expect(groups).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("shows an entry whose accessRule the user satisfies", () => {
|
|
45
|
+
const groups = selectNavGroups({
|
|
46
|
+
routes: [route("/ok", { accessRule: rule("x.read") })],
|
|
47
|
+
accessRules: ["p.x.read"],
|
|
48
|
+
isAuthenticated: true,
|
|
49
|
+
groupOrder: GROUP_ORDER,
|
|
50
|
+
});
|
|
51
|
+
expect(groups[0]?.items.map((r) => r.path)).toEqual(["/ok"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("isVisible predicate gates the entry (Infrastructure: any readable tab)", () => {
|
|
55
|
+
// Simulate Infrastructure: visible only if the user can read a tab. Here the
|
|
56
|
+
// predicate returns false, so the entry (and its lone group) vanish.
|
|
57
|
+
const infra = route("/infrastructure", {
|
|
58
|
+
isVisible: ({ accessRules }) => accessRules.includes("queue.read"),
|
|
59
|
+
});
|
|
60
|
+
expect(
|
|
61
|
+
selectNavGroups({
|
|
62
|
+
routes: [infra],
|
|
63
|
+
accessRules: [],
|
|
64
|
+
isAuthenticated: true,
|
|
65
|
+
groupOrder: GROUP_ORDER,
|
|
66
|
+
}),
|
|
67
|
+
).toEqual([]);
|
|
68
|
+
// With the tab grant, it appears.
|
|
69
|
+
expect(
|
|
70
|
+
selectNavGroups({
|
|
71
|
+
routes: [infra],
|
|
72
|
+
accessRules: ["queue.read"],
|
|
73
|
+
isAuthenticated: true,
|
|
74
|
+
groupOrder: GROUP_ORDER,
|
|
75
|
+
})[0]?.items.map((r) => r.path),
|
|
76
|
+
).toEqual(["/infrastructure"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("isVisible receives isAuthenticated (Notification: authenticated only)", () => {
|
|
80
|
+
const notif = route("/notification", {
|
|
81
|
+
isVisible: ({ isAuthenticated }) => isAuthenticated,
|
|
82
|
+
});
|
|
83
|
+
expect(
|
|
84
|
+
selectNavGroups({
|
|
85
|
+
routes: [notif],
|
|
86
|
+
accessRules: [],
|
|
87
|
+
isAuthenticated: false,
|
|
88
|
+
groupOrder: GROUP_ORDER,
|
|
89
|
+
}),
|
|
90
|
+
).toEqual([]);
|
|
91
|
+
expect(
|
|
92
|
+
selectNavGroups({
|
|
93
|
+
routes: [notif],
|
|
94
|
+
accessRules: [],
|
|
95
|
+
isAuthenticated: true,
|
|
96
|
+
groupOrder: GROUP_ORDER,
|
|
97
|
+
})[0]?.items.map((r) => r.path),
|
|
98
|
+
).toEqual(["/notification"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("both accessRule AND isVisible must pass", () => {
|
|
102
|
+
const r = route("/both", {
|
|
103
|
+
accessRule: rule("x.read"),
|
|
104
|
+
isVisible: ({ isAuthenticated }) => isAuthenticated,
|
|
105
|
+
});
|
|
106
|
+
// Has the rule but not authenticated -> hidden.
|
|
107
|
+
expect(
|
|
108
|
+
selectNavGroups({
|
|
109
|
+
routes: [r],
|
|
110
|
+
accessRules: ["p.x.read"],
|
|
111
|
+
isAuthenticated: false,
|
|
112
|
+
groupOrder: GROUP_ORDER,
|
|
113
|
+
}),
|
|
114
|
+
).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("orders groups by groupOrder then alphabetically, items by order then label", () => {
|
|
118
|
+
const groups = selectNavGroups({
|
|
119
|
+
routes: [
|
|
120
|
+
route("/z", { group: "Documentation", order: 0, label: "Z" }),
|
|
121
|
+
route("/w", { group: "Workspace", order: 0, label: "W" }),
|
|
122
|
+
route("/c2", { group: "Configuration", order: 2, label: "C2" }),
|
|
123
|
+
route("/c1", { group: "Configuration", order: 1, label: "C1" }),
|
|
124
|
+
route("/x", { group: "Xtra", order: 0, label: "X" }), // unknown group -> last
|
|
125
|
+
],
|
|
126
|
+
accessRules: [],
|
|
127
|
+
isAuthenticated: true,
|
|
128
|
+
groupOrder: GROUP_ORDER,
|
|
129
|
+
});
|
|
130
|
+
expect(groups.map((g) => g.group)).toEqual([
|
|
131
|
+
"Workspace",
|
|
132
|
+
"Configuration",
|
|
133
|
+
"Documentation",
|
|
134
|
+
"Xtra",
|
|
135
|
+
]);
|
|
136
|
+
expect(
|
|
137
|
+
groups.find((g) => g.group === "Configuration")?.items.map((r) => r.path),
|
|
138
|
+
).toEqual(["/c1", "/c2"]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isAccessRuleSatisfied, type AccessRule } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The nav-entry fields the sidebar filtering/grouping reads. Kept structural so
|
|
5
|
+
* this logic is pure and unit-testable without the registry or React.
|
|
6
|
+
*/
|
|
7
|
+
export interface NavLike {
|
|
8
|
+
group: string;
|
|
9
|
+
label: string;
|
|
10
|
+
order: number;
|
|
11
|
+
/** Static access rule gating visibility (effective rule from the registry). */
|
|
12
|
+
accessRule?: AccessRule;
|
|
13
|
+
/** Dynamic predicate; both this AND `accessRule` must pass to show the entry. */
|
|
14
|
+
isVisible?: (context: {
|
|
15
|
+
accessRules: string[];
|
|
16
|
+
isAuthenticated: boolean;
|
|
17
|
+
}) => boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One rendered sidebar section: a group label and its visible entries. */
|
|
21
|
+
export interface NavGroupOf<R> {
|
|
22
|
+
group: string;
|
|
23
|
+
items: R[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Filter routes to the ones the current user may see, group them by section, and
|
|
28
|
+
* order both groups and items. A route is visible when:
|
|
29
|
+
* - it declares `nav`, AND
|
|
30
|
+
* - its `accessRule` (if any) is satisfied by the user's access rules, AND
|
|
31
|
+
* - its `isVisible` predicate (if any) returns true.
|
|
32
|
+
*
|
|
33
|
+
* Crucially, groups are built ONLY from visible routes, so a section whose every
|
|
34
|
+
* entry is filtered out is not returned at all - e.g. the Infrastructure entry
|
|
35
|
+
* disappears for a user who can read none of its tabs. Pure: no registry, no
|
|
36
|
+
* hooks, no DOM.
|
|
37
|
+
*/
|
|
38
|
+
export function selectNavGroups<R extends { nav?: NavLike }>({
|
|
39
|
+
routes,
|
|
40
|
+
accessRules,
|
|
41
|
+
isAuthenticated,
|
|
42
|
+
groupOrder,
|
|
43
|
+
}: {
|
|
44
|
+
routes: R[];
|
|
45
|
+
accessRules: string[];
|
|
46
|
+
isAuthenticated: boolean;
|
|
47
|
+
groupOrder: readonly string[];
|
|
48
|
+
}): Array<NavGroupOf<R & { nav: NavLike }>> {
|
|
49
|
+
const visible = routes.filter((route): route is R & { nav: NavLike } => {
|
|
50
|
+
const nav = route.nav;
|
|
51
|
+
if (nav === undefined) return false;
|
|
52
|
+
if (
|
|
53
|
+
nav.accessRule !== undefined &&
|
|
54
|
+
!isAccessRuleSatisfied(accessRules, nav.accessRule)
|
|
55
|
+
) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
nav.isVisible !== undefined &&
|
|
60
|
+
!nav.isVisible({ accessRules, isAuthenticated })
|
|
61
|
+
) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const byGroup = new Map<string, Array<R & { nav: NavLike }>>();
|
|
68
|
+
for (const route of visible) {
|
|
69
|
+
const list = byGroup.get(route.nav.group);
|
|
70
|
+
if (list) list.push(route);
|
|
71
|
+
else byGroup.set(route.nav.group, [route]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const orderOf = (group: string): number => {
|
|
75
|
+
const index = groupOrder.indexOf(group);
|
|
76
|
+
return index === -1 ? groupOrder.length : index;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return [...byGroup.entries()]
|
|
80
|
+
.toSorted(([a], [b]) => orderOf(a) - orderOf(b) || a.localeCompare(b))
|
|
81
|
+
.map(([group, items]) => ({
|
|
82
|
+
group,
|
|
83
|
+
items: items.toSorted(
|
|
84
|
+
(a, b) =>
|
|
85
|
+
a.nav.order - b.nav.order || a.nav.label.localeCompare(b.nav.label),
|
|
86
|
+
),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
@@ -2,11 +2,8 @@ import React, { useMemo, useState } from "react";
|
|
|
2
2
|
import { NavLink, useLocation } from "react-router-dom";
|
|
3
3
|
import { pluginRegistry } from "@checkstack/frontend-api";
|
|
4
4
|
import { useAccessRules } from "@checkstack/auth-frontend";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
APP_DOC_SLUGS,
|
|
8
|
-
docsPath,
|
|
9
|
-
} from "@checkstack/common";
|
|
5
|
+
import { APP_DOC_SLUGS, docsPath } from "@checkstack/common";
|
|
6
|
+
import { selectNavGroups } from "./Sidebar.logic";
|
|
10
7
|
import {
|
|
11
8
|
Sheet,
|
|
12
9
|
SheetContent,
|
|
@@ -43,42 +40,24 @@ interface NavGroup {
|
|
|
43
40
|
|
|
44
41
|
/** Build the access-filtered, grouped, ordered nav model from the route registry. */
|
|
45
42
|
function useNavGroups(): NavGroup[] {
|
|
46
|
-
const { accessRules } = useAccessRules();
|
|
43
|
+
const { accessRules, isAuthenticated } = useAccessRules();
|
|
47
44
|
|
|
48
45
|
// getAllRoutes() is recomputed from the registry; plugin load/unload triggers
|
|
49
46
|
// an App re-render so this stays current. Cheap O(routes) work.
|
|
50
47
|
const routes = pluginRegistry.getAllRoutes();
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else byGroup.set(route.nav.group, [route]);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const orderOf = (group: string): number => {
|
|
68
|
-
const index = GROUP_ORDER.indexOf(group as (typeof GROUP_ORDER)[number]);
|
|
69
|
-
return index === -1 ? GROUP_ORDER.length : index;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
return [...byGroup.entries()]
|
|
73
|
-
.toSorted(([a], [b]) => orderOf(a) - orderOf(b) || a.localeCompare(b))
|
|
74
|
-
.map(([group, items]) => ({
|
|
75
|
-
group,
|
|
76
|
-
items: items.toSorted(
|
|
77
|
-
(a, b) =>
|
|
78
|
-
a.nav.order - b.nav.order || a.nav.label.localeCompare(b.nav.label),
|
|
79
|
-
),
|
|
80
|
-
}));
|
|
81
|
-
}, [routes, accessRules]);
|
|
49
|
+
// Pure filtering/grouping (unit-tested in Sidebar.logic.test.ts): access +
|
|
50
|
+
// isVisible gating, with empty groups dropped.
|
|
51
|
+
return useMemo(
|
|
52
|
+
() =>
|
|
53
|
+
selectNavGroups({
|
|
54
|
+
routes,
|
|
55
|
+
accessRules,
|
|
56
|
+
isAuthenticated,
|
|
57
|
+
groupOrder: GROUP_ORDER,
|
|
58
|
+
}) as NavGroup[],
|
|
59
|
+
[routes, accessRules, isAuthenticated],
|
|
60
|
+
);
|
|
82
61
|
}
|
|
83
62
|
|
|
84
63
|
function loadCollapsedGroups(): Set<string> {
|
package/vite.config.ts
CHANGED
|
@@ -11,6 +11,21 @@ import { monacoViteConfig } from "../ui/src/vite-monaco";
|
|
|
11
11
|
// Monorepo root is 2 levels up from core/frontend
|
|
12
12
|
const monorepoRoot = path.resolve(__dirname, "../..");
|
|
13
13
|
|
|
14
|
+
// The Module Federation share options we actually use. `@module-federation/vite`
|
|
15
|
+
// publishes a `shared` type that is NARROWER than the MF runtime it drives: it
|
|
16
|
+
// omits `eager` and types `requiredVersion` as `string` only. The runtime (and
|
|
17
|
+
// `@module-federation/sdk`'s own `SharedConfig`) supports both `eager: true` and
|
|
18
|
+
// `requiredVersion: false`, which we rely on. We author against this accurate
|
|
19
|
+
// shape and bridge the plugin's outdated param type with a single cast below.
|
|
20
|
+
interface HostShare {
|
|
21
|
+
singleton?: boolean;
|
|
22
|
+
eager?: boolean;
|
|
23
|
+
requiredVersion?: string | false;
|
|
24
|
+
}
|
|
25
|
+
type FederationSharedOption = NonNullable<
|
|
26
|
+
Parameters<typeof federation>[0]["shared"]
|
|
27
|
+
>;
|
|
28
|
+
|
|
14
29
|
// The Monaco / VS Code editor stack (`@checkstack/ui`'s CodeEditor) needs
|
|
15
30
|
// `worker.format: "es"` and a `vscode` resolve alias. Both are produced by this
|
|
16
31
|
// shared helper - also consumed by @checkstack/dev-server - so the app's config
|
|
@@ -34,7 +49,7 @@ export default defineConfig(({ command }) => {
|
|
|
34
49
|
// below), which fails dep optimization with UNLOADABLE_DEPENDENCY. In dev the
|
|
35
50
|
// host just bundles @checkstack/ui's editor normally (worker config + vscode
|
|
36
51
|
// alias are applied unconditionally below), exactly as before this change.
|
|
37
|
-
const editorShare =
|
|
52
|
+
const editorShare: Record<string, HostShare> =
|
|
38
53
|
command === "build"
|
|
39
54
|
? {
|
|
40
55
|
// The Monaco / VS Code editor (the only shared piece of
|
|
@@ -58,6 +73,33 @@ export default defineConfig(({ command }) => {
|
|
|
58
73
|
}
|
|
59
74
|
: {};
|
|
60
75
|
|
|
76
|
+
// Authored against the accurate `HostShare` shape (eager + requiredVersion:
|
|
77
|
+
// false). Extracted to a const so it is passed as a VARIABLE (not a fresh
|
|
78
|
+
// object literal), letting the single cast below bridge the plugin's narrower
|
|
79
|
+
// published `shared` type without tripping excess-property checks.
|
|
80
|
+
const shared: Record<string, HostShare> = {
|
|
81
|
+
react: { singleton: true, eager: true, requiredVersion: "^19.0.0" },
|
|
82
|
+
"react-dom": { singleton: true, eager: true, requiredVersion: "^19.0.0" },
|
|
83
|
+
"react-router-dom": {
|
|
84
|
+
singleton: true,
|
|
85
|
+
eager: true,
|
|
86
|
+
requiredVersion: "^7.0.0",
|
|
87
|
+
},
|
|
88
|
+
"@tanstack/react-query": {
|
|
89
|
+
singleton: true,
|
|
90
|
+
eager: true,
|
|
91
|
+
requiredVersion: "^5.0.0",
|
|
92
|
+
},
|
|
93
|
+
// First-party, version-locked to the host: skip version negotiation
|
|
94
|
+
// (its dep range is `workspace:*`, not a semver range).
|
|
95
|
+
"@checkstack/frontend-api": {
|
|
96
|
+
singleton: true,
|
|
97
|
+
eager: true,
|
|
98
|
+
requiredVersion: false,
|
|
99
|
+
},
|
|
100
|
+
...editorShare,
|
|
101
|
+
};
|
|
102
|
+
|
|
61
103
|
return {
|
|
62
104
|
// Tell Vite to look for .env files in monorepo root
|
|
63
105
|
envDir: monorepoRoot,
|
|
@@ -81,32 +123,8 @@ export default defineConfig(({ command }) => {
|
|
|
81
123
|
federation({
|
|
82
124
|
name: "checkstack_host",
|
|
83
125
|
remotes: {},
|
|
84
|
-
shared
|
|
85
|
-
|
|
86
|
-
"react-dom": {
|
|
87
|
-
singleton: true,
|
|
88
|
-
eager: true,
|
|
89
|
-
requiredVersion: "^19.0.0",
|
|
90
|
-
},
|
|
91
|
-
"react-router-dom": {
|
|
92
|
-
singleton: true,
|
|
93
|
-
eager: true,
|
|
94
|
-
requiredVersion: "^7.0.0",
|
|
95
|
-
},
|
|
96
|
-
"@tanstack/react-query": {
|
|
97
|
-
singleton: true,
|
|
98
|
-
eager: true,
|
|
99
|
-
requiredVersion: "^5.0.0",
|
|
100
|
-
},
|
|
101
|
-
// First-party, version-locked to the host: skip version negotiation
|
|
102
|
-
// (its dep range is `workspace:*`, not a semver range).
|
|
103
|
-
"@checkstack/frontend-api": {
|
|
104
|
-
singleton: true,
|
|
105
|
-
eager: true,
|
|
106
|
-
requiredVersion: false,
|
|
107
|
-
},
|
|
108
|
-
...editorShare,
|
|
109
|
-
},
|
|
126
|
+
// Cast bridges the plugin's outdated `shared` type (see HostShare).
|
|
127
|
+
shared: shared as FederationSharedOption,
|
|
110
128
|
}),
|
|
111
129
|
],
|
|
112
130
|
// The @typefox/monaco-editor-react + @codingame/monaco-vscode-* stack
|
|
@@ -165,8 +183,12 @@ export default defineConfig(({ command }) => {
|
|
|
165
183
|
build: {
|
|
166
184
|
// Use esnext to support top-level await and modern ES features
|
|
167
185
|
target: "esnext",
|
|
168
|
-
//
|
|
169
|
-
|
|
186
|
+
// Source maps over the Monaco / VS Code (`@codingame/*`) stack roughly
|
|
187
|
+
// DOUBLE the build's time and peak memory and ship ~MBs of `.js.map` into
|
|
188
|
+
// the image - enough to OOM-thrash a CI runner (the Docker build hung
|
|
189
|
+
// here). Off by default; opt in locally with `VITE_SOURCEMAP=true` when
|
|
190
|
+
// you need to debug the production bundle.
|
|
191
|
+
sourcemap: process.env.VITE_SOURCEMAP === "true",
|
|
170
192
|
// Monaco stays off the initial load via the `React.lazy(CodeEditor)`
|
|
171
193
|
// boundary (see CodeEditor.tsx) — verified preserved under Module
|
|
172
194
|
// Federation's automatic code-splitting. We do NOT hand-group chunks:
|