@terpjs/react-core 0.9.0 → 0.11.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 +57 -21
- package/package.json +6 -5
- package/src/AppShell.test.tsx +323 -4
- package/src/AppShell.tsx +401 -66
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +43 -18
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +12 -0
- package/src/markers.test.ts +27 -5
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/router.test.tsx +575 -2
- package/src/router.tsx +212 -19
- package/src/styles.test.ts +535 -58
- package/src/styles.ts +1130 -111
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +239 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +69 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +139 -0
- package/src/ui/Combobox.tsx +255 -43
- package/src/ui/DatePicker.tsx +44 -12
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +30 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +36 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/router.tsx
CHANGED
|
@@ -7,13 +7,14 @@ import {
|
|
|
7
7
|
useNavigate,
|
|
8
8
|
useParams,
|
|
9
9
|
useRouter,
|
|
10
|
+
useRouterState,
|
|
10
11
|
useSearch,
|
|
11
12
|
type AnyRoute,
|
|
12
13
|
type RouterHistory,
|
|
13
14
|
} from "@tanstack/react-router";
|
|
14
15
|
import type { ComponentType, ReactNode } from "react";
|
|
15
|
-
import { useEffect, useRef, useState } from "react";
|
|
16
|
-
import type { ModuleManifest } from "@terpjs/contract";
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
+
import type { ModuleManifest, NavGroup } from "@terpjs/contract";
|
|
17
18
|
|
|
18
19
|
import { AppShell } from "./AppShell";
|
|
19
20
|
import { ProfileView } from "./ProfileView";
|
|
@@ -25,8 +26,14 @@ import type {
|
|
|
25
26
|
TerpRouteSearch,
|
|
26
27
|
} from "./routeTypes";
|
|
27
28
|
import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
|
|
28
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
BRAND_FIELDS,
|
|
31
|
+
resolveLayoutDeclaration,
|
|
32
|
+
type LayoutDeclaration,
|
|
33
|
+
} from "./layoutDeclaration";
|
|
34
|
+
import { isDeclarationVisible, visibleNav } from "./nav";
|
|
29
35
|
import { NavLinkContext } from "./navLink";
|
|
36
|
+
import type { NavLinkRenderer } from "./navLink";
|
|
30
37
|
import { PageMarkerContext } from "./pageMarker";
|
|
31
38
|
import {
|
|
32
39
|
RouteSearchContext,
|
|
@@ -234,8 +241,51 @@ export interface BuildAppRouterOptions {
|
|
|
234
241
|
title: string;
|
|
235
242
|
/** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
|
|
236
243
|
logo?: ReactNode;
|
|
244
|
+
/**
|
|
245
|
+
* The dark-theme brand mark ({@link AppShell.logoDark}); the stylesheet picks per appearance.
|
|
246
|
+
*
|
|
247
|
+
* Forwarded because it was not, which made it the third slot to exist on the shell and be
|
|
248
|
+
* unreachable from the entry points every app uses — after `headerActions`, which this ADR's
|
|
249
|
+
* own Context complains about. This one was worse than unreachable: the project template
|
|
250
|
+
* instructs every new app to pass `logoDark` to `renderTerpApp`, so the documented example
|
|
251
|
+
* did not typecheck.
|
|
252
|
+
*/
|
|
253
|
+
logoDark?: ReactNode;
|
|
254
|
+
/** Extra header content, rendered before the theme / language controls. */
|
|
255
|
+
headerActions?: ReactNode;
|
|
237
256
|
/** Footer line under the content; default: a muted line with the app title. */
|
|
238
257
|
footer?: ReactNode;
|
|
258
|
+
/**
|
|
259
|
+
* Cap routed content at the published measure, with each page's header on the full track
|
|
260
|
+
* ({@link AppShell.contentWidth}); default `"full"`, which changes nothing.
|
|
261
|
+
*/
|
|
262
|
+
contentWidth?: "full" | "measured";
|
|
263
|
+
/**
|
|
264
|
+
* App-wide density ({@link AppShell.density}). **No default** — omitting it stamps nothing, so
|
|
265
|
+
* an app's own `data-density` on `<html>` still reaches the tree.
|
|
266
|
+
*/
|
|
267
|
+
density?: "comfortable" | "compact";
|
|
268
|
+
/**
|
|
269
|
+
* Where the primary navigation lives on desktop ({@link AppShell.navPlacement}): the
|
|
270
|
+
* full-height `"sidebar"` (default, and what every shell renders today) or `"header"`, a
|
|
271
|
+
* horizontal row in the header with no sidebar at all. Below the mobile breakpoint both are
|
|
272
|
+
* the drawer.
|
|
273
|
+
*/
|
|
274
|
+
navPlacement?: "sidebar" | "header";
|
|
275
|
+
/**
|
|
276
|
+
* The app's navigation groups ({@link AppShell.navGroups}), which manifest items reference by
|
|
277
|
+
* `NavItem.group`. Omit for the flat, unlabelled sidebar every app renders today.
|
|
278
|
+
*
|
|
279
|
+
* Prefer {@link BuildAppRouterOptions.layout}: `shell.navGroups` in the app's own
|
|
280
|
+
* `frontend/layout-contract.json` says the same thing in the one document a tool can read and
|
|
281
|
+
* rewrite. Declaring the groups in both places is refused rather than silently resolved.
|
|
282
|
+
*
|
|
283
|
+
* A duplicate id is refused here rather than tolerated: it is an authoring error with no
|
|
284
|
+
* legitimate transient form, and composition time is where it can be reported once instead of
|
|
285
|
+
* on every render. An item naming an *undeclared* group is the opposite case and is not an
|
|
286
|
+
* error at all — modules ship independently of the app, so `groupNav` lets it fall open.
|
|
287
|
+
*/
|
|
288
|
+
navGroups?: readonly NavGroup[];
|
|
239
289
|
/** Role-name -> minimum rank; an unknown role is denied (fail closed). */
|
|
240
290
|
roleRanks?: Record<string, number>;
|
|
241
291
|
/** Rendered when the current user may not access a route (default: a simple message). */
|
|
@@ -244,10 +294,32 @@ export interface BuildAppRouterOptions {
|
|
|
244
294
|
* Opt into a slot-typed layout contract (ADR 0079), e.g. `"standard"`: every routed
|
|
245
295
|
* archetype's body slot then accepts only the components the contract allows there,
|
|
246
296
|
* verified at runtime (fail closed) with the same directive message the
|
|
247
|
-
* `terp/layout-contract` lint rule phrases.
|
|
248
|
-
*
|
|
297
|
+
* `terp/layout-contract` lint rule phrases.
|
|
298
|
+
*
|
|
299
|
+
* Prefer {@link BuildAppRouterOptions.layout}. This option used to carry the instruction
|
|
300
|
+
* "keep it in sync with the app's checked-in `layout-contract.json`", which is a defect
|
|
301
|
+
* written as advice and, since this function began reading that file, worse than stale:
|
|
302
|
+
* doing what the sentence said is now refused as one fact declared twice, even when the two
|
|
303
|
+
* agree. Omit both for today's archetype-only behavior.
|
|
249
304
|
*/
|
|
250
305
|
layoutContract?: string;
|
|
306
|
+
/**
|
|
307
|
+
* The app's checked-in layout declaration (`frontend/layout-contract.json`, imported).
|
|
308
|
+
*
|
|
309
|
+
* The file the `terp/layout-contract` lint rule already reads, now read by the runtime half
|
|
310
|
+
* too, so `contract` is declared once instead of once per half. It also carries the palette
|
|
311
|
+
* the app opens on and the shell's own shape — `density`, `navPlacement`, `contentWidth` and
|
|
312
|
+
* the `navGroups` a module's `NavItem.group` names by id — all of which shipped as options
|
|
313
|
+
* here and were therefore out of reach of anything that edits files rather than code.
|
|
314
|
+
*
|
|
315
|
+
* The authoritative list is `TOP_LEVEL_KEYS` and `SHELL_KEYS` in
|
|
316
|
+
* {@link ./layoutDeclaration}, and the published `layout.manifest.json` beside them. This
|
|
317
|
+
* sentence is a restatement and has already drifted once.
|
|
318
|
+
*
|
|
319
|
+
* Declaring a key here AND passing the matching option is refused — see
|
|
320
|
+
* {@link resolveLayoutDeclaration}.
|
|
321
|
+
*/
|
|
322
|
+
layout?: LayoutDeclaration;
|
|
251
323
|
/** Router history (e.g. `createMemoryHistory`); omit for the browser history. */
|
|
252
324
|
history?: RouterHistory;
|
|
253
325
|
}
|
|
@@ -281,13 +353,33 @@ function DefaultUnauthorized() {
|
|
|
281
353
|
* built-in {@link ProfileView} mounts at {@link PROFILE_PATH} unless a manifest claims that
|
|
282
354
|
* path. Wrap the returned router in `<TerpProvider><RouterProvider router={router}/></TerpProvider>`.
|
|
283
355
|
*/
|
|
356
|
+
/**
|
|
357
|
+
* A declared path as the element the shell's slot takes.
|
|
358
|
+
*
|
|
359
|
+
* `alt=""` because the mark is decorative here: the shell renders the app's title beside it,
|
|
360
|
+
* so a name on the image would have a screen reader announce the app twice. That is also what
|
|
361
|
+
* the template's own commented example does, and what an app hand-writing the option should do.
|
|
362
|
+
*/
|
|
363
|
+
function brandMark(path: string | undefined) {
|
|
364
|
+
return path === undefined ? undefined : <img src={path} alt="" />;
|
|
365
|
+
}
|
|
366
|
+
|
|
284
367
|
export function buildAppRouter(
|
|
285
368
|
manifests: readonly ModuleManifest[],
|
|
286
369
|
options: BuildAppRouterOptions,
|
|
287
370
|
) {
|
|
288
371
|
const roleRanks = options.roleRanks ?? DEFAULT_ROLE_RANKS;
|
|
289
372
|
const Unauthorized = options.unauthorized ?? DefaultUnauthorized;
|
|
290
|
-
|
|
373
|
+
// The declaration and the options collapse into one set before anything reads them, so
|
|
374
|
+
// every consumer below sees a single answer per key and cannot pick a different precedence.
|
|
375
|
+
const layout = resolveLayoutDeclaration(options.layout, {
|
|
376
|
+
contract: options.layoutContract,
|
|
377
|
+
density: options.density,
|
|
378
|
+
navPlacement: options.navPlacement,
|
|
379
|
+
contentWidth: options.contentWidth,
|
|
380
|
+
navGroups: options.navGroups,
|
|
381
|
+
});
|
|
382
|
+
const layoutContract = layout.contract ?? null;
|
|
291
383
|
if (layoutContract !== null && LAYOUT_CONTRACTS[layoutContract] === undefined) {
|
|
292
384
|
throw new Error(
|
|
293
385
|
`Unknown layout contract "${layoutContract}"; known contracts: ` +
|
|
@@ -295,6 +387,40 @@ export function buildAppRouter(
|
|
|
295
387
|
".",
|
|
296
388
|
);
|
|
297
389
|
}
|
|
390
|
+
// An authoring error with no legitimate transient form, so it is refused here rather than
|
|
391
|
+
// absorbed. `groupNav` itself stays total — first declaration wins — because it runs on every
|
|
392
|
+
// render and a render must not be able to throw; this runs once, when the app is composed.
|
|
393
|
+
// Deliberately NOT symmetrical with an item naming an undeclared group, which is not an error
|
|
394
|
+
// at all: a module ships on its own schedule, so that is the normal state of an app mid-adoption
|
|
395
|
+
// and it falls open.
|
|
396
|
+
// Over the RESOLVED list, so one refusal covers a group declared in the file and a group
|
|
397
|
+
// passed as an option alike — which is also why `resolveLayoutDeclaration` does not restate it.
|
|
398
|
+
// The brand's two slots, each declarable as a checked-in PATH or passed as a rendered
|
|
399
|
+
// element. Refused together for the reason every other doubly-declared key is, and refused
|
|
400
|
+
// HERE rather than in the resolver because a path and an element are not comparable values:
|
|
401
|
+
// the resolver's message names both, and there is nothing to name on the code side but the
|
|
402
|
+
// fact that something was passed.
|
|
403
|
+
const brandConflicts = BRAND_FIELDS.filter(
|
|
404
|
+
(slot) => layout.brand?.[slot] !== undefined && options[slot] !== undefined,
|
|
405
|
+
);
|
|
406
|
+
if (brandConflicts.length > 0) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`frontend/layout-contract.json declares shell.brand.${brandConflicts.join(" and shell.brand.")}` +
|
|
409
|
+
` and the bootstrap options pass ${brandConflicts.join(" and ")}. Declare each mark in ` +
|
|
410
|
+
"one place: the file is what a tool can read and rewrite, so prefer it and drop the option.",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const duplicateGroups = (layout.navGroups ?? [])
|
|
415
|
+
.map((group) => group.id)
|
|
416
|
+
.filter((id, index, ids) => ids.indexOf(id) !== index);
|
|
417
|
+
if (duplicateGroups.length > 0) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
"Terp navGroups declare duplicate id(s): " +
|
|
420
|
+
[...new Set(duplicateGroups)].join(", ") +
|
|
421
|
+
". Each group id is referenced by NavItem.group and must be declared once.",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
298
424
|
const missingViews = manifests.flatMap((manifest) =>
|
|
299
425
|
manifest.routes
|
|
300
426
|
.filter((route) => options.views[route.view] === undefined)
|
|
@@ -317,27 +443,85 @@ export function buildAppRouter(
|
|
|
317
443
|
|
|
318
444
|
function Shell() {
|
|
319
445
|
const router = useRouter();
|
|
320
|
-
const
|
|
321
|
-
const
|
|
446
|
+
const user = useAuth().currentUser();
|
|
447
|
+
const rank = user?.role_rank ?? null;
|
|
448
|
+
const nav = visibleNav(manifests, {
|
|
449
|
+
canSeeRole: (role) => allows(roleRanks, rank, role),
|
|
450
|
+
permissions: user?.permissions ?? [],
|
|
451
|
+
});
|
|
452
|
+
// The shell decides which nav item is current and needs the path to do it. Selected, so the
|
|
453
|
+
// subscription re-renders only when the pathname itself changes — the same mechanism
|
|
454
|
+
// ModuleNav already uses.
|
|
455
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
456
|
+
// Memoised, and this is a fix for a regression the line above introduces rather than
|
|
457
|
+
// tidying. `Shell` used to re-render only when `useAuth()` changed; it now re-renders on
|
|
458
|
+
// every navigation. `Outlet` is memoised with no props, so a re-render alone bails out at
|
|
459
|
+
// that boundary and the routed subtree is untouched — but a CONTEXT VALUE punches straight
|
|
460
|
+
// through a memo bailout, and this value is used as a component (Breadcrumbs and HubCard
|
|
461
|
+
// render it through useNavLink), so an unstable identity is worse than a re-render: it
|
|
462
|
+
// remounts every in-app link in the tree on each navigation.
|
|
463
|
+
const renderNavLink = useCallback<NavLinkRenderer>(
|
|
464
|
+
// `activeOptions={{ exact: true }}` for the same reason the shell's `renderLink`
|
|
465
|
+
// below carries it, and it matters MORE here: this renderer is what `Breadcrumbs`
|
|
466
|
+
// and `HubCard` use, so its job is rendering ANCESTORS. Under the router's default
|
|
467
|
+
// prefix matching every ancestor link matched the URL and was marked active, which
|
|
468
|
+
// on any detail route emitted a second `aria-current="page"` — one on the crumb for
|
|
469
|
+
// `/definitions` and one on the current crumb — plus a stray `.active` class and
|
|
470
|
+
// `data-status="active"` that made an ancestor look like the current page. The fix
|
|
471
|
+
// was applied to the nav and missed on the component whose whole purpose is the
|
|
472
|
+
// trail; exact matching means a crumb is only ever current when it IS the URL, and
|
|
473
|
+
// the current crumb is a span rather than a link.
|
|
474
|
+
({ to, children, attributes }) => (
|
|
475
|
+
<Link to={to} activeOptions={{ exact: true }} {...attributes}>
|
|
476
|
+
{children}
|
|
477
|
+
</Link>
|
|
478
|
+
),
|
|
479
|
+
[],
|
|
480
|
+
);
|
|
322
481
|
return (
|
|
323
482
|
// Publish the router's Link so every layout component that renders an in-app link
|
|
324
483
|
// (Breadcrumbs, HubCard) navigates client-side by default. Forgetting `renderLink`
|
|
325
484
|
// used to degrade the app silently: a raw anchor, a full page reload, no error.
|
|
326
|
-
<NavLinkContext.Provider value={
|
|
485
|
+
<NavLinkContext.Provider value={renderNavLink}>
|
|
327
486
|
<AppShell
|
|
328
487
|
title={options.title}
|
|
329
|
-
logo={options.logo}
|
|
488
|
+
logo={brandMark(layout.brand?.logo) ?? options.logo}
|
|
489
|
+
logoDark={brandMark(layout.brand?.logoDark) ?? options.logoDark}
|
|
490
|
+
headerActions={options.headerActions}
|
|
330
491
|
footer={options.footer}
|
|
492
|
+
contentWidth={layout.contentWidth}
|
|
493
|
+
density={layout.density}
|
|
494
|
+
navPlacement={layout.navPlacement}
|
|
495
|
+
activePath={pathname}
|
|
331
496
|
nav={nav}
|
|
497
|
+
navGroups={layout.navGroups}
|
|
332
498
|
renderBrandLink={({ to, children }) => (
|
|
333
499
|
<Link to={to} data-terp="appshell-brand">
|
|
334
500
|
{children}
|
|
335
501
|
</Link>
|
|
336
502
|
)}
|
|
337
|
-
// No style objects and no activeProps: the shell's stylesheet owns the link
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
503
|
+
// No style objects and no activeProps: the shell's stylesheet owns the link geometry
|
|
504
|
+
// and keys the active route on aria-current="page".
|
|
505
|
+
//
|
|
506
|
+
// The shell supplies that attribute now, and `exact: true` is what makes the router
|
|
507
|
+
// agree instead of arguing. Two facts combine. `aria-current` is not among the props
|
|
508
|
+
// useLinkProps destructures, so a value passed here survives into the rendered anchor;
|
|
509
|
+
// and the router's own active props are spread LAST, but only when it considers the link
|
|
510
|
+
// active. With exact matching, "the router considers it active" implies the link's path
|
|
511
|
+
// equals the URL — which is the longest possible match, so it is always the same item
|
|
512
|
+
// the shell picked. The router can therefore only ever agree, never add a second
|
|
513
|
+
// current item.
|
|
514
|
+
//
|
|
515
|
+
// Prefix matching is what broke that: it marked every ancestor active, so `/settings`
|
|
516
|
+
// and `/settings/users` were both current at `/settings/users`. The old
|
|
517
|
+
// `exact: item.to === "/"` was a workaround for the same thing at the root, and it is
|
|
518
|
+
// gone because the shell's predicate matches on segments and `/` claims nothing else.
|
|
519
|
+
renderLink={(item, children, { active }) => (
|
|
520
|
+
<Link
|
|
521
|
+
to={item.to}
|
|
522
|
+
activeOptions={{ exact: true }}
|
|
523
|
+
aria-current={active ? "page" : undefined}
|
|
524
|
+
>
|
|
341
525
|
{children}
|
|
342
526
|
</Link>
|
|
343
527
|
)}
|
|
@@ -359,12 +543,21 @@ export function buildAppRouter(
|
|
|
359
543
|
function guardedRoute(
|
|
360
544
|
path: string,
|
|
361
545
|
View: ComponentType,
|
|
362
|
-
|
|
546
|
+
declaration: { role?: string; permission?: string },
|
|
363
547
|
viewName: string,
|
|
364
548
|
): AnyRoute {
|
|
365
549
|
function RouteComponent() {
|
|
366
|
-
const
|
|
367
|
-
const
|
|
550
|
+
const user = useAuth().currentUser();
|
|
551
|
+
const rank = user?.role_rank ?? null;
|
|
552
|
+
// The same resolution the sidebar uses, and using it here is what stops `permission` from
|
|
553
|
+
// becoming a cosmetic gate: hiding a link while leaving its route reachable by URL is not
|
|
554
|
+
// a weaker version of authorization, it is the appearance of it. `role` has never had that
|
|
555
|
+
// asymmetry — it is declared on both NavItem and ModuleRoute — so `permission` does not
|
|
556
|
+
// get to introduce one.
|
|
557
|
+
const allowed = isDeclarationVisible(declaration, {
|
|
558
|
+
canSeeRole: (role) => allows(roleRanks, rank, role),
|
|
559
|
+
permissions: user?.permissions ?? [],
|
|
560
|
+
});
|
|
368
561
|
// The runtime half of the "every routed view is a page archetype" control: Page
|
|
369
562
|
// (composed by OverviewPage / DetailPage / HubPage) marks the render; a routed view
|
|
370
563
|
// that mounted without any archetype in its tree is refused, fail closed. The check
|
|
@@ -416,14 +609,14 @@ export function buildAppRouter(
|
|
|
416
609
|
|
|
417
610
|
const childRoutes: AnyRoute[] = manifests.flatMap((manifest) =>
|
|
418
611
|
manifest.routes.map((route) =>
|
|
419
|
-
guardedRoute(route.path, options.views[route.view]!, route
|
|
612
|
+
guardedRoute(route.path, options.views[route.view]!, route, route.view),
|
|
420
613
|
),
|
|
421
614
|
);
|
|
422
615
|
const profileClaimed = manifests.some((manifest) =>
|
|
423
616
|
manifest.routes.some((route) => route.path === PROFILE_PATH),
|
|
424
617
|
);
|
|
425
618
|
if (!profileClaimed) {
|
|
426
|
-
childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView,
|
|
619
|
+
childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, {}, "profile"));
|
|
427
620
|
}
|
|
428
621
|
|
|
429
622
|
const routeTree = rootRoute.addChildren(childRoutes);
|