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.
- package/EXTERNAL_ALPHA.md +74 -0
- package/README.md +57 -0
- package/assets/claude-local/CLAUDE.md.template +45 -0
- package/assets/claude-local/bundle.json +22 -0
- package/assets/claude-local/skills/README.md +6 -0
- package/assets/claude-local/skills/app-pack-authoring.md +8 -0
- package/assets/claude-local/skills/event-bus.md +8 -0
- package/assets/claude-local/skills/ontology-mapping.md +8 -0
- package/assets/claude-local/skills/openclaw-skill-importer.md +7 -0
- package/assets/claude-local/skills/schema-engine.md +8 -0
- package/assets/claude-local/skills/secrets-runtime-bridge.md +9 -0
- package/assets/claude-local/skills/sidecar-generation.md +9 -0
- package/assets/templates/fastapi-sidecar/.env.example +8 -0
- package/assets/templates/fastapi-sidecar/README.md +77 -0
- package/assets/templates/fastapi-sidecar/poetry.lock +757 -0
- package/assets/templates/fastapi-sidecar/pyproject.toml +42 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/__init__.py +3 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/auth.py +70 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/bridge/__init__.py +3 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/bridge/client.py +71 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/logging_utils.py +25 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/main.py +85 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/receipts.py +24 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/settings.py +41 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/template_validation.py +26 -0
- package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/worker.py +33 -0
- package/assets/templates/fastapi-sidecar/template.json +43 -0
- package/assets/templates/fastapi-sidecar/template.schema.json +160 -0
- package/assets/templates/fastapi-sidecar/tests/conftest.py +36 -0
- package/assets/templates/fastapi-sidecar/tests/test_app.py +39 -0
- package/assets/templates/fastapi-sidecar/tests/test_auth.py +32 -0
- package/assets/templates/fastapi-sidecar/tests/test_bridge_client.py +31 -0
- package/assets/templates/fastapi-sidecar/tests/test_materialization.py +55 -0
- package/assets/templates/fastapi-sidecar/tests/test_template_contract.py +49 -0
- package/assets/templates/fastapi-sidecar/tests/test_worker.py +7 -0
- package/assets/templates/fastapi-sidecar/tools/template/validate_template.py +20 -0
- package/assets/templates/next-tenant-app/.env.example +8 -0
- package/assets/templates/next-tenant-app/.storybook/main.ts +19 -0
- package/assets/templates/next-tenant-app/.storybook/preview.tsx +38 -0
- package/assets/templates/next-tenant-app/README.md +115 -0
- package/assets/templates/next-tenant-app/components.json +21 -0
- package/assets/templates/next-tenant-app/eslint.config.mjs +41 -0
- package/assets/templates/next-tenant-app/next-env.d.ts +6 -0
- package/assets/templates/next-tenant-app/next.config.ts +8 -0
- package/assets/templates/next-tenant-app/package-lock.json +9682 -0
- package/assets/templates/next-tenant-app/package.json +59 -0
- package/assets/templates/next-tenant-app/pnpm-lock.yaml +6062 -0
- package/assets/templates/next-tenant-app/postcss.config.mjs +8 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/context/route.test.ts +90 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/context/route.ts +78 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/login/route.ts +31 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/logout/route.ts +16 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/password-change/route.test.ts +79 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/password-change/route.ts +40 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/password-status/route.test.ts +42 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/password-status/route.ts +29 -0
- package/assets/templates/next-tenant-app/src/app/api/auth/session/route.ts +26 -0
- package/assets/templates/next-tenant-app/src/app/api/gateway/commands/[runId]/route.test.ts +40 -0
- package/assets/templates/next-tenant-app/src/app/api/gateway/commands/[runId]/route.ts +47 -0
- package/assets/templates/next-tenant-app/src/app/api/gateway/commands/route.test.ts +43 -0
- package/assets/templates/next-tenant-app/src/app/api/gateway/commands/route.ts +45 -0
- package/assets/templates/next-tenant-app/src/app/app/examples/runtime-commands/page.test.ts +83 -0
- package/assets/templates/next-tenant-app/src/app/app/examples/runtime-commands/page.tsx +30 -0
- package/assets/templates/next-tenant-app/src/app/app/layout.tsx +20 -0
- package/assets/templates/next-tenant-app/src/app/app/page.test.ts +62 -0
- package/assets/templates/next-tenant-app/src/app/app/page.tsx +24 -0
- package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.test.ts +70 -0
- package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.tsx +57 -0
- package/assets/templates/next-tenant-app/src/app/blog/page.test.ts +42 -0
- package/assets/templates/next-tenant-app/src/app/blog/page.tsx +37 -0
- package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.test.ts +70 -0
- package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.tsx +55 -0
- package/assets/templates/next-tenant-app/src/app/docs/page.test.ts +42 -0
- package/assets/templates/next-tenant-app/src/app/docs/page.tsx +37 -0
- package/assets/templates/next-tenant-app/src/app/globals.css +70 -0
- package/assets/templates/next-tenant-app/src/app/layout.tsx +69 -0
- package/assets/templates/next-tenant-app/src/app/login/page.test.ts +55 -0
- package/assets/templates/next-tenant-app/src/app/login/page.tsx +33 -0
- package/assets/templates/next-tenant-app/src/app/page.test.ts +56 -0
- package/assets/templates/next-tenant-app/src/app/page.tsx +35 -0
- package/assets/templates/next-tenant-app/src/app/pricing/page.test.ts +55 -0
- package/assets/templates/next-tenant-app/src/app/pricing/page.tsx +35 -0
- package/assets/templates/next-tenant-app/src/app/providers.tsx +25 -0
- package/assets/templates/next-tenant-app/src/app/robots.test.ts +20 -0
- package/assets/templates/next-tenant-app/src/app/robots.ts +18 -0
- package/assets/templates/next-tenant-app/src/app/sitemap.test.ts +49 -0
- package/assets/templates/next-tenant-app/src/app/sitemap.ts +54 -0
- package/assets/templates/next-tenant-app/src/components/ui/button.tsx +59 -0
- package/assets/templates/next-tenant-app/src/components/ui/input.tsx +21 -0
- package/assets/templates/next-tenant-app/src/design-system/docs/governance.mdx +26 -0
- package/assets/templates/next-tenant-app/src/design-system/patterns/panel-frame.stories.tsx +48 -0
- package/assets/templates/next-tenant-app/src/design-system/patterns/panel-frame.tsx +26 -0
- package/assets/templates/next-tenant-app/src/design-system/patterns/status-badge.stories.tsx +26 -0
- package/assets/templates/next-tenant-app/src/design-system/patterns/status-badge.tsx +35 -0
- package/assets/templates/next-tenant-app/src/design-system/patterns/theme-mode-toggle.stories.tsx +21 -0
- package/assets/templates/next-tenant-app/src/design-system/patterns/theme-mode-toggle.tsx +75 -0
- package/assets/templates/next-tenant-app/src/design-system/primitives/button.stories.tsx +37 -0
- package/assets/templates/next-tenant-app/src/design-system/primitives/button.ts +1 -0
- package/assets/templates/next-tenant-app/src/design-system/primitives/input.stories.tsx +26 -0
- package/assets/templates/next-tenant-app/src/design-system/primitives/input.ts +1 -0
- package/assets/templates/next-tenant-app/src/design-system/recipes/chrome.ts +28 -0
- package/assets/templates/next-tenant-app/src/design-system/tokens/foundation.css +31 -0
- package/assets/templates/next-tenant-app/src/design-system/tokens/index.css +3 -0
- package/assets/templates/next-tenant-app/src/design-system/tokens/manifest.json +85 -0
- package/assets/templates/next-tenant-app/src/design-system/tokens/manifest.ts +87 -0
- package/assets/templates/next-tenant-app/src/design-system/tokens/semantic.css +105 -0
- package/assets/templates/next-tenant-app/src/design-system/tokens/theme.css +59 -0
- package/assets/templates/next-tenant-app/src/design-system/tokens/tokens.stories.tsx +71 -0
- package/assets/templates/next-tenant-app/src/features/auth/components/login-screen.tsx +198 -0
- package/assets/templates/next-tenant-app/src/features/dashboard/components/tenant-dashboard.tsx +153 -0
- package/assets/templates/next-tenant-app/src/features/examples/runtime-command-demo/components/runtime-command-demo.tsx +342 -0
- package/assets/templates/next-tenant-app/src/features/public-shell/components/content-article.tsx +66 -0
- package/assets/templates/next-tenant-app/src/features/public-shell/components/content-collection.tsx +108 -0
- package/assets/templates/next-tenant-app/src/features/public-shell/components/marketing-page-canvas.tsx +111 -0
- package/assets/templates/next-tenant-app/src/features/public-shell/components/public-site-shell.tsx +111 -0
- package/assets/templates/next-tenant-app/src/features/shell/components/private-app-shell.tsx +624 -0
- package/assets/templates/next-tenant-app/src/lib/app-routes.test.ts +20 -0
- package/assets/templates/next-tenant-app/src/lib/app-routes.ts +59 -0
- package/assets/templates/next-tenant-app/src/lib/content/__fixtures__/public-site-snapshot.ts +189 -0
- package/assets/templates/next-tenant-app/src/lib/content/adapter.server.test.ts +318 -0
- package/assets/templates/next-tenant-app/src/lib/content/adapter.server.ts +232 -0
- package/assets/templates/next-tenant-app/src/lib/content/contracts.ts +339 -0
- package/assets/templates/next-tenant-app/src/lib/content/custom-adapter.ts +5 -0
- package/assets/templates/next-tenant-app/src/lib/content/empty-state.ts +96 -0
- package/assets/templates/next-tenant-app/src/lib/platform/auth.server.test.ts +75 -0
- package/assets/templates/next-tenant-app/src/lib/platform/auth.server.ts +25 -0
- package/assets/templates/next-tenant-app/src/lib/platform/client.server.test.ts +170 -0
- package/assets/templates/next-tenant-app/src/lib/platform/client.server.ts +661 -0
- package/assets/templates/next-tenant-app/src/lib/platform/contracts.ts +131 -0
- package/assets/templates/next-tenant-app/src/lib/platform/endpoints.server.ts +34 -0
- package/assets/templates/next-tenant-app/src/lib/platform/env.server.test.ts +102 -0
- package/assets/templates/next-tenant-app/src/lib/platform/env.server.ts +87 -0
- package/assets/templates/next-tenant-app/src/lib/platform/route-response.ts +33 -0
- package/assets/templates/next-tenant-app/src/lib/platform/session.server.ts +108 -0
- package/assets/templates/next-tenant-app/src/lib/public-site.test.ts +20 -0
- package/assets/templates/next-tenant-app/src/lib/public-site.ts +49 -0
- package/assets/templates/next-tenant-app/src/lib/theme-config.ts +10 -0
- package/assets/templates/next-tenant-app/src/lib/theme.tsx +159 -0
- package/assets/templates/next-tenant-app/src/lib/utils.ts +6 -0
- package/assets/templates/next-tenant-app/template.json +27 -0
- package/assets/templates/next-tenant-app/template.schema.json +160 -0
- package/assets/templates/next-tenant-app/test/server-only-stub.ts +1 -0
- package/assets/templates/next-tenant-app/tools/design-system/build-token-manifest.mjs +3 -0
- package/assets/templates/next-tenant-app/tools/design-system/check-imports.mjs +9 -0
- package/assets/templates/next-tenant-app/tools/design-system/check-stories.mjs +9 -0
- package/assets/templates/next-tenant-app/tools/design-system/check-values.mjs +9 -0
- package/assets/templates/next-tenant-app/tools/design-system/checks.mjs +238 -0
- package/assets/templates/next-tenant-app/tools/design-system/eslint-plugin-design-system.mjs +184 -0
- package/assets/templates/next-tenant-app/tools/design-system/playwright.config.mjs +34 -0
- package/assets/templates/next-tenant-app/tools/design-system/run-checks.mjs +22 -0
- package/assets/templates/next-tenant-app/tools/design-system/shared.mjs +166 -0
- package/assets/templates/next-tenant-app/tools/design-system/visual.spec.ts +41 -0
- package/assets/templates/next-tenant-app/tools/template/validate-route-contract.mjs +39 -0
- package/assets/templates/next-tenant-app/tools/template/validate-template.mjs +45 -0
- package/assets/templates/next-tenant-app/tsconfig.json +42 -0
- package/assets/templates/next-tenant-app/vitest.config.ts +25 -0
- package/bin/minutework.js +40 -0
- package/dist/auth.d.ts +59 -0
- package/dist/auth.js +338 -0
- package/dist/auth.js.map +1 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +26 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +20 -0
- package/dist/compile.js +121 -0
- package/dist/compile.js.map +1 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +102 -0
- package/dist/config.js.map +1 -0
- package/dist/deploy-state.d.ts +35 -0
- package/dist/deploy-state.js +30 -0
- package/dist/deploy-state.js.map +1 -0
- package/dist/deploy.d.ts +22 -0
- package/dist/deploy.js +308 -0
- package/dist/deploy.js.map +1 -0
- package/dist/developer-client.d.ts +88 -0
- package/dist/developer-client.js +78 -0
- package/dist/developer-client.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +290 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +22 -0
- package/dist/init.js +421 -0
- package/dist/init.js.map +1 -0
- package/dist/launcher.d.ts +1 -0
- package/dist/launcher.js +50 -0
- package/dist/launcher.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.js +33 -0
- package/dist/paths.js.map +1 -0
- package/dist/sandbox.d.ts +30 -0
- package/dist/sandbox.js +852 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/state.d.ts +46 -0
- package/dist/state.js +82 -0
- package/dist/state.js.map +1 -0
- package/dist/tokens.d.ts +14 -0
- package/dist/tokens.js +293 -0
- package/dist/tokens.js.map +1 -0
- 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
|
+
};
|