@genesislcap/foundation-react-utils 14.496.2-alpha-da9ccbd.0 → 14.497.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/dist/custom-elements.json +245 -0
- package/dist/dts/router/app-routes.d.ts +69 -0
- package/dist/dts/router/app-routes.d.ts.map +1 -0
- package/dist/dts/router/index.d.ts +33 -0
- package/dist/dts/router/index.d.ts.map +1 -0
- package/dist/dts/router/pbc-routes.d.ts +36 -0
- package/dist/dts/router/pbc-routes.d.ts.map +1 -0
- package/dist/dts/router/post-login-redirect.d.ts +25 -0
- package/dist/dts/router/post-login-redirect.d.ts.map +1 -0
- package/dist/dts/router/protected-route.d.ts +80 -0
- package/dist/dts/router/protected-route.d.ts.map +1 -0
- package/dist/dts/router/router.test.d.ts +2 -0
- package/dist/dts/router/router.test.d.ts.map +1 -0
- package/dist/dts/router/single-component.d.ts +52 -0
- package/dist/dts/router/single-component.d.ts.map +1 -0
- package/dist/esm/router/app-routes.js +32 -0
- package/dist/esm/router/index.js +27 -0
- package/dist/esm/router/pbc-routes.js +19 -0
- package/dist/esm/router/post-login-redirect.js +14 -0
- package/dist/esm/router/protected-route.js +46 -0
- package/dist/esm/router/router.test.js +82 -0
- package/dist/esm/router/single-component.js +51 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +39 -14
- package/src/router/app-routes.tsx +141 -0
- package/src/router/index.ts +41 -0
- package/src/router/pbc-routes.ts +56 -0
- package/src/router/post-login-redirect.ts +29 -0
- package/src/router/protected-route.tsx +121 -0
- package/src/router/router.test.ts +107 -0
- package/src/router/single-component.tsx +94 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for {@link ProtectedRoute}.
|
|
6
|
+
*/
|
|
7
|
+
export interface ProtectedRouteProps {
|
|
8
|
+
/** Whether the current user is authenticated. */
|
|
9
|
+
isAuthenticated: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Whether the current user may view this route. When `false` (and
|
|
12
|
+
* authenticated), redirects to `notPermittedPath` instead of rendering.
|
|
13
|
+
* Defaults to `true`.
|
|
14
|
+
*/
|
|
15
|
+
hasPermission?: boolean;
|
|
16
|
+
/** Path to redirect to when unauthenticated. Defaults to `/login`. */
|
|
17
|
+
loginPath?: string;
|
|
18
|
+
/** Path to redirect to when authenticated but not permitted. Defaults to `/not-permitted`. */
|
|
19
|
+
notPermittedPath?: string;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Route guard. In order:
|
|
25
|
+
* - not authenticated → redirect to `loginPath`, stashing the full current
|
|
26
|
+
* location in `state.from` (incl. `search` + `hash`) so the login flow can
|
|
27
|
+
* restore the original deep-link via {@link buildPostLoginRedirect};
|
|
28
|
+
* - authenticated but `hasPermission === false` → redirect to `notPermittedPath`;
|
|
29
|
+
* - otherwise → render `children`.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately decoupled from any auth package — pass the booleans in (or use
|
|
32
|
+
* {@link createProtectedRoute} to bind the checks once).
|
|
33
|
+
*/
|
|
34
|
+
export function ProtectedRoute({
|
|
35
|
+
isAuthenticated,
|
|
36
|
+
hasPermission = true,
|
|
37
|
+
loginPath = '/login',
|
|
38
|
+
notPermittedPath = '/not-permitted',
|
|
39
|
+
children,
|
|
40
|
+
}: ProtectedRouteProps) {
|
|
41
|
+
const location = useLocation();
|
|
42
|
+
|
|
43
|
+
if (!isAuthenticated) {
|
|
44
|
+
return <Navigate to={loginPath} state={{ from: location }} replace />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!hasPermission) {
|
|
48
|
+
return <Navigate to={notPermittedPath} replace />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return <>{children}</>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Props of the component returned by {@link createProtectedRoute}. `permissionCode`
|
|
56
|
+
* is forwarded to the bound `hasPermission` check (e.g. by {@link renderAppRoutes}).
|
|
57
|
+
*/
|
|
58
|
+
export interface BoundProtectedRouteProps {
|
|
59
|
+
children: ReactNode;
|
|
60
|
+
/** Route permission code handed to the bound `hasPermission` check. */
|
|
61
|
+
permissionCode?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Options for {@link createProtectedRoute}.
|
|
66
|
+
*/
|
|
67
|
+
export interface CreateProtectedRouteOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Called at render time to determine auth state. Kept as a function (not a
|
|
70
|
+
* boolean) so it is re-evaluated on every render and the util stays free of
|
|
71
|
+
* any specific auth dependency.
|
|
72
|
+
*/
|
|
73
|
+
getIsAuthenticated: () => boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Optional per-route permission check, called at render time with the route's
|
|
76
|
+
* `permissionCode`. Absent → every authenticated user is permitted.
|
|
77
|
+
*/
|
|
78
|
+
hasPermission?: (permissionCode: string | undefined) => boolean;
|
|
79
|
+
/** Path to redirect to when unauthenticated. Defaults to `/login`. */
|
|
80
|
+
loginPath?: string;
|
|
81
|
+
/** Path to redirect to when authenticated but not permitted. Defaults to `/not-permitted`. */
|
|
82
|
+
notPermittedPath?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Bind auth (and optionally permission) checks once and get an ergonomic
|
|
87
|
+
* `<ProtectedRoute>` that only needs `children` (plus an optional
|
|
88
|
+
* `permissionCode`) — ideal when a route table wraps many elements.
|
|
89
|
+
*
|
|
90
|
+
* **Call this at module scope, never inside a component's render.** It returns a
|
|
91
|
+
* new component type each call; invoking it during render recreates that type
|
|
92
|
+
* every render, forcing React to unmount and remount the entire protected
|
|
93
|
+
* subtree (flicker + lost state).
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```tsx
|
|
97
|
+
* const ProtectedRoute = createProtectedRoute({
|
|
98
|
+
* getIsAuthenticated: () => getUser().isAuthenticated,
|
|
99
|
+
* hasPermission: (code) => !code || canView(getUser(), code),
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export function createProtectedRoute({
|
|
104
|
+
getIsAuthenticated,
|
|
105
|
+
hasPermission,
|
|
106
|
+
loginPath,
|
|
107
|
+
notPermittedPath,
|
|
108
|
+
}: CreateProtectedRouteOptions) {
|
|
109
|
+
return function BoundProtectedRoute({ children, permissionCode }: BoundProtectedRouteProps) {
|
|
110
|
+
return (
|
|
111
|
+
<ProtectedRoute
|
|
112
|
+
isAuthenticated={getIsAuthenticated()}
|
|
113
|
+
hasPermission={hasPermission ? hasPermission(permissionCode) : true}
|
|
114
|
+
loginPath={loginPath}
|
|
115
|
+
notPermittedPath={notPermittedPath}
|
|
116
|
+
>
|
|
117
|
+
{children}
|
|
118
|
+
</ProtectedRoute>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
+
import { mergePbcRoutes } from './pbc-routes';
|
|
3
|
+
import { buildPostLoginRedirect } from './post-login-redirect';
|
|
4
|
+
import { createComponentRegistry } from './single-component';
|
|
5
|
+
|
|
6
|
+
const RedirectSuite = createLogicSuite('buildPostLoginRedirect');
|
|
7
|
+
|
|
8
|
+
RedirectSuite('falls back to `/` when there is no stashed origin', () => {
|
|
9
|
+
assert.is(buildPostLoginRedirect({}), '/');
|
|
10
|
+
assert.is(buildPostLoginRedirect({ state: null }), '/');
|
|
11
|
+
assert.is(buildPostLoginRedirect({ state: {} }), '/');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
RedirectSuite('honours a custom default path', () => {
|
|
15
|
+
assert.is(buildPostLoginRedirect({}, '/home'), '/home');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
RedirectSuite('returns the pathname when there is no search/hash', () => {
|
|
19
|
+
assert.is(buildPostLoginRedirect({ state: { from: { pathname: '/grids' } } }), '/grids');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
RedirectSuite('preserves search and hash so deep-link params survive', () => {
|
|
23
|
+
assert.is(
|
|
24
|
+
buildPostLoginRedirect({
|
|
25
|
+
state: { from: { pathname: '/', search: '?component=Home', hash: '#top' } },
|
|
26
|
+
}),
|
|
27
|
+
'/?component=Home#top',
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
RedirectSuite.run();
|
|
32
|
+
|
|
33
|
+
const RegistrySuite = createLogicSuite('createComponentRegistry');
|
|
34
|
+
|
|
35
|
+
const Home = () => null;
|
|
36
|
+
const GridsShowcase = () => null;
|
|
37
|
+
const registry = createComponentRegistry({ Home, GridsShowcase });
|
|
38
|
+
|
|
39
|
+
RegistrySuite('resolves an exact name', () => {
|
|
40
|
+
assert.is(registry.resolve('Home'), Home);
|
|
41
|
+
assert.is(registry.resolve('GridsShowcase'), GridsShowcase);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
RegistrySuite('resolves case- and separator-insensitively', () => {
|
|
45
|
+
assert.is(registry.resolve('home'), Home);
|
|
46
|
+
assert.is(registry.resolve('gridsshowcase'), GridsShowcase);
|
|
47
|
+
assert.is(registry.resolve('grids-showcase'), GridsShowcase);
|
|
48
|
+
assert.is(registry.resolve('grids_showcase'), GridsShowcase);
|
|
49
|
+
assert.is(registry.resolve('Grids Showcase'), GridsShowcase);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
RegistrySuite('returns undefined for unknown or empty names', () => {
|
|
53
|
+
assert.is(registry.resolve('nope'), undefined);
|
|
54
|
+
assert.is(registry.resolve(null), undefined);
|
|
55
|
+
assert.is(registry.resolve(undefined), undefined);
|
|
56
|
+
assert.is(registry.resolve(''), undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
RegistrySuite('exposes the registered names via available()', () => {
|
|
60
|
+
assert.equal(registry.available(), ['Home', 'GridsShowcase']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
RegistrySuite.run();
|
|
64
|
+
|
|
65
|
+
const PbcSuite = createLogicSuite('mergePbcRoutes');
|
|
66
|
+
|
|
67
|
+
PbcSuite('appends PBC routes after the static ones', () => {
|
|
68
|
+
const staticRoutes = [{ path: '/home', element: null }];
|
|
69
|
+
const merged = mergePbcRoutes(staticRoutes, [{ path: 'reporting' }], {
|
|
70
|
+
renderPbc: () => 'pbc',
|
|
71
|
+
});
|
|
72
|
+
assert.is(merged.length, 2);
|
|
73
|
+
assert.is(merged[0].path, '/home');
|
|
74
|
+
assert.is(merged[1].path, '/reporting');
|
|
75
|
+
assert.is(merged[1].element, 'pbc');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
PbcSuite('carries pbc element/tag + navItems + settings through `data`', () => {
|
|
79
|
+
const navItems = [{ navId: 'header', title: 'Reporting' }];
|
|
80
|
+
const el = () => 'loader';
|
|
81
|
+
const merged = mergePbcRoutes(
|
|
82
|
+
[],
|
|
83
|
+
[
|
|
84
|
+
{
|
|
85
|
+
path: 'reporting',
|
|
86
|
+
element: el,
|
|
87
|
+
elementTag: 'reporting-app',
|
|
88
|
+
navItems,
|
|
89
|
+
settings: { permissionCode: 'ReportView' },
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
{ renderPbc: () => null },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
assert.is(merged[0].permissionCode, 'ReportView');
|
|
96
|
+
assert.is(merged[0].data?.pbcElement, el);
|
|
97
|
+
assert.is(merged[0].data?.pbcElementTag, 'reporting-app');
|
|
98
|
+
assert.equal(merged[0].data?.navItems, navItems);
|
|
99
|
+
assert.is(merged[0].data?.permissionCode, 'ReportView');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
PbcSuite('returns only the static routes when there are no PBC routes', () => {
|
|
103
|
+
const staticRoutes = [{ path: '/home', element: null }];
|
|
104
|
+
assert.equal(mergePbcRoutes(staticRoutes, [], { renderPbc: () => null }), staticRoutes);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
PbcSuite.run();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a query-string parameter from the current URL.
|
|
5
|
+
*
|
|
6
|
+
* Call this at **module-evaluation time** in your app (i.e. as a top-level
|
|
7
|
+
* `const`), before the app shell's async bootstrap rewrites the URL and drops
|
|
8
|
+
* the query string, and store the result. That way a deep-link param such as
|
|
9
|
+
* `?component=<name>` survives even though it is gone by the time the router
|
|
10
|
+
* mounts.
|
|
11
|
+
*/
|
|
12
|
+
export function readInitialParam(key: string): string | null {
|
|
13
|
+
const search = typeof window !== 'undefined' ? window.location.search : '';
|
|
14
|
+
return new URLSearchParams(search).get(key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Normalize a name so lookups are case-insensitive and separator-agnostic. */
|
|
18
|
+
const normalize = (name: string): string => name.toLowerCase().replace(/[-_\s]/g, '');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A name → component registry with tolerant lookup. Create one with
|
|
22
|
+
* {@link createComponentRegistry}.
|
|
23
|
+
*/
|
|
24
|
+
export interface ComponentRegistry {
|
|
25
|
+
/** The underlying name → component map. */
|
|
26
|
+
entries: Record<string, ComponentType<any>>;
|
|
27
|
+
/** All registered names, for help/error output. */
|
|
28
|
+
available(): string[];
|
|
29
|
+
/** Resolve a registered component by name, tolerant of case and separators. */
|
|
30
|
+
resolve(name: string | null | undefined): ComponentType<any> | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a name → component registry whose `resolve` is case- and
|
|
35
|
+
* separator-insensitive (`LimitDistributionChart` === `limit-distribution-chart`).
|
|
36
|
+
*/
|
|
37
|
+
export function createComponentRegistry(
|
|
38
|
+
entries: Record<string, ComponentType<any>>,
|
|
39
|
+
): ComponentRegistry {
|
|
40
|
+
return {
|
|
41
|
+
entries,
|
|
42
|
+
available: () => Object.keys(entries),
|
|
43
|
+
resolve(name) {
|
|
44
|
+
if (!name) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const direct = entries[name];
|
|
48
|
+
if (direct) {
|
|
49
|
+
return direct;
|
|
50
|
+
}
|
|
51
|
+
const target = normalize(name);
|
|
52
|
+
const key = Object.keys(entries).find((k) => normalize(k) === target);
|
|
53
|
+
return key ? entries[key] : undefined;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Props for {@link SingleComponentOutlet}.
|
|
60
|
+
*/
|
|
61
|
+
export interface SingleComponentOutletProps {
|
|
62
|
+
/** The requested component name (typically captured via {@link readInitialParam}). */
|
|
63
|
+
name: string | null | undefined;
|
|
64
|
+
/** Registry to resolve the name against. */
|
|
65
|
+
registry: ComponentRegistry;
|
|
66
|
+
/**
|
|
67
|
+
* Render when no component matches. Defaults to a plain, unstyled
|
|
68
|
+
* "Unknown component" message; pass your own to theme it.
|
|
69
|
+
*/
|
|
70
|
+
renderUnknown?: (name: string | null | undefined, available: string[]) => ReactNode;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const defaultUnknown = (name: string | null | undefined, available: string[]): ReactNode => (
|
|
74
|
+
<div role="alert" style={{ padding: 16 }}>
|
|
75
|
+
{`Unknown component "${name ?? ''}". Available: ${available.join(', ')}`}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve `name` against `registry` and render the matching component, or a
|
|
81
|
+
* fallback when unmatched.
|
|
82
|
+
*
|
|
83
|
+
* Theming/provider chrome is intentionally left to the caller — wrap this outlet
|
|
84
|
+
* in your app's design-system provider so the standalone component is themed
|
|
85
|
+
* identically to how it appears in-app.
|
|
86
|
+
*/
|
|
87
|
+
export function SingleComponentOutlet({
|
|
88
|
+
name,
|
|
89
|
+
registry,
|
|
90
|
+
renderUnknown = defaultUnknown,
|
|
91
|
+
}: SingleComponentOutletProps) {
|
|
92
|
+
const Component = registry.resolve(name);
|
|
93
|
+
return <>{Component ? <Component /> : renderUnknown(name, registry.available())}</>;
|
|
94
|
+
}
|