@terpjs/react-core 0.1.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.
Files changed (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
package/src/Page.tsx ADDED
@@ -0,0 +1,158 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { useContext, useEffect, useRef, useState } from "react";
3
+
4
+ import { Breadcrumbs } from "./Breadcrumbs";
5
+ import type { BreadcrumbItem, RenderBreadcrumbLink } from "./Breadcrumbs";
6
+ import { ErrorState } from "./ErrorState";
7
+ import {
8
+ LayoutSlotContext,
9
+ useLayoutContract,
10
+ verifySlotChildren,
11
+ } from "./layoutContract";
12
+ import { LoadingState } from "./LoadingState";
13
+ import { usePageMarker } from "./pageMarker";
14
+ import { useUiText } from "./uiText";
15
+ import type { UiText } from "./uiText";
16
+
17
+ export interface PageProps {
18
+ /** The page heading (rendered as the single `h1`). */
19
+ title: UiText;
20
+ /** Ancestor breadcrumb trail, outermost first; the current page's crumb is appended automatically. */
21
+ breadcrumbs?: readonly BreadcrumbItem[];
22
+ /** Link renderer for ancestor crumbs; pass the stack's `Link` (see {@link Breadcrumbs}). */
23
+ renderLink?: RenderBreadcrumbLink;
24
+ /** Optional page-level actions, rendered on the heading row (e.g. a primary `Button`). */
25
+ actions?: ReactNode;
26
+ /** Show the loading state instead of the body (the header stays for orientation). */
27
+ isLoading?: boolean;
28
+ /** Loading slot; defaults to the standard {@link LoadingState} spinner block. */
29
+ loadingState?: ReactNode;
30
+ /**
31
+ * The failure to surface instead of the body (e.g. the record 404'd or access was revoked).
32
+ * Accepts the caught error itself (`ApiError`, `Error`, raw envelope) or a plain message;
33
+ * pass `useResource`'s `cause ?? error` so the stable `code` reaches the code→copy map.
34
+ * Takes precedence over `isLoading`, so a failed query never gets stuck on a spinner.
35
+ */
36
+ error?: unknown;
37
+ /** Error slot; defaults to {@link ErrorState} rendering `error` through the error-code map. */
38
+ errorState?: ReactNode;
39
+ /** The page body. */
40
+ children: ReactNode;
41
+ }
42
+
43
+ const pageStyle: CSSProperties = {
44
+ display: "grid",
45
+ gridTemplateColumns: "minmax(0, 1fr)",
46
+ gap: "var(--space-4)",
47
+ alignContent: "start",
48
+ minWidth: 0,
49
+ };
50
+
51
+ const headerStyle: CSSProperties = { display: "grid", gap: "var(--space-2)" };
52
+
53
+ const breadcrumbRowStyle: CSSProperties = {
54
+ display: "flex",
55
+ alignItems: "center",
56
+ minHeight: "2rem",
57
+ };
58
+
59
+ const titleRowStyle: CSSProperties = {
60
+ display: "flex",
61
+ alignItems: "center",
62
+ justifyContent: "space-between",
63
+ gap: "var(--space-3)",
64
+ flexWrap: "wrap",
65
+ };
66
+
67
+ const titleStyle: CSSProperties = {
68
+ margin: 0,
69
+ fontSize: "var(--font-size-lg)",
70
+ fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
71
+ letterSpacing: 0,
72
+ color: "var(--color-neutral-900)",
73
+ lineHeight: 1.3,
74
+ };
75
+
76
+ /**
77
+ * The base content-page frame: every routed view is constructed the same way — one
78
+ * header holding the breadcrumb trail (when there is a path back up through the
79
+ * layers), then one row with the single `h1` title on the left and the page's
80
+ * `actions` slot on the right, then the body. A root page omits the redundant
81
+ * current-page-only crumb. Title-first DOM order keeps narrow layouts natural.
82
+ * `OverviewPage` and `DetailPage` specialise it for the standard overview -> detail
83
+ * layering; a bespoke screen composes `Page` directly.
84
+ *
85
+ * The frame also owns the async body states: `error` (which wins, so a failed
86
+ * query never hides behind a spinner) then `isLoading` replace the body while the
87
+ * header stays put, so the user keeps their place in the layers.
88
+ */
89
+ export function Page({
90
+ title,
91
+ breadcrumbs,
92
+ renderLink,
93
+ actions,
94
+ isLoading,
95
+ loadingState,
96
+ error,
97
+ errorState,
98
+ children,
99
+ }: PageProps) {
100
+ const resolve = useUiText();
101
+ // Mark the routed view as archetype-framed (the runtime half of the page-archetype control).
102
+ usePageMarker()?.();
103
+ // The runtime half of the slot-typed layout contract control (ADR 0079): when the app
104
+ // opted into a contract and the enclosing archetype declared a governed body slot
105
+ // (OverviewPage / DetailPage), the rendered body's DOM children must each carry an
106
+ // allowed component's data-terp marker — verified one macrotask after mount (like the
107
+ // page-archetype check) and refused fail closed with the lint rule's directive message.
108
+ const contract = useLayoutContract();
109
+ const slotOwner = useContext(LayoutSlotContext);
110
+ const articleRef = useRef<HTMLElement>(null);
111
+ const [slotViolation, setSlotViolation] = useState<string | null>(null);
112
+ const showsBody = (error === null || error === undefined) && !isLoading;
113
+ useEffect(() => {
114
+ if (contract === null || slotOwner === null || !showsBody) {
115
+ return;
116
+ }
117
+ const timer = setTimeout(() => {
118
+ const article = articleRef.current;
119
+ if (article === null) {
120
+ return;
121
+ }
122
+ const body = [...article.children].filter((child) => child.tagName !== "HEADER");
123
+ setSlotViolation(verifySlotChildren(contract, slotOwner, body));
124
+ }, 0);
125
+ return () => clearTimeout(timer);
126
+ });
127
+ if (slotViolation !== null) {
128
+ throw new Error(slotViolation);
129
+ }
130
+ const hasAncestors = breadcrumbs !== undefined && breadcrumbs.length > 0;
131
+ const trail: BreadcrumbItem[] = hasAncestors ? [...breadcrumbs, { label: title }] : [];
132
+ const body =
133
+ error !== null && error !== undefined ? (
134
+ (errorState ?? <ErrorState error={error} />)
135
+ ) : isLoading ? (
136
+ (loadingState ?? <LoadingState />)
137
+ ) : (
138
+ children
139
+ );
140
+ return (
141
+ <article ref={articleRef} style={pageStyle}>
142
+ <header style={headerStyle}>
143
+ {hasAncestors && (
144
+ <div style={breadcrumbRowStyle}>
145
+ <Breadcrumbs items={trail} renderLink={renderLink} />
146
+ </div>
147
+ )}
148
+ <div style={titleRowStyle}>
149
+ <h1 style={titleStyle}>{resolve(title)}</h1>
150
+ {actions}
151
+ </div>
152
+ </header>
153
+ {/* Reset the slot for the body's own subtree, so nested content is never judged
154
+ by an ancestor archetype's slot. */}
155
+ <LayoutSlotContext.Provider value={null}>{body}</LayoutSlotContext.Provider>
156
+ </article>
157
+ );
158
+ }
@@ -0,0 +1,104 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { PageActions } from "./PageActions";
6
+ import { Button } from "./ui/Button";
7
+ import { UiTextProvider } from "./uiText";
8
+
9
+ afterEach(cleanup);
10
+
11
+ describe("PageActions", () => {
12
+ it("returns no wrapper when no actions are supplied", () => {
13
+ const { container } = render(<PageActions />);
14
+
15
+ expect(container).toBeEmptyDOMElement();
16
+ });
17
+
18
+ it("orders overflow, secondary, then primary actions", () => {
19
+ render(
20
+ <PageActions
21
+ overflow={[{ label: "Archive", onSelect: () => {} }]}
22
+ secondary={<Button variant="secondary">Filter</Button>}
23
+ primary={<Button>Save</Button>}
24
+ />,
25
+ );
26
+
27
+ expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([
28
+ "⋯",
29
+ "Filter",
30
+ "Save",
31
+ ]);
32
+ });
33
+
34
+ it("opens overflow actions and closes after selecting one, restoring trigger focus", () => {
35
+ const onSelect = vi.fn();
36
+ render(<PageActions overflow={[{ label: "Delete", variant: "destructive", onSelect }]} />);
37
+
38
+ fireEvent.click(screen.getByRole("button", { name: "More actions" }));
39
+ fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
40
+
41
+ expect(onSelect).toHaveBeenCalledOnce();
42
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
43
+ expect(screen.getByRole("button", { name: "More actions" })).toHaveFocus();
44
+ });
45
+
46
+ it("moves focus into the menu on open and roams items with arrow keys", () => {
47
+ render(
48
+ <PageActions
49
+ overflow={[
50
+ { label: "Archive", onSelect: () => {} },
51
+ { label: "Duplicate", onSelect: () => {}, disabled: true },
52
+ { label: "Delete", onSelect: () => {} },
53
+ ]}
54
+ />,
55
+ );
56
+
57
+ fireEvent.click(screen.getByRole("button", { name: "More actions" }));
58
+ const menu = screen.getByRole("menu");
59
+ expect(screen.getByRole("menuitem", { name: "Archive" })).toHaveFocus();
60
+
61
+ fireEvent.keyDown(menu, { key: "ArrowDown" });
62
+ expect(screen.getByRole("menuitem", { name: "Delete" })).toHaveFocus();
63
+ fireEvent.keyDown(menu, { key: "ArrowDown" });
64
+ expect(screen.getByRole("menuitem", { name: "Archive" })).toHaveFocus();
65
+ fireEvent.keyDown(menu, { key: "ArrowUp" });
66
+ expect(screen.getByRole("menuitem", { name: "Delete" })).toHaveFocus();
67
+ fireEvent.keyDown(menu, { key: "Home" });
68
+ expect(screen.getByRole("menuitem", { name: "Archive" })).toHaveFocus();
69
+ fireEvent.keyDown(menu, { key: "End" });
70
+ expect(screen.getByRole("menuitem", { name: "Delete" })).toHaveFocus();
71
+ });
72
+
73
+ it("closes on Escape (restoring trigger focus) and on outside click", () => {
74
+ render(<PageActions overflow={[{ label: "Archive", onSelect: () => {} }]} />);
75
+
76
+ fireEvent.click(screen.getByRole("button", { name: "More actions" }));
77
+ fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
78
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
79
+ expect(screen.getByRole("button", { name: "More actions" })).toHaveFocus();
80
+
81
+ fireEvent.click(screen.getByRole("button", { name: "More actions" }));
82
+ expect(screen.getByRole("menu")).toBeInTheDocument();
83
+ fireEvent.pointerDown(document.body);
84
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
85
+ });
86
+
87
+ it("closes when tabbing away from the menu", () => {
88
+ render(<PageActions overflow={[{ label: "Archive", onSelect: () => {} }]} />);
89
+
90
+ fireEvent.click(screen.getByRole("button", { name: "More actions" }));
91
+ fireEvent.keyDown(screen.getByRole("menu"), { key: "Tab" });
92
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
93
+ });
94
+
95
+ it("localises the overflow trigger label", () => {
96
+ render(
97
+ <UiTextProvider strings={{ moreActions: "Meer acties" }}>
98
+ <PageActions overflow={[{ label: "Archiveren", onSelect: () => {} }]} />
99
+ </UiTextProvider>,
100
+ );
101
+
102
+ expect(screen.getByRole("button", { name: "Meer acties" })).toBeInTheDocument();
103
+ });
104
+ });
@@ -0,0 +1,72 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ import { Menu, MenuItem } from "./ui/Menu";
4
+ import { useStrings } from "./uiText";
5
+ import type { UiText } from "./uiText";
6
+
7
+ export interface OverflowAction {
8
+ /** Display label for the menu item. */
9
+ label: UiText;
10
+ /** Optional leading icon or glyph. */
11
+ icon?: ReactNode;
12
+ onSelect: () => void;
13
+ /** Destructive actions are shown in the danger colour inside the overflow menu. */
14
+ variant?: "default" | "destructive";
15
+ disabled?: boolean;
16
+ }
17
+
18
+ export interface PageActionsProps {
19
+ /** The single most important call-to-action, supplied by the page as a styled element. */
20
+ primary?: ReactNode;
21
+ /** Supporting action(s), supplied by the page as styled element(s). */
22
+ secondary?: ReactNode;
23
+ /** Rare or destructive actions, kept out of the primary click path. */
24
+ overflow?: readonly OverflowAction[];
25
+ className?: string;
26
+ }
27
+
28
+ const clusterStyle: CSSProperties = {
29
+ display: "flex",
30
+ flexWrap: "wrap",
31
+ alignItems: "center",
32
+ justifyContent: "flex-end",
33
+ gap: "var(--space-2)",
34
+ };
35
+
36
+ /** Standard right-aligned action cluster for page headers. */
37
+ export function PageActions({ primary, secondary, overflow, className }: PageActionsProps) {
38
+ const strings = useStrings();
39
+ const hasOverflow = overflow !== undefined && overflow.length > 0;
40
+
41
+ if (primary === undefined && secondary === undefined && !hasOverflow) {
42
+ return null;
43
+ }
44
+
45
+ return (
46
+ <div className={className} style={clusterStyle}>
47
+ {hasOverflow && (
48
+ <Menu trigger="⋯" triggerLabel={strings.moreActions}>
49
+ {({ close }) => (
50
+ <>
51
+ {overflow.map((action) => (
52
+ <MenuItem
53
+ key={typeof action.label === "string" ? action.label : action.label.id}
54
+ label={action.label}
55
+ icon={action.icon}
56
+ destructive={action.variant === "destructive"}
57
+ disabled={action.disabled}
58
+ onSelect={() => {
59
+ action.onSelect();
60
+ close(true);
61
+ }}
62
+ />
63
+ ))}
64
+ </>
65
+ )}
66
+ </Menu>
67
+ )}
68
+ {secondary}
69
+ {primary}
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,112 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { useEffect } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { ProfileView } from "./ProfileView";
7
+ import { TerpProvider, useAuth } from "./TerpProvider";
8
+ import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "./locale";
9
+ import { ThemeProvider } from "./theme";
10
+
11
+ function jsonResponse(body: unknown): Response {
12
+ return new Response(JSON.stringify(body), {
13
+ status: 200,
14
+ headers: { "content-type": "application/json" },
15
+ });
16
+ }
17
+
18
+ afterEach(() => {
19
+ cleanup();
20
+ vi.restoreAllMocks();
21
+ window.localStorage.clear();
22
+ document.documentElement.removeAttribute("data-theme");
23
+ });
24
+
25
+ function LogInOnMount() {
26
+ const auth = useAuth();
27
+ useEffect(() => {
28
+ void auth.login({ email: "jane.doe@example.com", password: "pw" });
29
+ }, []);
30
+ return null;
31
+ }
32
+
33
+ function stubAuthFetch() {
34
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
35
+ const url = (input as Request).url;
36
+ if (url.endsWith("/api/v1/auth/login")) {
37
+ return jsonResponse({ access_token: "token", token_type: "bearer" });
38
+ }
39
+ if (url.endsWith("/api/v1/auth/logout")) {
40
+ return new Response(null, { status: 204 });
41
+ }
42
+ return jsonResponse({
43
+ id: "u1",
44
+ email: "jane.doe@example.com",
45
+ role_rank: 20,
46
+ role_name: "editor",
47
+ });
48
+ });
49
+ vi.stubGlobal("fetch", fetchMock);
50
+ return fetchMock;
51
+ }
52
+
53
+ describe("ProfileView", () => {
54
+ it("shows the identity, the preference controls and sign-out, framed by a Page", async () => {
55
+ const fetchMock = stubAuthFetch();
56
+ render(
57
+ <ThemeProvider>
58
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: LOCALE_NL }}>
59
+ <TerpProvider baseUrl="https://api.test">
60
+ <LogInOnMount />
61
+ <ProfileView />
62
+ </TerpProvider>
63
+ </LocaleProvider>
64
+ </ThemeProvider>,
65
+ );
66
+
67
+ await waitFor(() =>
68
+ expect(screen.getByRole("heading", { level: 1, name: "Profile" })).toBeInTheDocument(),
69
+ );
70
+ expect(screen.getAllByText("jane.doe@example.com").length).toBeGreaterThanOrEqual(1);
71
+ expect(screen.getByText("editor (20)")).toBeInTheDocument();
72
+ // The stacked preference controls live here (settings surface).
73
+ expect(screen.getByLabelText("Theme")).toBeInTheDocument();
74
+ expect(screen.getByLabelText("Language")).toBeInTheDocument();
75
+
76
+ fireEvent.click(screen.getByRole("button", { name: "Sign out" }));
77
+ await waitFor(() =>
78
+ expect(
79
+ fetchMock.mock.calls.some(([input]) =>
80
+ (input as Request).url.endsWith("/api/v1/auth/logout"),
81
+ ),
82
+ ).toBe(true),
83
+ );
84
+ });
85
+
86
+ it("renders nothing while signed out", () => {
87
+ stubAuthFetch();
88
+ render(
89
+ <TerpProvider baseUrl="https://api.test">
90
+ <ProfileView />
91
+ </TerpProvider>,
92
+ );
93
+ expect(screen.queryByRole("heading", { name: "Profile" })).not.toBeInTheDocument();
94
+ });
95
+
96
+ it("follows the active locale (Dutch out of the box)", async () => {
97
+ stubAuthFetch();
98
+ window.localStorage.setItem("terp.locale", "nl");
99
+ render(
100
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: LOCALE_NL }}>
101
+ <TerpProvider baseUrl="https://api.test">
102
+ <LogInOnMount />
103
+ <ProfileView />
104
+ </TerpProvider>
105
+ </LocaleProvider>,
106
+ );
107
+ await waitFor(() =>
108
+ expect(screen.getByRole("heading", { level: 1, name: "Profiel" })).toBeInTheDocument(),
109
+ );
110
+ expect(screen.getByRole("button", { name: "Uitloggen" })).toBeInTheDocument();
111
+ });
112
+ });
@@ -0,0 +1,84 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ import { Page } from "./Page";
4
+ import { useAuth } from "./TerpProvider";
5
+ import { LanguageSwitcher } from "./locale";
6
+ import { Stack, DetailList } from "./layout";
7
+ import { ThemeToggle } from "./theme";
8
+ import { userInitials } from "./UserMenu";
9
+ import { Button } from "./ui/Button";
10
+ import { useStrings } from "./uiText";
11
+
12
+ const avatarStyle: CSSProperties = {
13
+ display: "inline-flex",
14
+ alignItems: "center",
15
+ justifyContent: "center",
16
+ width: "3.5rem",
17
+ height: "3.5rem",
18
+ flexShrink: 0,
19
+ borderRadius: "var(--radius-full)",
20
+ background: "var(--color-brand-primary)",
21
+ color: "var(--color-brand-primary-contrast)",
22
+ fontSize: "var(--font-size-lg)",
23
+ fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
24
+ };
25
+
26
+ const mutedStyle: CSSProperties = { margin: 0, color: "var(--color-neutral-600)" };
27
+
28
+ const cardStyle: CSSProperties = {
29
+ display: "grid",
30
+ gap: "var(--space-4)",
31
+ padding: "var(--space-4)",
32
+ maxWidth: "32rem",
33
+ background: "var(--color-neutral-0)",
34
+ border: "1px solid var(--color-neutral-200)",
35
+ borderRadius: "var(--radius-lg)",
36
+ };
37
+
38
+ /**
39
+ * The built-in profile / settings page the {@link UserMenu}'s Settings item opens.
40
+ * `buildAppRouter` mounts it at `/profile` in every app (an app manifest claiming
41
+ * that path wins): the signed-in identity (avatar, email, role — the server-validated
42
+ * `/me` session, not token claims), the standard theme + language preferences, and
43
+ * sign-out. A `Page` archetype, so it satisfies the routed-view frame control.
44
+ */
45
+ export function ProfileView() {
46
+ const auth = useAuth();
47
+ const strings = useStrings();
48
+ const user = auth.currentUser();
49
+ if (user === null) {
50
+ return null; // RequireAuth gates the shell; this is unreachable when signed out.
51
+ }
52
+ return (
53
+ <Page title={strings.profile}>
54
+ <Stack gap={4}>
55
+ <div style={cardStyle}>
56
+ <Stack direction="row" gap={3} align="center">
57
+ <span aria-hidden="true" style={avatarStyle}>
58
+ {userInitials(user.email)}
59
+ </span>
60
+ <Stack gap={0}>
61
+ <strong style={{ overflowWrap: "anywhere" }}>{user.email}</strong>
62
+ <p style={mutedStyle}>{user.role_name}</p>
63
+ </Stack>
64
+ </Stack>
65
+ <DetailList
66
+ items={[
67
+ { label: strings.email, value: user.email },
68
+ { label: strings.role, value: `${user.role_name} (${user.role_rank})` },
69
+ ]}
70
+ />
71
+ </div>
72
+ <div style={cardStyle}>
73
+ <ThemeToggle />
74
+ <LanguageSwitcher />
75
+ <div>
76
+ <Button variant="secondary" onClick={() => void auth.logout()}>
77
+ {strings.signOut}
78
+ </Button>
79
+ </div>
80
+ </div>
81
+ </Stack>
82
+ </Page>
83
+ );
84
+ }
@@ -0,0 +1,89 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import { useEffect } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { RequireAuth } from "./RequireAuth";
7
+ import { TerpProvider, useAuth } from "./TerpProvider";
8
+
9
+ function jsonResponse(body: unknown): Response {
10
+ return new Response(JSON.stringify(body), {
11
+ status: 200,
12
+ headers: { "content-type": "application/json" },
13
+ });
14
+ }
15
+
16
+ afterEach(() => {
17
+ cleanup();
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ function LogInOnMount() {
22
+ const auth = useAuth();
23
+ useEffect(() => {
24
+ void auth.login({ email: "user@example.com", password: "pw" });
25
+ }, []);
26
+ return null;
27
+ }
28
+
29
+ describe("RequireAuth", () => {
30
+ it("shows the fallback while signed out", async () => {
31
+ render(
32
+ <TerpProvider baseUrl="https://api.test">
33
+ <RequireAuth fallback={<span>please-sign-in</span>}>
34
+ <span>app</span>
35
+ </RequireAuth>
36
+ </TerpProvider>,
37
+ );
38
+ await waitFor(() => expect(screen.getByText("please-sign-in")).toBeInTheDocument());
39
+ expect(screen.queryByText("app")).not.toBeInTheDocument();
40
+ });
41
+
42
+ it("shows the pending view while boot refresh is in flight", async () => {
43
+ let finishRefresh!: () => void;
44
+ vi.stubGlobal(
45
+ "fetch",
46
+ vi.fn<typeof fetch>(
47
+ async () => new Promise<Response>((resolve) => {
48
+ finishRefresh = () => resolve(new Response("{}", { status: 401 }));
49
+ }),
50
+ ),
51
+ );
52
+
53
+ render(
54
+ <TerpProvider baseUrl="https://api.test">
55
+ <RequireAuth fallback={<span>please-sign-in</span>} pending={<span>checking</span>}>
56
+ <span>app</span>
57
+ </RequireAuth>
58
+ </TerpProvider>,
59
+ );
60
+ expect(screen.getByText("checking")).toBeInTheDocument();
61
+ expect(screen.queryByText("please-sign-in")).not.toBeInTheDocument();
62
+
63
+ finishRefresh();
64
+ await waitFor(() => expect(screen.getByText("please-sign-in")).toBeInTheDocument());
65
+ });
66
+
67
+ it("shows children once a user signs in", async () => {
68
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
69
+ const url = (input as Request).url;
70
+ if (url.endsWith("/api/v1/auth/login")) {
71
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
72
+ }
73
+ return jsonResponse({ id: "1", email: "user@example.com", role_rank: 10, role_name: "viewer" });
74
+ });
75
+ vi.stubGlobal("fetch", fetchMock);
76
+
77
+ render(
78
+ <TerpProvider baseUrl="https://api.test">
79
+ <LogInOnMount />
80
+ <RequireAuth fallback={<span>please-sign-in</span>}>
81
+ <span>app</span>
82
+ </RequireAuth>
83
+ </TerpProvider>,
84
+ );
85
+
86
+ await waitFor(() => expect(screen.getByText("app")).toBeInTheDocument());
87
+ expect(screen.queryByText("please-sign-in")).not.toBeInTheDocument();
88
+ });
89
+ });
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { useAuth } from "./TerpProvider";
4
+
5
+ export interface RequireAuthProps {
6
+ /** Rendered while no user is signed in (e.g. a login view). */
7
+ fallback: ReactNode;
8
+ /** Rendered while the provider is checking a persisted session (default: nothing). */
9
+ pending?: ReactNode;
10
+ children: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * Render `children` only when a user is signed in, otherwise `fallback` (typically a login
15
+ * view). Pair with the router so the authenticated app mounts only once there is a session;
16
+ * the backend still enforces authorization on every request.
17
+ */
18
+ export function RequireAuth({ fallback, pending = null, children }: RequireAuthProps) {
19
+ const auth = useAuth();
20
+ if (auth.loading()) return <>{pending}</>;
21
+ return <>{auth.currentUser() !== null ? children : fallback}</>;
22
+ }