minutework 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 (203) hide show
  1. package/EXTERNAL_ALPHA.md +74 -0
  2. package/README.md +57 -0
  3. package/assets/claude-local/CLAUDE.md.template +45 -0
  4. package/assets/claude-local/bundle.json +22 -0
  5. package/assets/claude-local/skills/README.md +6 -0
  6. package/assets/claude-local/skills/app-pack-authoring.md +8 -0
  7. package/assets/claude-local/skills/event-bus.md +8 -0
  8. package/assets/claude-local/skills/ontology-mapping.md +8 -0
  9. package/assets/claude-local/skills/openclaw-skill-importer.md +7 -0
  10. package/assets/claude-local/skills/schema-engine.md +8 -0
  11. package/assets/claude-local/skills/secrets-runtime-bridge.md +9 -0
  12. package/assets/claude-local/skills/sidecar-generation.md +9 -0
  13. package/assets/templates/fastapi-sidecar/.env.example +8 -0
  14. package/assets/templates/fastapi-sidecar/README.md +77 -0
  15. package/assets/templates/fastapi-sidecar/poetry.lock +757 -0
  16. package/assets/templates/fastapi-sidecar/pyproject.toml +42 -0
  17. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/__init__.py +3 -0
  18. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/auth.py +70 -0
  19. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/bridge/__init__.py +3 -0
  20. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/bridge/client.py +71 -0
  21. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/logging_utils.py +25 -0
  22. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/main.py +85 -0
  23. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/receipts.py +24 -0
  24. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/settings.py +41 -0
  25. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/template_validation.py +26 -0
  26. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/worker.py +33 -0
  27. package/assets/templates/fastapi-sidecar/template.json +43 -0
  28. package/assets/templates/fastapi-sidecar/template.schema.json +160 -0
  29. package/assets/templates/fastapi-sidecar/tests/conftest.py +36 -0
  30. package/assets/templates/fastapi-sidecar/tests/test_app.py +39 -0
  31. package/assets/templates/fastapi-sidecar/tests/test_auth.py +32 -0
  32. package/assets/templates/fastapi-sidecar/tests/test_bridge_client.py +31 -0
  33. package/assets/templates/fastapi-sidecar/tests/test_materialization.py +55 -0
  34. package/assets/templates/fastapi-sidecar/tests/test_template_contract.py +49 -0
  35. package/assets/templates/fastapi-sidecar/tests/test_worker.py +7 -0
  36. package/assets/templates/fastapi-sidecar/tools/template/validate_template.py +20 -0
  37. package/assets/templates/next-tenant-app/.env.example +8 -0
  38. package/assets/templates/next-tenant-app/.storybook/main.ts +19 -0
  39. package/assets/templates/next-tenant-app/.storybook/preview.tsx +38 -0
  40. package/assets/templates/next-tenant-app/README.md +115 -0
  41. package/assets/templates/next-tenant-app/components.json +21 -0
  42. package/assets/templates/next-tenant-app/eslint.config.mjs +41 -0
  43. package/assets/templates/next-tenant-app/next-env.d.ts +6 -0
  44. package/assets/templates/next-tenant-app/next.config.ts +8 -0
  45. package/assets/templates/next-tenant-app/package-lock.json +9682 -0
  46. package/assets/templates/next-tenant-app/package.json +59 -0
  47. package/assets/templates/next-tenant-app/pnpm-lock.yaml +6062 -0
  48. package/assets/templates/next-tenant-app/postcss.config.mjs +8 -0
  49. package/assets/templates/next-tenant-app/src/app/api/auth/context/route.test.ts +90 -0
  50. package/assets/templates/next-tenant-app/src/app/api/auth/context/route.ts +78 -0
  51. package/assets/templates/next-tenant-app/src/app/api/auth/login/route.ts +31 -0
  52. package/assets/templates/next-tenant-app/src/app/api/auth/logout/route.ts +16 -0
  53. package/assets/templates/next-tenant-app/src/app/api/auth/password-change/route.test.ts +79 -0
  54. package/assets/templates/next-tenant-app/src/app/api/auth/password-change/route.ts +40 -0
  55. package/assets/templates/next-tenant-app/src/app/api/auth/password-status/route.test.ts +42 -0
  56. package/assets/templates/next-tenant-app/src/app/api/auth/password-status/route.ts +29 -0
  57. package/assets/templates/next-tenant-app/src/app/api/auth/session/route.ts +26 -0
  58. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/[runId]/route.test.ts +40 -0
  59. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/[runId]/route.ts +47 -0
  60. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/route.test.ts +43 -0
  61. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/route.ts +45 -0
  62. package/assets/templates/next-tenant-app/src/app/app/examples/runtime-commands/page.test.ts +83 -0
  63. package/assets/templates/next-tenant-app/src/app/app/examples/runtime-commands/page.tsx +30 -0
  64. package/assets/templates/next-tenant-app/src/app/app/layout.tsx +20 -0
  65. package/assets/templates/next-tenant-app/src/app/app/page.test.ts +62 -0
  66. package/assets/templates/next-tenant-app/src/app/app/page.tsx +24 -0
  67. package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.test.ts +70 -0
  68. package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.tsx +57 -0
  69. package/assets/templates/next-tenant-app/src/app/blog/page.test.ts +42 -0
  70. package/assets/templates/next-tenant-app/src/app/blog/page.tsx +37 -0
  71. package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.test.ts +70 -0
  72. package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.tsx +55 -0
  73. package/assets/templates/next-tenant-app/src/app/docs/page.test.ts +42 -0
  74. package/assets/templates/next-tenant-app/src/app/docs/page.tsx +37 -0
  75. package/assets/templates/next-tenant-app/src/app/globals.css +70 -0
  76. package/assets/templates/next-tenant-app/src/app/layout.tsx +69 -0
  77. package/assets/templates/next-tenant-app/src/app/login/page.test.ts +55 -0
  78. package/assets/templates/next-tenant-app/src/app/login/page.tsx +33 -0
  79. package/assets/templates/next-tenant-app/src/app/page.test.ts +56 -0
  80. package/assets/templates/next-tenant-app/src/app/page.tsx +35 -0
  81. package/assets/templates/next-tenant-app/src/app/pricing/page.test.ts +55 -0
  82. package/assets/templates/next-tenant-app/src/app/pricing/page.tsx +35 -0
  83. package/assets/templates/next-tenant-app/src/app/providers.tsx +25 -0
  84. package/assets/templates/next-tenant-app/src/app/robots.test.ts +20 -0
  85. package/assets/templates/next-tenant-app/src/app/robots.ts +18 -0
  86. package/assets/templates/next-tenant-app/src/app/sitemap.test.ts +49 -0
  87. package/assets/templates/next-tenant-app/src/app/sitemap.ts +54 -0
  88. package/assets/templates/next-tenant-app/src/components/ui/button.tsx +59 -0
  89. package/assets/templates/next-tenant-app/src/components/ui/input.tsx +21 -0
  90. package/assets/templates/next-tenant-app/src/design-system/docs/governance.mdx +26 -0
  91. package/assets/templates/next-tenant-app/src/design-system/patterns/panel-frame.stories.tsx +48 -0
  92. package/assets/templates/next-tenant-app/src/design-system/patterns/panel-frame.tsx +26 -0
  93. package/assets/templates/next-tenant-app/src/design-system/patterns/status-badge.stories.tsx +26 -0
  94. package/assets/templates/next-tenant-app/src/design-system/patterns/status-badge.tsx +35 -0
  95. package/assets/templates/next-tenant-app/src/design-system/patterns/theme-mode-toggle.stories.tsx +21 -0
  96. package/assets/templates/next-tenant-app/src/design-system/patterns/theme-mode-toggle.tsx +75 -0
  97. package/assets/templates/next-tenant-app/src/design-system/primitives/button.stories.tsx +37 -0
  98. package/assets/templates/next-tenant-app/src/design-system/primitives/button.ts +1 -0
  99. package/assets/templates/next-tenant-app/src/design-system/primitives/input.stories.tsx +26 -0
  100. package/assets/templates/next-tenant-app/src/design-system/primitives/input.ts +1 -0
  101. package/assets/templates/next-tenant-app/src/design-system/recipes/chrome.ts +28 -0
  102. package/assets/templates/next-tenant-app/src/design-system/tokens/foundation.css +31 -0
  103. package/assets/templates/next-tenant-app/src/design-system/tokens/index.css +3 -0
  104. package/assets/templates/next-tenant-app/src/design-system/tokens/manifest.json +85 -0
  105. package/assets/templates/next-tenant-app/src/design-system/tokens/manifest.ts +87 -0
  106. package/assets/templates/next-tenant-app/src/design-system/tokens/semantic.css +105 -0
  107. package/assets/templates/next-tenant-app/src/design-system/tokens/theme.css +59 -0
  108. package/assets/templates/next-tenant-app/src/design-system/tokens/tokens.stories.tsx +71 -0
  109. package/assets/templates/next-tenant-app/src/features/auth/components/login-screen.tsx +198 -0
  110. package/assets/templates/next-tenant-app/src/features/dashboard/components/tenant-dashboard.tsx +153 -0
  111. package/assets/templates/next-tenant-app/src/features/examples/runtime-command-demo/components/runtime-command-demo.tsx +342 -0
  112. package/assets/templates/next-tenant-app/src/features/public-shell/components/content-article.tsx +66 -0
  113. package/assets/templates/next-tenant-app/src/features/public-shell/components/content-collection.tsx +108 -0
  114. package/assets/templates/next-tenant-app/src/features/public-shell/components/marketing-page-canvas.tsx +111 -0
  115. package/assets/templates/next-tenant-app/src/features/public-shell/components/public-site-shell.tsx +111 -0
  116. package/assets/templates/next-tenant-app/src/features/shell/components/private-app-shell.tsx +624 -0
  117. package/assets/templates/next-tenant-app/src/lib/app-routes.test.ts +20 -0
  118. package/assets/templates/next-tenant-app/src/lib/app-routes.ts +59 -0
  119. package/assets/templates/next-tenant-app/src/lib/content/__fixtures__/public-site-snapshot.ts +189 -0
  120. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.test.ts +318 -0
  121. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.ts +232 -0
  122. package/assets/templates/next-tenant-app/src/lib/content/contracts.ts +339 -0
  123. package/assets/templates/next-tenant-app/src/lib/content/custom-adapter.ts +5 -0
  124. package/assets/templates/next-tenant-app/src/lib/content/empty-state.ts +96 -0
  125. package/assets/templates/next-tenant-app/src/lib/platform/auth.server.test.ts +75 -0
  126. package/assets/templates/next-tenant-app/src/lib/platform/auth.server.ts +25 -0
  127. package/assets/templates/next-tenant-app/src/lib/platform/client.server.test.ts +170 -0
  128. package/assets/templates/next-tenant-app/src/lib/platform/client.server.ts +661 -0
  129. package/assets/templates/next-tenant-app/src/lib/platform/contracts.ts +131 -0
  130. package/assets/templates/next-tenant-app/src/lib/platform/endpoints.server.ts +34 -0
  131. package/assets/templates/next-tenant-app/src/lib/platform/env.server.test.ts +102 -0
  132. package/assets/templates/next-tenant-app/src/lib/platform/env.server.ts +87 -0
  133. package/assets/templates/next-tenant-app/src/lib/platform/route-response.ts +33 -0
  134. package/assets/templates/next-tenant-app/src/lib/platform/session.server.ts +108 -0
  135. package/assets/templates/next-tenant-app/src/lib/public-site.test.ts +20 -0
  136. package/assets/templates/next-tenant-app/src/lib/public-site.ts +49 -0
  137. package/assets/templates/next-tenant-app/src/lib/theme-config.ts +10 -0
  138. package/assets/templates/next-tenant-app/src/lib/theme.tsx +159 -0
  139. package/assets/templates/next-tenant-app/src/lib/utils.ts +6 -0
  140. package/assets/templates/next-tenant-app/template.json +27 -0
  141. package/assets/templates/next-tenant-app/template.schema.json +160 -0
  142. package/assets/templates/next-tenant-app/test/server-only-stub.ts +1 -0
  143. package/assets/templates/next-tenant-app/tools/design-system/build-token-manifest.mjs +3 -0
  144. package/assets/templates/next-tenant-app/tools/design-system/check-imports.mjs +9 -0
  145. package/assets/templates/next-tenant-app/tools/design-system/check-stories.mjs +9 -0
  146. package/assets/templates/next-tenant-app/tools/design-system/check-values.mjs +9 -0
  147. package/assets/templates/next-tenant-app/tools/design-system/checks.mjs +238 -0
  148. package/assets/templates/next-tenant-app/tools/design-system/eslint-plugin-design-system.mjs +184 -0
  149. package/assets/templates/next-tenant-app/tools/design-system/playwright.config.mjs +34 -0
  150. package/assets/templates/next-tenant-app/tools/design-system/run-checks.mjs +22 -0
  151. package/assets/templates/next-tenant-app/tools/design-system/shared.mjs +166 -0
  152. package/assets/templates/next-tenant-app/tools/design-system/visual.spec.ts +41 -0
  153. package/assets/templates/next-tenant-app/tools/template/validate-route-contract.mjs +39 -0
  154. package/assets/templates/next-tenant-app/tools/template/validate-template.mjs +45 -0
  155. package/assets/templates/next-tenant-app/tsconfig.json +42 -0
  156. package/assets/templates/next-tenant-app/vitest.config.ts +25 -0
  157. package/bin/minutework.js +40 -0
  158. package/dist/auth.d.ts +59 -0
  159. package/dist/auth.js +338 -0
  160. package/dist/auth.js.map +1 -0
  161. package/dist/browser.d.ts +1 -0
  162. package/dist/browser.js +26 -0
  163. package/dist/browser.js.map +1 -0
  164. package/dist/cli.d.ts +2 -0
  165. package/dist/cli.js +5 -0
  166. package/dist/cli.js.map +1 -0
  167. package/dist/compile.d.ts +20 -0
  168. package/dist/compile.js +121 -0
  169. package/dist/compile.js.map +1 -0
  170. package/dist/config.d.ts +25 -0
  171. package/dist/config.js +102 -0
  172. package/dist/config.js.map +1 -0
  173. package/dist/deploy-state.d.ts +35 -0
  174. package/dist/deploy-state.js +30 -0
  175. package/dist/deploy-state.js.map +1 -0
  176. package/dist/deploy.d.ts +22 -0
  177. package/dist/deploy.js +308 -0
  178. package/dist/deploy.js.map +1 -0
  179. package/dist/developer-client.d.ts +88 -0
  180. package/dist/developer-client.js +78 -0
  181. package/dist/developer-client.js.map +1 -0
  182. package/dist/index.d.ts +27 -0
  183. package/dist/index.js +290 -0
  184. package/dist/index.js.map +1 -0
  185. package/dist/init.d.ts +22 -0
  186. package/dist/init.js +421 -0
  187. package/dist/init.js.map +1 -0
  188. package/dist/launcher.d.ts +1 -0
  189. package/dist/launcher.js +50 -0
  190. package/dist/launcher.js.map +1 -0
  191. package/dist/paths.d.ts +12 -0
  192. package/dist/paths.js +33 -0
  193. package/dist/paths.js.map +1 -0
  194. package/dist/sandbox.d.ts +30 -0
  195. package/dist/sandbox.js +852 -0
  196. package/dist/sandbox.js.map +1 -0
  197. package/dist/state.d.ts +46 -0
  198. package/dist/state.js +82 -0
  199. package/dist/state.js.map +1 -0
  200. package/dist/tokens.d.ts +14 -0
  201. package/dist/tokens.js +293 -0
  202. package/dist/tokens.js.map +1 -0
  203. package/package.json +43 -0
@@ -0,0 +1,624 @@
1
+ "use client";
2
+
3
+ import type { FormEvent } from "react";
4
+ import { startTransition, useEffect, useEffectEvent, useState } from "react";
5
+ import dynamic from "next/dynamic";
6
+ import Link from "next/link";
7
+ import { useRouter } from "next/navigation";
8
+ import {
9
+ Building2,
10
+ KeyRound,
11
+ LayoutDashboard,
12
+ Loader2,
13
+ LogOut,
14
+ RefreshCcw,
15
+ ShieldCheck,
16
+ Workflow,
17
+ } from "lucide-react";
18
+
19
+ import { TenantDashboard } from "@/features/dashboard/components/tenant-dashboard";
20
+ import { PanelFrame } from "@/design-system/patterns/panel-frame";
21
+ import { StatusBadge } from "@/design-system/patterns/status-badge";
22
+ import { ThemeModeToggle } from "@/design-system/patterns/theme-mode-toggle";
23
+ import { Button } from "@/design-system/primitives/button";
24
+ import { Input } from "@/design-system/primitives/input";
25
+ import { appRoutes } from "@/lib/app-routes";
26
+ import type {
27
+ AuthenticatedPlatformSession,
28
+ PasswordStatus,
29
+ SessionMembership,
30
+ } from "@/lib/platform/contracts";
31
+
32
+ type ShellView = "dashboard" | "runtime-command-demo";
33
+
34
+ function RuntimeCommandDemoFallback() {
35
+ return (
36
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="space-y-3">
37
+ <div className="flex items-center gap-2">
38
+ <Loader2 className="size-4 animate-spin text-primary" />
39
+ <p className="text-sm font-semibold text-foreground">
40
+ Loading runtime example
41
+ </p>
42
+ </div>
43
+ <p className="text-sm leading-6 text-muted-foreground">
44
+ The optional example module is loaded only when the runtime example route
45
+ is active.
46
+ </p>
47
+ </PanelFrame>
48
+ );
49
+ }
50
+
51
+ const RuntimeCommandDemo = dynamic(
52
+ () =>
53
+ import(
54
+ "@/features/examples/runtime-command-demo/components/runtime-command-demo"
55
+ ).then((module) => module.RuntimeCommandDemo),
56
+ {
57
+ loading: () => <RuntimeCommandDemoFallback />,
58
+ },
59
+ );
60
+
61
+ function membershipById(
62
+ memberships: SessionMembership[],
63
+ tenantId: string,
64
+ ) {
65
+ return memberships.find((membership) => membership.tenant_id === tenantId) ?? null;
66
+ }
67
+
68
+ async function readErrorDetail(response: Response, fallback: string) {
69
+ const payload = (await response.json().catch(() => null)) as
70
+ | { detail?: string }
71
+ | null;
72
+
73
+ return payload?.detail || fallback;
74
+ }
75
+
76
+ export function PrivateAppShell({
77
+ appName,
78
+ initialSession,
79
+ operatorConsoleHref,
80
+ runtimeCommandExampleEnabled,
81
+ view,
82
+ }: {
83
+ appName: string;
84
+ initialSession: AuthenticatedPlatformSession;
85
+ operatorConsoleHref?: string;
86
+ runtimeCommandExampleEnabled: boolean;
87
+ view: ShellView;
88
+ }) {
89
+ const router = useRouter();
90
+ const [session, setSession] = useState(initialSession);
91
+ const [selectedTenantId, setSelectedTenantId] = useState(
92
+ initialSession.active_tenant_id,
93
+ );
94
+ const [signingOut, setSigningOut] = useState(false);
95
+ const [tenantPending, setTenantPending] = useState(false);
96
+ const [tenantError, setTenantError] = useState("");
97
+ const [tenantNotice, setTenantNotice] = useState("");
98
+ const [passwordStatus, setPasswordStatus] = useState<PasswordStatus | null>(null);
99
+ const [passwordLoading, setPasswordLoading] = useState(true);
100
+ const [passwordPending, setPasswordPending] = useState(false);
101
+ const [passwordError, setPasswordError] = useState("");
102
+ const [passwordNotice, setPasswordNotice] = useState("");
103
+ const [currentPassword, setCurrentPassword] = useState("");
104
+ const [newPassword, setNewPassword] = useState("");
105
+ const [confirmPassword, setConfirmPassword] = useState("");
106
+
107
+ const activeTenant = session.active_tenant;
108
+ const activeMembership = membershipById(
109
+ session.memberships,
110
+ session.active_tenant_id,
111
+ );
112
+ const pendingMembership = membershipById(session.memberships, selectedTenantId);
113
+
114
+ function redirectToLogin() {
115
+ startTransition(() => {
116
+ router.replace(appRoutes.login);
117
+ router.refresh();
118
+ });
119
+ }
120
+
121
+ const loadPasswordStatus = useEffectEvent(async () => {
122
+ setPasswordLoading(true);
123
+
124
+ try {
125
+ const response = await fetch("/api/auth/password-status", {
126
+ method: "GET",
127
+ cache: "no-store",
128
+ });
129
+
130
+ if (response.status === 401) {
131
+ redirectToLogin();
132
+ return;
133
+ }
134
+
135
+ if (!response.ok) {
136
+ setPasswordError(
137
+ await readErrorDetail(response, "Unable to load password settings."),
138
+ );
139
+ return;
140
+ }
141
+
142
+ const payload = (await response.json()) as PasswordStatus;
143
+ setPasswordStatus(payload);
144
+ setPasswordError("");
145
+ } catch {
146
+ setPasswordError("Unable to load password settings.");
147
+ } finally {
148
+ setPasswordLoading(false);
149
+ }
150
+ });
151
+
152
+ useEffect(() => {
153
+ setSelectedTenantId(session.active_tenant_id);
154
+ }, [session.active_tenant_id]);
155
+
156
+ useEffect(() => {
157
+ void loadPasswordStatus();
158
+ }, []);
159
+
160
+ async function handleLogout() {
161
+ setSigningOut(true);
162
+ await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
163
+ redirectToLogin();
164
+ }
165
+
166
+ async function handleTenantSwitch() {
167
+ if (!selectedTenantId || selectedTenantId === session.active_tenant_id) {
168
+ return;
169
+ }
170
+
171
+ setTenantPending(true);
172
+ setTenantError("");
173
+ setTenantNotice("");
174
+
175
+ try {
176
+ const response = await fetch("/api/auth/context", {
177
+ method: "PUT",
178
+ headers: { "Content-Type": "application/json" },
179
+ body: JSON.stringify({ tenant_id: selectedTenantId }),
180
+ });
181
+
182
+ if (response.status === 401) {
183
+ redirectToLogin();
184
+ return;
185
+ }
186
+
187
+ if (!response.ok) {
188
+ setTenantError(
189
+ await readErrorDetail(response, "Unable to update tenant context."),
190
+ );
191
+ return;
192
+ }
193
+
194
+ const payload = (await response.json()) as AuthenticatedPlatformSession;
195
+ setSession(payload);
196
+ setTenantNotice("Tenant context updated.");
197
+ } catch {
198
+ setTenantError("Unable to update tenant context.");
199
+ } finally {
200
+ setTenantPending(false);
201
+ }
202
+ }
203
+
204
+ async function handleTenantReset() {
205
+ setTenantPending(true);
206
+ setTenantError("");
207
+ setTenantNotice("");
208
+
209
+ try {
210
+ const response = await fetch("/api/auth/context", {
211
+ method: "DELETE",
212
+ });
213
+
214
+ if (response.status === 401) {
215
+ redirectToLogin();
216
+ return;
217
+ }
218
+
219
+ if (!response.ok) {
220
+ setTenantError(
221
+ await readErrorDetail(response, "Unable to reset tenant context."),
222
+ );
223
+ return;
224
+ }
225
+
226
+ const payload = (await response.json()) as AuthenticatedPlatformSession;
227
+ setSession(payload);
228
+ setTenantNotice("Tenant context reset to the platform default.");
229
+ } catch {
230
+ setTenantError("Unable to reset tenant context.");
231
+ } finally {
232
+ setTenantPending(false);
233
+ }
234
+ }
235
+
236
+ async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
237
+ event.preventDefault();
238
+
239
+ if (newPassword !== confirmPassword) {
240
+ setPasswordError("Passwords must match.");
241
+ return;
242
+ }
243
+
244
+ setPasswordPending(true);
245
+ setPasswordError("");
246
+ setPasswordNotice("");
247
+
248
+ try {
249
+ const response = await fetch("/api/auth/password-change", {
250
+ method: "POST",
251
+ headers: { "Content-Type": "application/json" },
252
+ body: JSON.stringify({
253
+ current_password: currentPassword,
254
+ new_password: newPassword,
255
+ confirm_password: confirmPassword,
256
+ }),
257
+ });
258
+
259
+ if (response.status === 401) {
260
+ redirectToLogin();
261
+ return;
262
+ }
263
+
264
+ if (!response.ok) {
265
+ setPasswordError(
266
+ await readErrorDetail(response, "Unable to change password."),
267
+ );
268
+ return;
269
+ }
270
+
271
+ const payload = (await response.json()) as PasswordStatus;
272
+ setPasswordStatus(payload);
273
+ setCurrentPassword("");
274
+ setNewPassword("");
275
+ setConfirmPassword("");
276
+ setPasswordNotice("Password saved.");
277
+ } catch {
278
+ setPasswordError("Unable to change password.");
279
+ } finally {
280
+ setPasswordPending(false);
281
+ }
282
+ }
283
+
284
+ if (!activeTenant) {
285
+ return (
286
+ <main className="min-h-screen bg-background text-foreground">
287
+ <div className="mx-auto flex min-h-screen max-w-5xl items-center px-6 py-10">
288
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="w-full space-y-4">
289
+ <div className="space-y-2">
290
+ <p className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
291
+ Missing tenant context
292
+ </p>
293
+ <h1 className="text-3xl font-semibold tracking-tight">
294
+ This account is authenticated but has no active tenant membership.
295
+ </h1>
296
+ <p className="text-sm leading-7 text-muted-foreground">
297
+ The template expects an authenticated membership-backed tenant
298
+ session before entering the private app shell.
299
+ </p>
300
+ </div>
301
+ <Button onClick={handleLogout} variant="outline" className="w-fit">
302
+ Return to login
303
+ </Button>
304
+ </PanelFrame>
305
+ </div>
306
+ </main>
307
+ );
308
+ }
309
+
310
+ const content =
311
+ view === "runtime-command-demo" ? (
312
+ <RuntimeCommandDemo
313
+ activeTenant={activeTenant}
314
+ onUnauthorized={redirectToLogin}
315
+ viewerName={session.user.username}
316
+ />
317
+ ) : (
318
+ <TenantDashboard
319
+ appName={appName}
320
+ runtimeCommandExampleEnabled={runtimeCommandExampleEnabled}
321
+ session={session}
322
+ />
323
+ );
324
+
325
+ return (
326
+ <main className="min-h-screen bg-background text-foreground">
327
+ <div className="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-6 py-8">
328
+ <header className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
329
+ <div className="space-y-4">
330
+ <div className="space-y-2">
331
+ <p className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
332
+ Private Next.js Tenant Template
333
+ </p>
334
+ <h1 className="max-w-3xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
335
+ {appName}
336
+ </h1>
337
+ <p className="max-w-3xl text-base leading-7 text-muted-foreground sm:text-lg">
338
+ A canonical `platform_session_bff` scaffold for tenant-facing
339
+ Builder apps. The browser talks only to this Next.js app, and the
340
+ app keeps platform auth and CSRF credentials server-owned.
341
+ </p>
342
+ </div>
343
+
344
+ <div className="flex flex-wrap gap-3">
345
+ <StatusBadge tone="primary">platform_session_bff</StatusBadge>
346
+ <StatusBadge tone="info">Active tenant aware</StatusBadge>
347
+ <StatusBadge tone="success">Builder-ready scaffold</StatusBadge>
348
+ </div>
349
+ </div>
350
+
351
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
352
+ <ThemeModeToggle className="w-full sm:w-72" />
353
+ {operatorConsoleHref ? (
354
+ <Button asChild type="button" variant="outline">
355
+ <a href={operatorConsoleHref} target="_blank" rel="noreferrer">
356
+ Open /ops
357
+ </a>
358
+ </Button>
359
+ ) : null}
360
+ <Button
361
+ type="button"
362
+ variant="outline"
363
+ className="gap-2"
364
+ onClick={handleLogout}
365
+ disabled={signingOut}
366
+ >
367
+ {signingOut ? (
368
+ <Loader2 className="size-4 animate-spin" />
369
+ ) : (
370
+ <LogOut className="size-4" />
371
+ )}
372
+ {signingOut ? "Signing out" : "Log out"}
373
+ </Button>
374
+ </div>
375
+ </header>
376
+
377
+ <nav className="flex flex-wrap gap-2">
378
+ <Button asChild variant={view === "dashboard" ? "default" : "outline"}>
379
+ <Link href={appRoutes.appHome}>
380
+ <LayoutDashboard className="size-4" />
381
+ Dashboard
382
+ </Link>
383
+ </Button>
384
+ {runtimeCommandExampleEnabled ? (
385
+ <Button
386
+ asChild
387
+ variant={view === "runtime-command-demo" ? "default" : "outline"}
388
+ >
389
+ <Link href={appRoutes.runtimeCommandExample}>
390
+ <Workflow className="size-4" />
391
+ Runtime example
392
+ </Link>
393
+ </Button>
394
+ ) : null}
395
+ </nav>
396
+
397
+ <section className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
398
+ <div>{content}</div>
399
+
400
+ <div className="grid gap-6">
401
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="space-y-5">
402
+ <div className="space-y-2">
403
+ <div className="flex items-center gap-2">
404
+ <Building2 className="size-4 text-primary" />
405
+ <p className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
406
+ Tenant context
407
+ </p>
408
+ </div>
409
+ <h2 className="text-2xl font-semibold tracking-tight">
410
+ {activeTenant.tenant_name}
411
+ </h2>
412
+ <p className="text-sm leading-6 text-muted-foreground">
413
+ Signed in as{" "}
414
+ <span className="font-medium text-foreground">
415
+ {session.user.username}
416
+ </span>{" "}
417
+ with role{" "}
418
+ <span className="font-medium text-foreground">
419
+ {activeMembership?.role ?? activeTenant.role}
420
+ </span>
421
+ .
422
+ </p>
423
+ </div>
424
+
425
+ <div className="space-y-2">
426
+ <label
427
+ className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
428
+ htmlFor="tenant-context"
429
+ >
430
+ Active tenant
431
+ </label>
432
+ <select
433
+ id="tenant-context"
434
+ className="flex h-12 w-full rounded-xl border border-border bg-background px-4 text-sm text-foreground outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40"
435
+ value={selectedTenantId}
436
+ onChange={(event) => {
437
+ setSelectedTenantId(event.target.value);
438
+ setTenantError("");
439
+ setTenantNotice("");
440
+ }}
441
+ disabled={tenantPending}
442
+ >
443
+ {session.memberships.map((membership) => (
444
+ <option key={membership.tenant_id} value={membership.tenant_id}>
445
+ {membership.tenant_name} ({membership.tenant_slug})
446
+ </option>
447
+ ))}
448
+ </select>
449
+ </div>
450
+
451
+ {pendingMembership ? (
452
+ <PanelFrame tone="raised" radius="xl" padding="md" className="space-y-2">
453
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
454
+ Selected membership
455
+ </p>
456
+ <p className="text-sm font-semibold text-foreground">
457
+ {pendingMembership.tenant_name}
458
+ </p>
459
+ <p className="text-sm text-muted-foreground">
460
+ {pendingMembership.tenant_slug} · {pendingMembership.role}
461
+ </p>
462
+ </PanelFrame>
463
+ ) : null}
464
+
465
+ <div className="flex flex-wrap gap-3">
466
+ <Button
467
+ type="button"
468
+ className="gap-2"
469
+ onClick={handleTenantSwitch}
470
+ disabled={tenantPending || selectedTenantId === session.active_tenant_id}
471
+ >
472
+ {tenantPending ? (
473
+ <Loader2 className="size-4 animate-spin" />
474
+ ) : (
475
+ <Building2 className="size-4" />
476
+ )}
477
+ Apply tenant
478
+ </Button>
479
+ <Button
480
+ type="button"
481
+ variant="outline"
482
+ className="gap-2"
483
+ onClick={handleTenantReset}
484
+ disabled={tenantPending}
485
+ >
486
+ <RefreshCcw className="size-4" />
487
+ Reset context
488
+ </Button>
489
+ </div>
490
+
491
+ {tenantError ? <p className="text-sm text-destructive">{tenantError}</p> : null}
492
+ {tenantNotice ? <p className="text-sm text-primary">{tenantNotice}</p> : null}
493
+ </PanelFrame>
494
+
495
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="space-y-5">
496
+ <div className="space-y-2">
497
+ <div className="flex items-center gap-2">
498
+ <ShieldCheck className="size-4 text-primary" />
499
+ <p className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
500
+ Password settings
501
+ </p>
502
+ </div>
503
+ <h2 className="text-2xl font-semibold tracking-tight">
504
+ Account security
505
+ </h2>
506
+ <p className="text-sm leading-6 text-muted-foreground">
507
+ Password status and updates are proxied through the same
508
+ server-owned BFF as login, logout, and session restore.
509
+ </p>
510
+ </div>
511
+
512
+ {passwordLoading ? (
513
+ <PanelFrame tone="raised" radius="xl" padding="lg" className="space-y-2">
514
+ <div className="flex items-center gap-2">
515
+ <Loader2 className="size-4 animate-spin text-primary" />
516
+ <p className="text-sm font-semibold text-foreground">
517
+ Loading password status
518
+ </p>
519
+ </div>
520
+ </PanelFrame>
521
+ ) : (
522
+ <form className="space-y-4" onSubmit={handlePasswordSubmit}>
523
+ <div className="flex flex-wrap gap-3">
524
+ <StatusBadge tone={passwordStatus?.has_password ? "success" : "info"}>
525
+ {passwordStatus?.has_password
526
+ ? "Password enabled"
527
+ : "Password missing"}
528
+ </StatusBadge>
529
+ <StatusBadge tone="default">Platform session</StatusBadge>
530
+ </div>
531
+
532
+ {passwordStatus?.has_password ? (
533
+ <div className="space-y-2">
534
+ <label
535
+ className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
536
+ htmlFor="current-password"
537
+ >
538
+ Current password
539
+ </label>
540
+ <Input
541
+ id="current-password"
542
+ type="password"
543
+ autoComplete="current-password"
544
+ className="h-12"
545
+ value={currentPassword}
546
+ onChange={(event) => {
547
+ setCurrentPassword(event.target.value);
548
+ setPasswordError("");
549
+ setPasswordNotice("");
550
+ }}
551
+ />
552
+ </div>
553
+ ) : null}
554
+
555
+ <div className="space-y-2">
556
+ <label
557
+ className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
558
+ htmlFor="new-password"
559
+ >
560
+ New password
561
+ </label>
562
+ <Input
563
+ id="new-password"
564
+ type="password"
565
+ autoComplete="new-password"
566
+ className="h-12"
567
+ value={newPassword}
568
+ onChange={(event) => {
569
+ setNewPassword(event.target.value);
570
+ setPasswordError("");
571
+ setPasswordNotice("");
572
+ }}
573
+ />
574
+ </div>
575
+
576
+ <div className="space-y-2">
577
+ <label
578
+ className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
579
+ htmlFor="confirm-password"
580
+ >
581
+ Confirm password
582
+ </label>
583
+ <Input
584
+ id="confirm-password"
585
+ type="password"
586
+ autoComplete="new-password"
587
+ className="h-12"
588
+ value={confirmPassword}
589
+ onChange={(event) => {
590
+ setConfirmPassword(event.target.value);
591
+ setPasswordError("");
592
+ setPasswordNotice("");
593
+ }}
594
+ />
595
+ </div>
596
+
597
+ {passwordError ? (
598
+ <p className="text-sm text-destructive">{passwordError}</p>
599
+ ) : null}
600
+ {passwordNotice ? (
601
+ <p className="text-sm text-primary">{passwordNotice}</p>
602
+ ) : null}
603
+
604
+ <Button
605
+ type="submit"
606
+ className="gap-2"
607
+ disabled={passwordPending || passwordLoading}
608
+ >
609
+ {passwordPending ? (
610
+ <Loader2 className="size-4 animate-spin" />
611
+ ) : (
612
+ <KeyRound className="size-4" />
613
+ )}
614
+ {passwordStatus?.has_password ? "Update password" : "Create password"}
615
+ </Button>
616
+ </form>
617
+ )}
618
+ </PanelFrame>
619
+ </div>
620
+ </section>
621
+ </div>
622
+ </main>
623
+ );
624
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { appRoutes } from "@/lib/app-routes";
4
+
5
+ describe("appRoutes", () => {
6
+ it("builds a blog route from a single slug segment", () => {
7
+ expect(appRoutes.blogPost("launching-the-combined-web-starter")).toBe(
8
+ "/blog/launching-the-combined-web-starter",
9
+ );
10
+ expect(appRoutes.blogPost(["launching-the-combined-web-starter"])).toBe(
11
+ "/blog/launching-the-combined-web-starter",
12
+ );
13
+ });
14
+
15
+ it("rejects multi-segment blog slugs instead of truncating them", () => {
16
+ expect(() => appRoutes.blogPost(["release", "v1"])).toThrow(
17
+ "Blog routes require exactly one slug segment.",
18
+ );
19
+ });
20
+ });
@@ -0,0 +1,59 @@
1
+ import type { Route } from "next";
2
+
3
+ function joinRouteSegments(segments: readonly string[]) {
4
+ return segments.map((segment) => encodeURIComponent(segment)).join("/");
5
+ }
6
+
7
+ function requireSingleRouteSegment(
8
+ slug: string | readonly string[],
9
+ routeLabel: string,
10
+ ) {
11
+ if (typeof slug === "string") {
12
+ const segment = slug.trim();
13
+
14
+ if (segment.length === 0) {
15
+ throw new Error(`${routeLabel} require a non-empty slug segment.`);
16
+ }
17
+
18
+ return segment;
19
+ }
20
+
21
+ if (slug.length !== 1) {
22
+ throw new Error(`${routeLabel} require exactly one slug segment.`);
23
+ }
24
+
25
+ const [segment] = slug;
26
+ const normalizedSegment = segment?.trim();
27
+
28
+ if (!normalizedSegment) {
29
+ throw new Error(`${routeLabel} require a non-empty slug segment.`);
30
+ }
31
+
32
+ return normalizedSegment;
33
+ }
34
+
35
+ const publicHomeRoute: Route = "/";
36
+ const pricingRoute: Route = "/pricing";
37
+ const docsIndexRoute: Route = "/docs";
38
+ const blogIndexRoute: Route = "/blog";
39
+ const loginRoute: Route = "/login";
40
+ const appHomeRoute: Route = "/app";
41
+ const runtimeCommandExampleRoute: Route = "/app/examples/runtime-commands";
42
+
43
+ export const appRoutes = {
44
+ publicHome: publicHomeRoute,
45
+ pricing: pricingRoute,
46
+ docsIndex: docsIndexRoute,
47
+ docsPage(slugParts: readonly string[]) {
48
+ return `/docs/${joinRouteSegments(slugParts)}` as Route;
49
+ },
50
+ blogIndex: blogIndexRoute,
51
+ blogPost(slug: string | readonly string[]) {
52
+ return `/blog/${encodeURIComponent(
53
+ requireSingleRouteSegment(slug, "Blog routes"),
54
+ )}` as Route;
55
+ },
56
+ login: loginRoute,
57
+ appHome: appHomeRoute,
58
+ runtimeCommandExample: runtimeCommandExampleRoute,
59
+ };