@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
@@ -0,0 +1,166 @@
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 { TerpProvider, useAuth } from "./TerpProvider";
7
+ import { UserMenu, userInitials } from "./UserMenu";
8
+ import { LOCALE_EN, 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("userInitials", () => {
54
+ it("derives initials from the email's local part", () => {
55
+ expect(userInitials("jane.doe@example.com")).toBe("JD");
56
+ expect(userInitials("admin@example.test")).toBe("A");
57
+ expect(userInitials("@example.test")).toBe("?");
58
+ });
59
+ });
60
+
61
+ describe("UserMenu", () => {
62
+ it("renders nothing while signed out", () => {
63
+ stubAuthFetch();
64
+ render(
65
+ <TerpProvider baseUrl="https://api.test">
66
+ <UserMenu />
67
+ </TerpProvider>,
68
+ );
69
+ expect(screen.queryByRole("button", { name: "Account menu" })).not.toBeInTheDocument();
70
+ });
71
+
72
+ it("shows the avatar initials, email and role, and opens the panel", async () => {
73
+ stubAuthFetch();
74
+ render(
75
+ <ThemeProvider>
76
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: { label: "Nederlands" } }}>
77
+ <TerpProvider baseUrl="https://api.test">
78
+ <LogInOnMount />
79
+ <UserMenu />
80
+ </TerpProvider>
81
+ </LocaleProvider>
82
+ </ThemeProvider>,
83
+ );
84
+ const trigger = await screen.findByRole("button", { name: "Account menu" });
85
+ expect(screen.getByText("JD")).toBeInTheDocument();
86
+ expect(screen.getByText("jane.doe@example.com")).toBeInTheDocument();
87
+ expect(screen.getByText("editor")).toBeInTheDocument();
88
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
89
+
90
+ fireEvent.click(trigger);
91
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
92
+ // The panel shows the identity block and offers sign-out (Settings needs onSettings).
93
+ expect(screen.getAllByText("jane.doe@example.com")).toHaveLength(2);
94
+ expect(screen.queryByRole("menuitem", { name: "Settings" })).not.toBeInTheDocument();
95
+ expect(screen.getByRole("menuitem", { name: "Sign out" })).toBeInTheDocument();
96
+ });
97
+
98
+ it("opens the settings page and closes the panel", async () => {
99
+ stubAuthFetch();
100
+ const onSettings = vi.fn();
101
+ render(
102
+ <TerpProvider baseUrl="https://api.test">
103
+ <LogInOnMount />
104
+ <UserMenu onSettings={onSettings} />
105
+ </TerpProvider>,
106
+ );
107
+ fireEvent.click(await screen.findByRole("button", { name: "Account menu" }));
108
+ fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
109
+ expect(onSettings).toHaveBeenCalledTimes(1);
110
+ expect(screen.queryByRole("menuitem", { name: "Sign out" })).not.toBeInTheDocument();
111
+ });
112
+
113
+ it("shows only the avatar on the trigger when collapsed", async () => {
114
+ stubAuthFetch();
115
+ render(
116
+ <TerpProvider baseUrl="https://api.test">
117
+ <LogInOnMount />
118
+ <UserMenu collapsed />
119
+ </TerpProvider>,
120
+ );
121
+ const trigger = await screen.findByRole("button", { name: "Account menu" });
122
+ expect(screen.getByText("JD")).toBeInTheDocument();
123
+ expect(screen.queryByText("jane.doe@example.com")).not.toBeInTheDocument();
124
+ expect(trigger.style.width).toBe("100%");
125
+ expect(trigger.style.boxSizing).toBe("border-box");
126
+ expect(trigger.style.justifyContent).toBe("center");
127
+ expect(trigger.style.padding).toBe("0px");
128
+ // The identity still surfaces inside the opened panel.
129
+ fireEvent.click(trigger);
130
+ expect(screen.getByText("jane.doe@example.com")).toBeInTheDocument();
131
+ });
132
+
133
+ it("signs out via the menu (revokes the token server-side)", async () => {
134
+ const fetchMock = stubAuthFetch();
135
+ render(
136
+ <TerpProvider baseUrl="https://api.test">
137
+ <LogInOnMount />
138
+ <UserMenu />
139
+ </TerpProvider>,
140
+ );
141
+ fireEvent.click(await screen.findByRole("button", { name: "Account menu" }));
142
+ fireEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
143
+ await waitFor(() =>
144
+ expect(
145
+ fetchMock.mock.calls.some(([input]) =>
146
+ (input as Request).url.endsWith("/api/v1/auth/logout"),
147
+ ),
148
+ ).toBe(true),
149
+ );
150
+ });
151
+
152
+ it("closes on Escape", async () => {
153
+ stubAuthFetch();
154
+ render(
155
+ <TerpProvider baseUrl="https://api.test">
156
+ <LogInOnMount />
157
+ <UserMenu />
158
+ </TerpProvider>,
159
+ );
160
+ const trigger = await screen.findByRole("button", { name: "Account menu" });
161
+ fireEvent.click(trigger);
162
+ expect(screen.getByRole("menuitem", { name: "Sign out" })).toBeInTheDocument();
163
+ fireEvent.keyDown(document, { key: "Escape" });
164
+ expect(screen.queryByRole("menuitem", { name: "Sign out" })).not.toBeInTheDocument();
165
+ });
166
+ });
@@ -0,0 +1,125 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ import { Icon } from "./icons";
4
+ import { useAuth } from "./TerpProvider";
5
+ import { Menu, MenuItem } from "./ui/Menu";
6
+ import { CONTROL_TEXT_STYLE } from "./ui/controlStyles";
7
+ import { useStrings } from "./uiText";
8
+
9
+ /** Initials for the avatar: the first letters of the email's local-part words. */
10
+ export function userInitials(email: string): string {
11
+ const local = email.split("@")[0] ?? "";
12
+ const words = local.split(/[._+-]+/).filter((word) => word.length > 0);
13
+ const initials = words.slice(0, 2).map((word) => word[0]!.toUpperCase());
14
+ return initials.join("") || "?";
15
+ }
16
+
17
+ const triggerStyle: CSSProperties = {
18
+ ...CONTROL_TEXT_STYLE,
19
+ display: "flex",
20
+ alignItems: "center",
21
+ justifyContent: "flex-start",
22
+ gap: "var(--space-2)",
23
+ width: "100%",
24
+ boxSizing: "border-box",
25
+ padding: "var(--space-2)",
26
+ textAlign: "left",
27
+ color: "var(--color-neutral-900)",
28
+ background: "transparent",
29
+ border: "1px solid transparent",
30
+ borderRadius: "var(--radius-md)",
31
+ cursor: "pointer",
32
+ };
33
+
34
+ const collapsedTriggerStyle: CSSProperties = {
35
+ justifyContent: "center",
36
+ gap: 0,
37
+ padding: 0,
38
+ };
39
+
40
+ const avatarStyle: CSSProperties = {
41
+ display: "inline-flex",
42
+ alignItems: "center",
43
+ justifyContent: "center",
44
+ width: "2rem",
45
+ height: "2rem",
46
+ flexShrink: 0,
47
+ borderRadius: "var(--radius-full)",
48
+ background: "var(--color-brand-primary)",
49
+ color: "var(--color-brand-primary-contrast)",
50
+ fontSize: "var(--font-size-sm)",
51
+ fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
52
+ };
53
+
54
+ const identityStyle: CSSProperties = { display: "grid", minWidth: 0, fontSize: "var(--font-size-sm)" };
55
+ const emailStyle: CSSProperties = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
56
+ const roleStyle: CSSProperties = { color: "var(--color-neutral-600)" };
57
+ const panelIdentityStyle: CSSProperties = {
58
+ display: "grid",
59
+ gap: "var(--space-1)",
60
+ padding: "var(--space-2)",
61
+ marginBottom: "var(--space-1)",
62
+ borderBottom: "1px solid var(--color-neutral-200)",
63
+ fontSize: "var(--font-size-sm)",
64
+ overflowWrap: "anywhere",
65
+ };
66
+
67
+ export interface UserMenuProps {
68
+ /** Icon-rail mode: show only the avatar on the trigger (the shell's collapsed state). */
69
+ collapsed?: boolean;
70
+ /** Opens the settings / profile page; rendered as the menu's first item when provided. */
71
+ onSettings?: () => void;
72
+ }
73
+
74
+ /** The signed-in user's account menu. */
75
+ export function UserMenu({ collapsed = false, onSettings }: UserMenuProps = {}) {
76
+ const auth = useAuth();
77
+ const strings = useStrings();
78
+ const user = auth.currentUser();
79
+ if (user === null) {
80
+ return null;
81
+ }
82
+
83
+ const trigger = (
84
+ <>
85
+ <span aria-hidden="true" style={avatarStyle}>{userInitials(user.email)}</span>
86
+ {!collapsed && (
87
+ <span style={identityStyle}>
88
+ <span style={emailStyle}>{user.email}</span>
89
+ <span style={roleStyle}>{user.role_name}</span>
90
+ </span>
91
+ )}
92
+ </>
93
+ );
94
+
95
+ return (
96
+ <Menu
97
+ trigger={trigger}
98
+ triggerLabel={strings.accountMenu}
99
+ placement="top"
100
+ align="start"
101
+ triggerStyle={collapsed ? { ...triggerStyle, ...collapsedTriggerStyle } : triggerStyle}
102
+ panelStyle={{ minWidth: "14rem", padding: "var(--space-2)" }}
103
+ >
104
+ {({ close }) => (
105
+ <>
106
+ <div style={panelIdentityStyle}>
107
+ <span>{user.email}</span>
108
+ <span style={roleStyle}>{user.role_name}</span>
109
+ </div>
110
+ {onSettings !== undefined && (
111
+ <MenuItem
112
+ label={strings.settings}
113
+ icon={<Icon name="user" />}
114
+ onSelect={() => {
115
+ close(true);
116
+ onSettings();
117
+ }}
118
+ />
119
+ )}
120
+ <MenuItem label={strings.signOut} icon={<Icon name="logout" />} onSelect={() => void auth.logout()} />
121
+ </>
122
+ )}
123
+ </Menu>
124
+ );
125
+ }
@@ -0,0 +1,108 @@
1
+ import { Link } from "@tanstack/react-router";
2
+ import { useEffect, useState } from "react";
3
+
4
+ import { HubCard, HubPage } from "../HubPage";
5
+ import type { RenderHubCardLink } from "../HubPage";
6
+ import type { AdminAreaSections } from "../bootstrap";
7
+ import { NavIcon } from "../icons";
8
+ import { useTerpClient } from "../TerpProvider";
9
+ import { unwrap } from "../unwrap";
10
+ import { useStrings } from "../uiText";
11
+
12
+ const renderLink: RenderHubCardLink = ({ to, children }) => <Link to={to}>{children}</Link>;
13
+
14
+ interface HubStats {
15
+ users: number | null;
16
+ groups: number | null;
17
+ }
18
+
19
+ /**
20
+ * Live totals for the hub cards (a `limit=1` page carries the exact total).
21
+ * A section the app dropped never fires its call — its capability may not be
22
+ * mounted at all.
23
+ */
24
+ function useHubStats(sections: Required<AdminAreaSections>): HubStats {
25
+ const client = useTerpClient();
26
+ const [stats, setStats] = useState<HubStats>({ users: null, groups: null });
27
+ const { users: wantUsers, groups: wantGroups } = sections;
28
+ useEffect(() => {
29
+ const controller = new AbortController();
30
+ void (async () => {
31
+ try {
32
+ const [users, groups] = await Promise.all([
33
+ wantUsers
34
+ ? client.GET("/api/v1/users/", {
35
+ params: { query: { limit: 1 } },
36
+ signal: controller.signal,
37
+ })
38
+ : null,
39
+ wantGroups
40
+ ? client.GET("/api/v1/groups/", {
41
+ params: { query: { limit: 1 } },
42
+ signal: controller.signal,
43
+ })
44
+ : null,
45
+ ]);
46
+ setStats({
47
+ users: users !== null ? unwrap(users).total : null,
48
+ groups: groups !== null ? unwrap(groups).total : null,
49
+ });
50
+ } catch {
51
+ // The cards stay navigable without their stat lines (e.g. offline, races).
52
+ }
53
+ })();
54
+ return () => controller.abort();
55
+ }, [client, wantUsers, wantGroups]);
56
+ return stats;
57
+ }
58
+
59
+ /**
60
+ * The packaged admin hub (`/admin`): one card per administration area — users,
61
+ * groups and the audit log — with live totals where they are cheap to know.
62
+ * The sidebar's single "Admin" entry opens this hub; the overviews breadcrumb
63
+ * back to it, keeping the hub -> overview -> detail layering every Terp screen
64
+ * follows. `sections` (default: all) mirrors the app's `adminArea` selection —
65
+ * a dropped section loses its card and its stat call.
66
+ */
67
+ export function AdminHub({ sections }: { sections?: AdminAreaSections } = {}) {
68
+ const strings = useStrings();
69
+ const selected = {
70
+ users: sections?.users !== false,
71
+ groups: sections?.groups !== false,
72
+ audit: sections?.audit !== false,
73
+ };
74
+ const stats = useHubStats(selected);
75
+ return (
76
+ <HubPage title={strings.admin} parents={[{ label: strings.home, to: "/" }]}>
77
+ {selected.users && (
78
+ <HubCard
79
+ to="/admin/users"
80
+ title={strings.adminUsers}
81
+ description={strings.adminUsersDescription}
82
+ icon={<NavIcon name="users" label={strings.adminUsers} />}
83
+ stat={stats.users !== null ? String(stats.users) : undefined}
84
+ renderLink={renderLink}
85
+ />
86
+ )}
87
+ {selected.groups && (
88
+ <HubCard
89
+ to="/admin/groups"
90
+ title={strings.adminGroups}
91
+ description={strings.adminGroupsDescription}
92
+ icon={<NavIcon name="shield" label={strings.adminGroups} />}
93
+ stat={stats.groups !== null ? String(stats.groups) : undefined}
94
+ renderLink={renderLink}
95
+ />
96
+ )}
97
+ {selected.audit && (
98
+ <HubCard
99
+ to="/admin/audit"
100
+ title={strings.adminAudit}
101
+ description={strings.adminAuditDescription}
102
+ icon={<NavIcon name="audit" label={strings.adminAudit} />}
103
+ renderLink={renderLink}
104
+ />
105
+ )}
106
+ </HubPage>
107
+ );
108
+ }
@@ -0,0 +1,116 @@
1
+ import { useMemo } from "react";
2
+ import type { components } from "@terpjs/contract";
3
+
4
+ import { Page } from "../Page";
5
+ import { useTerpClient } from "../TerpProvider";
6
+ import { DataView, HttpDataViewRepository, useServerDataView } from "../dataview";
7
+ import type { DataViewColumn } from "../dataview";
8
+ import { DetailList } from "../layout";
9
+ import { useStrings } from "../uiText";
10
+ import type { TerpStrings } from "../uiText";
11
+ import { unwrap } from "../unwrap";
12
+
13
+ import { adminCrumb, renderAdminCrumb } from "./crumbs";
14
+
15
+ type AuditEventRead = components["schemas"]["AuditEventRead"];
16
+
17
+ function buildColumns(strings: TerpStrings): DataViewColumn<AuditEventRead>[] {
18
+ return [
19
+ {
20
+ id: "created_at",
21
+ header: strings.whenColumn,
22
+ accessor: (e) => e.created_at,
23
+ cell: (e) => new Date(e.created_at).toLocaleString(),
24
+ meta: { mobileSlot: "date", width: 170 },
25
+ },
26
+ {
27
+ id: "action",
28
+ header: strings.actionColumn,
29
+ accessor: (e) => e.action,
30
+ meta: { mobileSlot: "status", width: 100 },
31
+ },
32
+ {
33
+ id: "target",
34
+ header: strings.targetColumn,
35
+ accessor: (e) => `${e.target_type} ${e.target_id}`,
36
+ cell: (e) => `${e.target_type} · ${e.target_id.slice(0, 8)}`,
37
+ meta: { mobileSlot: "title" },
38
+ },
39
+ {
40
+ id: "actor",
41
+ header: strings.actorColumn,
42
+ accessor: (e) => e.actor_id ?? "",
43
+ cell: (e) => (e.actor_id === null ? "—" : e.actor_id.slice(0, 8)),
44
+ meta: { mobileSlot: "subtitle", width: 110 },
45
+ },
46
+ ];
47
+ }
48
+
49
+ const payloadStyle = {
50
+ margin: 0,
51
+ padding: "var(--space-3)",
52
+ background: "var(--color-neutral-100)",
53
+ borderRadius: "var(--radius-md)",
54
+ fontSize: "var(--font-size-sm, 0.875rem)",
55
+ overflowX: "auto" as const,
56
+ };
57
+
58
+ /**
59
+ * The packaged audit-log screen (`/admin/audit`): the append-only trail every
60
+ * mutation lands in (ADR 0007), newest first as served, read-only by design.
61
+ * A row expands to the full identifiers and the redacted payload snapshot.
62
+ */
63
+ export function AuditLogAdmin() {
64
+ const client = useTerpClient();
65
+ const strings = useStrings();
66
+ const serverQuery = useServerDataView({ initialPageSize: 25 });
67
+
68
+ const columns = useMemo(() => buildColumns(strings), [strings]);
69
+ const repository = useMemo(
70
+ () =>
71
+ new HttpDataViewRepository<AuditEventRead>({
72
+ getRowId: (e) => e.id,
73
+ search: false,
74
+ request: async ({ skip, limit }, signal) => {
75
+ const page = unwrap(
76
+ await client.GET("/api/v1/audit/", {
77
+ params: { query: { skip, limit } },
78
+ signal,
79
+ }),
80
+ );
81
+ return { items: page.items, total: page.total };
82
+ },
83
+ }),
84
+ [client],
85
+ );
86
+
87
+ return (
88
+ <Page
89
+ title={strings.adminAudit}
90
+ breadcrumbs={[adminCrumb(strings)]}
91
+ renderLink={renderAdminCrumb}
92
+ >
93
+ <DataView<AuditEventRead>
94
+ viewId="terp.admin.audit"
95
+ repository={repository}
96
+ columns={columns}
97
+ serverQuery={serverQuery}
98
+ pageSizeOptions={[25, 50, 100]}
99
+ renderExpanded={(event) => (
100
+ <div>
101
+ <DetailList
102
+ items={[
103
+ { label: strings.targetColumn, value: `${event.target_type} ${event.target_id}` },
104
+ { label: strings.actorColumn, value: event.actor_id ?? "—" },
105
+ { label: "Request", value: event.request_id ?? "—" },
106
+ ]}
107
+ />
108
+ {event.payload !== null && (
109
+ <pre style={payloadStyle}>{JSON.stringify(event.payload, null, 2)}</pre>
110
+ )}
111
+ </div>
112
+ )}
113
+ />
114
+ </Page>
115
+ );
116
+ }
@@ -0,0 +1,90 @@
1
+ import { useNavigate } from "@tanstack/react-router";
2
+ import { useState } from "react";
3
+ import type { FormEvent } from "react";
4
+
5
+ import { Field } from "../Field";
6
+ import { Icon } from "../icons";
7
+ import { Stack } from "../layout";
8
+ import { Page } from "../Page";
9
+ import { PageActions } from "../PageActions";
10
+ import { useTerpClient } from "../TerpProvider";
11
+ import { useToast } from "../toast";
12
+ import { Button } from "../ui/Button";
13
+ import { Input } from "../ui/Input";
14
+ import { useStrings } from "../uiText";
15
+ import { unwrap } from "../unwrap";
16
+
17
+ import { adminCrumb, renderAdminCrumb } from "./crumbs";
18
+
19
+ const FORM_ID = "terp-admin-group-create";
20
+
21
+ /** Dedicated group-creation page (`/admin/groups/new`). */
22
+ export function GroupCreate() {
23
+ const client = useTerpClient();
24
+ const navigate = useNavigate();
25
+ const strings = useStrings();
26
+ const toast = useToast();
27
+ const [name, setName] = useState("");
28
+ const [description, setDescription] = useState("");
29
+ const [creating, setCreating] = useState(false);
30
+
31
+ async function onSubmit(event: FormEvent) {
32
+ event.preventDefault();
33
+ setCreating(true);
34
+ try {
35
+ const group = unwrap(
36
+ await client.POST("/api/v1/groups/", { body: { name, description } }),
37
+ );
38
+ toast.success(strings.saved);
39
+ await navigate({
40
+ to: "/admin/groups/$groupId",
41
+ params: { groupId: group.id },
42
+ });
43
+ } catch (error) {
44
+ toast.warning(error instanceof Error ? error.message : strings.requestFailed);
45
+ } finally {
46
+ setCreating(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <Page
52
+ title={strings.createGroup}
53
+ breadcrumbs={[
54
+ { ...adminCrumb(strings), to: "/admin" },
55
+ { label: strings.adminGroups, to: "/admin/groups" },
56
+ ]}
57
+ renderLink={renderAdminCrumb}
58
+ actions={
59
+ <PageActions
60
+ secondary={
61
+ <Button variant="secondary" onClick={() => void navigate({ to: "/admin/groups" })}>
62
+ {strings.cancel}
63
+ </Button>
64
+ }
65
+ primary={
66
+ <Button
67
+ type="submit"
68
+ form={FORM_ID}
69
+ icon={<Icon name="plus" />}
70
+ disabled={creating}
71
+ >
72
+ {creating ? strings.working : strings.createGroup}
73
+ </Button>
74
+ }
75
+ />
76
+ }
77
+ >
78
+ <div style={{ maxWidth: "32rem" }}>
79
+ <Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
80
+ <Field label={strings.groupName}>
81
+ <Input value={name} onChange={(event) => setName(event.target.value)} required />
82
+ </Field>
83
+ <Field label={strings.description}>
84
+ <Input value={description} onChange={(event) => setDescription(event.target.value)} />
85
+ </Field>
86
+ </Stack>
87
+ </div>
88
+ </Page>
89
+ );
90
+ }