minutework 0.1.34 → 0.1.36

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 (26) hide show
  1. package/EXTERNAL_ALPHA.md +8 -6
  2. package/README.md +1 -1
  3. package/assets/claude-local/CLAUDE.md.template +12 -8
  4. package/assets/templates/mobile-app/package.json +1 -0
  5. package/assets/templates/mobile-app/src/mw/client.ts +29 -232
  6. package/assets/templates/mobile-app/src/mw/session.ts +6 -2
  7. package/assets/templates/next-tenant-app/.env.example +2 -8
  8. package/assets/templates/next-tenant-app/README.md +4 -0
  9. package/assets/templates/next-tenant-app/src/app/app/demo/page.tsx +1 -7
  10. package/assets/templates/next-tenant-app/src/app/app/layout.tsx +9 -1
  11. package/assets/templates/next-tenant-app/src/features/shell/components/authenticated-app-layout-shell.tsx +181 -0
  12. package/assets/templates/next-tenant-app/src/features/shell/components/private-app-shell.tsx +3 -113
  13. package/assets/templates/next-tenant-app/src/mw/mock.test.ts +2 -1
  14. package/assets/templates/next-tenant-app/src/mw/mock.ts +3 -1
  15. package/assets/templates/next-tenant-app/tools/template/validate-route-contract.mjs +120 -1
  16. package/assets/templates/next-tenant-app/tools/template/with-public-site-fixture.mjs +28 -114
  17. package/assets/templates/next-tenant-app/vitest.config.ts +2 -6
  18. package/dist/runtime-package.d.ts +2 -1
  19. package/dist/runtime-package.js +12 -4
  20. package/dist/runtime-package.js.map +1 -1
  21. package/dist/workspace-bootstrap.js +2 -5
  22. package/dist/workspace-bootstrap.js.map +1 -1
  23. package/package.json +1 -1
  24. package/vendor/workspace-mcp/types.d.ts +27 -0
  25. package/assets/templates/mobile-app/src/mw/contracts.ts +0 -79
  26. package/assets/templates/mobile-app/src/mw/endpoints.ts +0 -42
package/EXTERNAL_ALPHA.md CHANGED
@@ -120,14 +120,16 @@ If you run `minutework init` **without** `--starter` in an interactive terminal,
120
120
  The generated `tenant-app/.env.example` defaults to:
121
121
 
122
122
  ```dotenv
123
- MW_CONTENT_API_TOKEN=
124
- MW_PUBLIC_SITE_PROPERTY_KEY=main-site
125
- MW_PUBLIC_SITE_ENV=preview
123
+ NEXT_PUBLIC_MW_APP_ID=tenant.app
124
+ MW_TEMPLATE_APP_NAME=Tenant App
125
+ MW_PUBLIC_BASE_URL=
126
126
  ```
127
127
 
128
- `MW_CONTENT_API_TOKEN` stays server-only. `MW_PUBLIC_SITE_ENV=preview` targets
129
- preview or draft-safe reads, while `MW_PUBLIC_SITE_ENV=live` is publication-safe
130
- and intended for published snapshot delivery.
128
+ `tenant-app` browser auth and manifest calls use same-origin `/_mw` routes
129
+ through `@minutework/web-auth`. Do not add platform content tokens or
130
+ public-site property variables to the SDK-based tenant app; public content
131
+ should come from hosted published releases/snapshots or gateway-approved
132
+ public-content routes over the MinuteWork substrate.
131
133
 
132
134
  If the workspace cannot compile hosted preview release metadata for the linked property, deploy fails closed.
133
135
 
package/README.md CHANGED
@@ -122,7 +122,7 @@ Combined **tenant-app + sidecar** workspaces keep sidecar setup explicit. Root `
122
122
 
123
123
  For direct third-party web hosts such as Vercel, deploy `tenant-app` only. In Vercel, point the project Root Directory at `tenant-app`; the optional sidecar remains a separate Poetry-managed surface.
124
124
 
125
- `link` provisions or resolves the default published-site property for the active tenant and stores that property key in repo-local state. The generated `tenant-app/.env.example` includes the standard site env contract: `MW_CONTENT_API_TOKEN`, `MW_PUBLIC_SITE_PROPERTY_KEY`, and `MW_PUBLIC_SITE_ENV`. It defaults to `MW_PUBLIC_SITE_PROPERTY_KEY=main-site` and `MW_PUBLIC_SITE_ENV=preview`.
125
+ `link` provisions or resolves the default published-site property for the active tenant and stores that property key in repo-local state. The SDK-based `tenant-app/.env.example` contains only the app metadata contract used by the template: `NEXT_PUBLIC_MW_APP_ID`, `MW_TEMPLATE_APP_NAME`, and optional `MW_PUBLIC_BASE_URL`. Browser auth and manifest calls use same-origin `/_mw` routes through `@minutework/web-auth`; do not add platform content tokens or public-site property vars to the tenant app.
126
126
 
127
127
  `deploy --preview` always revalidates and recompiles before submit, prints a local-vs-remote diff, requires confirmation unless `--yes` is passed, polls typed receipts until a terminal state, and persists the last known preview deploy state under `.minutework/deploy/preview/status.json`. This alpha is preview-first; live public delivery remains follow-on.
128
128
 
@@ -6,10 +6,13 @@ smallest surface that fits the request.
6
6
 
7
7
  `tenant-app` is the combined web starter: public site routes at the root plus a
8
8
  private authenticated workspace under `/app`.
9
+ It uses `@minutework/web-auth` through a thin `src/mw/` substrate and
10
+ same-origin `/_mw` routes; do not add per-app auth/gateway BFF routes or
11
+ browser-stored tokens.
9
12
 
10
13
  `mobile` is the standalone Expo/React Native client starter. It talks directly
11
14
  to the platform with native device-flow auth and bearer tokens; it is not a
12
- `tenant-app` BFF cookie client and not a `sidecar`. It ships through the
15
+ `tenant-app` web SDK cookie client and not a `sidecar`. It ships through the
13
16
  developer's own EAS/App Store/Play Store pipeline, not `minutework deploy`.
14
17
 
15
18
  ## Refresh Managed Guidance
@@ -189,13 +192,14 @@ a concrete backend responsibility such as:
189
192
  - `mw.core.site` already includes `config`, `page`, `nav`, `form`, and
190
193
  `submission` schemas before Builder starts authoring.
191
194
  - Author public content against runtime/CMS records and published-web flows, not
192
- against a fixed in-repo marketing template.
193
- - Use `MW_PUBLIC_CONTENT_SOURCE` to select the public content adapter mode.
194
- - `MW_CONTENT_API_TOKEN`, `MW_PUBLIC_SITE_PROPERTY_KEY`, and
195
- `MW_PUBLIC_SITE_ENV` are required only for `MW_PUBLIC_CONTENT_SOURCE=minutework_cms`.
196
- - `MW_STATIC_PUBLIC_CONTENT_PATH` is used for `MW_PUBLIC_CONTENT_SOURCE=static_json`.
197
- - `MW_PUBLIC_SITE_ENV=preview` is for preview or draft-safe reads.
198
- - `MW_PUBLIC_SITE_ENV=live` is for publication-safe delivery.
195
+ against a fixed in-repo marketing template or a server-only content-token
196
+ adapter in `tenant-app`.
197
+ - The SDK-based `tenant-app` public metadata env contract is
198
+ `NEXT_PUBLIC_MW_APP_ID`, `MW_TEMPLATE_APP_NAME`, and optional
199
+ `MW_PUBLIC_BASE_URL`; do not add legacy `MW_CONTENT_API_TOKEN`,
200
+ `MW_PUBLIC_CONTENT_SOURCE`, `MW_PUBLIC_SITE_PROPERTY_KEY`,
201
+ `MW_PUBLIC_SITE_ENV`, or `MW_STATIC_PUBLIC_CONTENT_PATH` variables unless a
202
+ compatibility task explicitly targets that old adapter.
199
203
  - Anonymous live delivery should prefer published snapshots instead of direct
200
204
  runtime reads on every request.
201
205
  - `runtime_local_sidecar` is an opt-in public-site serving exception, not the
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@expo/metro-runtime": "~56.0.13",
15
+ "@minutework/native-auth": "^0.1.0",
15
16
  "expo": "~56.0.8",
16
17
  "expo-asset": "~56.0.15",
17
18
  "expo-constants": "~56.0.16",
@@ -1,251 +1,48 @@
1
- // MinuteWork substrate. Thin layer — do not put product UI/logic here.
2
- //
3
- // MinuteWork native token client. Implements the browser-assisted device flow
4
- // against the SHIPPED platform native-token endpoints (`/api/v1/native/session/*`).
5
- //
6
- // Authentication is owned by the MinuteWork platform. This client only *obtains
7
- // and uses* a platform-issued bearer token — there is NO JWT minting, NO password
8
- // handling, NO local user table, and NO parallel auth stack here. Token plaintext
9
- // is never logged.
10
- //
11
- // ----------------------------------------------------------------------------
12
- // Device flow (browser-assisted authorization):
13
- // ----------------------------------------------------------------------------
14
- // 1. authorize(): generate a PKCE code_verifier + S256 code_challenge, open the
15
- // platform authorize URL in a system browser (`expo-web-browser`) with the
16
- // app's deep-link redirect_uri + code_challenge + an anti-forgery `state`.
17
- // The platform authenticates the human and redirects back to redirect_uri
18
- // with a short-lived single-use `code` (and the echoed `state`).
19
- // 2. exchange(code, verifier): POST {code, code_verifier, redirect_uri} to
20
- // `/token-exchange/`; the platform returns the access/refresh token pair.
21
- // 3. store the pair in the device keychain via `mwSession.setTokens(...)`.
22
- // 4. for every platform call, send `Authorization: Bearer <access>`. On a 401,
23
- // POST the stored refresh token to `/refresh/`, persist the rotated pair,
24
- // and retry once.
25
- // 5. logout(): POST `/logout/` to revoke, then `mwSession.clearTokens()`.
26
- //
27
- // This is the DIRECT platform path — there is no tenant-app BFF and no
28
- // server-owned cookie in front of the mobile client.
29
-
30
1
  import * as Crypto from "expo-crypto";
31
2
  import * as Linking from "expo-linking";
32
3
  import * as WebBrowser from "expo-web-browser";
33
-
34
4
  import {
35
- nativeSessionSchema,
36
- nativeTokenPairSchema,
37
- type NativeSession,
38
- type NativeTokenPair,
39
- } from "@/mw/contracts";
40
- import { platformNativeEndpoints } from "@/mw/endpoints";
5
+ createNativeAuthClient,
6
+ type NativeBrowser,
7
+ type NativeCrypto,
8
+ } from "@minutework/native-auth";
9
+
10
+ import { mwEnv } from "@/mw/env";
41
11
  import { mwSession } from "@/mw/session";
42
12
 
43
13
  // The deep-link path the platform redirects back to. The scheme is read from
44
14
  // `app.json` ("scheme") by expo-linking; the dev configures that scheme.
45
15
  const REDIRECT_PATH = "auth/native-callback";
46
16
 
47
- // Result of a single authorize round-trip: the one-time code plus the PKCE
48
- // verifier that must be presented at exchange.
49
- export interface NativeAuthorizationResult {
50
- code: string;
51
- codeVerifier: string;
52
- redirectUri: string;
53
- }
54
-
55
- function base64UrlFromBytes(bytes: Uint8Array): string {
56
- let binary = "";
57
- for (const byte of bytes) {
58
- binary += String.fromCharCode(byte);
59
- }
60
- // btoa is available in the Hermes/React Native runtime.
61
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
62
- }
63
-
64
- // PKCE code_verifier: 32 random bytes -> base64url (43 chars, no padding). This
65
- // is URL-safe and whitespace-free, matching the platform's `.strip()`ed verifier
66
- // and its 255-char cap.
67
- function generateCodeVerifier(): string {
68
- return base64UrlFromBytes(Crypto.getRandomBytes(32));
69
- }
70
-
71
- // S256 challenge: base64url(SHA-256(verifier)). Mirrors the platform's
72
- // `build_pkce_code_challenge` (sha256 digest -> urlsafe base64 -> strip "=").
73
- async function deriveCodeChallenge(codeVerifier: string): Promise<string> {
74
- const digestBase64 = await Crypto.digestStringAsync(
75
- Crypto.CryptoDigestAlgorithm.SHA256,
76
- codeVerifier,
77
- { encoding: Crypto.CryptoEncoding.BASE64 },
78
- );
79
- return digestBase64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
80
- }
81
-
82
- function buildAuthorizeUrl(params: {
83
- redirectUri: string;
84
- state: string;
85
- codeChallenge: string;
86
- }): string {
87
- const url = new URL(platformNativeEndpoints.authorize);
88
- url.searchParams.set("redirect_uri", params.redirectUri);
89
- url.searchParams.set("state", params.state);
90
- url.searchParams.set("code_challenge", params.codeChallenge);
91
- return url.toString();
92
- }
93
-
94
- async function postJson(url: string, body: Record<string, unknown>): Promise<Response> {
95
- return fetch(url, {
96
- method: "POST",
97
- headers: {
98
- "Content-Type": "application/json",
99
- Accept: "application/json",
100
- },
101
- body: JSON.stringify(body),
102
- });
103
- }
104
-
105
- // Surface a platform error without leaking response internals. The platform
106
- // returns `{detail: "..."}` for native auth failures.
107
- async function readErrorDetail(response: Response, fallback: string): Promise<string> {
108
- try {
109
- const data = (await response.json()) as { detail?: unknown };
110
- if (data && typeof data.detail === "string" && data.detail.length > 0) {
111
- return data.detail;
112
- }
113
- } catch {
114
- // ignore non-JSON bodies
115
- }
116
- return `${fallback} (HTTP ${response.status})`;
117
- }
118
-
119
- export const mwClient = {
120
- // Step 1: open the platform authorize URL in a system browser and resolve with
121
- // the single-use authorization code + the PKCE verifier to exchange it.
122
- async authorize(): Promise<NativeAuthorizationResult> {
123
- const redirectUri = Linking.createURL(REDIRECT_PATH);
124
- const codeVerifier = generateCodeVerifier();
125
- const codeChallenge = await deriveCodeChallenge(codeVerifier);
126
- const state = base64UrlFromBytes(Crypto.getRandomBytes(16));
127
-
128
- const authorizeUrl = buildAuthorizeUrl({ redirectUri, state, codeChallenge });
129
- const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, redirectUri);
130
-
131
- if (result.type !== "success" || !result.url) {
132
- throw new Error("Sign in was cancelled before the platform returned a code.");
133
- }
134
-
135
- const returned = new URL(result.url);
136
- const returnedState = returned.searchParams.get("state");
137
- if (returnedState !== state) {
138
- // Anti-forgery: the platform must echo back the exact state we sent.
139
- throw new Error("Sign in failed: callback state did not match the request.");
140
- }
141
- const code = returned.searchParams.get("code");
142
- if (!code) {
143
- throw new Error("Sign in failed: the platform callback did not include a code.");
144
- }
145
-
146
- return { code, codeVerifier, redirectUri };
147
- },
148
-
149
- // Step 2: exchange the authorization code for a platform-issued token pair and
150
- // persist it to the device keychain.
151
- async exchange(
152
- code: string,
153
- codeVerifier: string,
154
- redirectUri: string,
155
- ): Promise<NativeTokenPair> {
156
- const response = await postJson(platformNativeEndpoints.tokenExchange, {
157
- code,
158
- code_verifier: codeVerifier,
159
- redirect_uri: redirectUri,
160
- });
161
- if (!response.ok) {
162
- throw new Error(await readErrorDetail(response, "Token exchange failed"));
163
- }
164
- const pair = nativeTokenPairSchema.parse(await response.json());
165
- await mwSession.setTokens(
166
- pair.access_token,
167
- pair.refresh_token,
168
- pair.access_token_expires_at,
169
- );
170
- return pair;
17
+ const nativeCrypto: NativeCrypto = {
18
+ randomBytes(length: number): Uint8Array {
19
+ return Crypto.getRandomBytes(length);
171
20
  },
172
-
173
- // Step 4 (on 401 / proactive): rotate the access token using the stored refresh
174
- // token and persist the rotated pair.
175
- async refresh(): Promise<NativeTokenPair> {
176
- const stored = await mwSession.getTokens();
177
- if (!stored) {
178
- throw new Error("No stored session to refresh. Sign in again.");
179
- }
180
- const response = await postJson(platformNativeEndpoints.refresh, {
181
- refresh_token: stored.refresh,
182
- });
183
- if (!response.ok) {
184
- // Refresh failure means the session is dead; clear it so the app routes
185
- // back to login.
186
- await mwSession.clearTokens();
187
- throw new Error(await readErrorDetail(response, "Session refresh failed"));
188
- }
189
- const pair = nativeTokenPairSchema.parse(await response.json());
190
- await mwSession.setTokens(
191
- pair.access_token,
192
- pair.refresh_token,
193
- pair.access_token_expires_at,
21
+ async sha256Base64Url(input: string): Promise<string> {
22
+ const digestBase64 = await Crypto.digestStringAsync(
23
+ Crypto.CryptoDigestAlgorithm.SHA256,
24
+ input,
25
+ { encoding: Crypto.CryptoEncoding.BASE64 },
194
26
  );
195
- return pair;
27
+ return digestBase64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
196
28
  },
29
+ };
197
30
 
198
- // Resolve the active session (user + active tenant + memberships) from the
199
- // stored token, auto-refreshing once on a 401 then retrying.
200
- async loadSession(): Promise<NativeSession> {
201
- const stored = await mwSession.getTokens();
202
- if (!stored) {
203
- throw new Error("Not signed in.");
204
- }
205
-
206
- const meOnce = async (accessToken: string): Promise<Response> =>
207
- fetch(platformNativeEndpoints.me, {
208
- method: "GET",
209
- headers: {
210
- Accept: "application/json",
211
- Authorization: `Bearer ${accessToken}`,
212
- },
213
- });
214
-
215
- let response = await meOnce(stored.access);
216
- if (response.status === 401) {
217
- // Single refresh-and-retry on an expired/invalid access token.
218
- const rotated = await this.refresh();
219
- response = await meOnce(rotated.access_token);
220
- }
221
- if (!response.ok) {
222
- throw new Error(await readErrorDetail(response, "Failed to load session"));
223
- }
224
- return nativeSessionSchema.parse(await response.json());
31
+ const nativeBrowser: NativeBrowser = {
32
+ createRedirectUri(): string {
33
+ return Linking.createURL(REDIRECT_PATH);
225
34
  },
226
-
227
- // Step 5: revoke the token pair on the platform, then clear local secure
228
- // storage. Local state is always cleared, even if the revoke call fails.
229
- async logout(): Promise<void> {
230
- const stored = await mwSession.getTokens();
231
- try {
232
- if (stored) {
233
- await fetch(platformNativeEndpoints.logout, {
234
- method: "POST",
235
- headers: {
236
- "Content-Type": "application/json",
237
- Accept: "application/json",
238
- Authorization: `Bearer ${stored.access}`,
239
- },
240
- body: JSON.stringify({}),
241
- });
242
- }
243
- } catch {
244
- // Best-effort revoke; never block local sign-out on a network error.
245
- } finally {
246
- await mwSession.clearTokens();
247
- }
35
+ async openAuthSession(url: string, redirectUri: string): Promise<string | null> {
36
+ const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
37
+ return result.type === "success" && result.url ? result.url : null;
248
38
  },
249
39
  };
250
40
 
41
+ export const mwClient = createNativeAuthClient({
42
+ platformBaseUrl: mwEnv.platformBaseUrl,
43
+ storage: mwSession,
44
+ crypto: nativeCrypto,
45
+ browser: nativeBrowser,
46
+ });
47
+
251
48
  export type MwClient = typeof mwClient;
@@ -8,7 +8,11 @@
8
8
 
9
9
  import * as SecureStore from "expo-secure-store";
10
10
 
11
- import { storedTokensSchema, type StoredTokens } from "@/mw/contracts";
11
+ import {
12
+ storedTokensSchema,
13
+ type NativeTokenStorage,
14
+ type StoredTokens,
15
+ } from "@minutework/native-auth";
12
16
 
13
17
  const TOKEN_KEY = "mw.native.token_pair";
14
18
 
@@ -45,6 +49,6 @@ export const mwSession = {
45
49
  async clearTokens(): Promise<void> {
46
50
  await SecureStore.deleteItemAsync(TOKEN_KEY, SECURE_STORE_OPTIONS);
47
51
  },
48
- };
52
+ } satisfies NativeTokenStorage;
49
53
 
50
54
  export type MwSession = typeof mwSession;
@@ -1,9 +1,3 @@
1
- MW_PLATFORM_BASE_URL=http://127.0.0.1:8000
2
- MW_PUBLIC_CONTENT_SOURCE=none
3
- MW_CONTENT_API_TOKEN=
4
- MW_TEMPLATE_APP_NAME=MinuteWork Combined Starter
1
+ NEXT_PUBLIC_MW_APP_ID=tenant.app
2
+ MW_TEMPLATE_APP_NAME=Tenant App
5
3
  MW_PUBLIC_BASE_URL=
6
- MW_PUBLIC_SITE_PROPERTY_KEY=
7
- MW_PUBLIC_SITE_ENV=preview
8
- MW_STATIC_PUBLIC_CONTENT_PATH=content/public-site.json
9
- MW_ENABLE_RUNTIME_COMMAND_EXAMPLE=false
@@ -43,6 +43,10 @@ workaround.
43
43
  - authenticated workspace at `/app`
44
44
  - SDK manifest demo at `/app/demo`
45
45
 
46
+ All `/app/*` routes are wrapped by `src/app/app/layout.tsx`, which renders the
47
+ shared authenticated layout guard. Individual `/app` pages should assume an
48
+ authenticated tenant-customer session instead of reimplementing auth checks.
49
+
46
50
  The template intentionally does not include `src/lib/platform/*`,
47
51
  `src/app/api/auth/*`, `src/app/api/gateway/*`, an operator-console link, a
48
52
  runtime-command demo, or a server-only public-content token adapter.
@@ -5,11 +5,5 @@ export const metadata = {
5
5
  };
6
6
 
7
7
  export default function DemoPage() {
8
- return (
9
- <main className="min-h-screen bg-background text-foreground">
10
- <div className="mx-auto flex min-h-screen max-w-5xl flex-col gap-6 px-6 py-8">
11
- <ManifestDemo />
12
- </div>
13
- </main>
14
- );
8
+ return <ManifestDemo />;
15
9
  }
@@ -1,6 +1,8 @@
1
1
  import type { Metadata } from "next";
2
2
  import type { ReactNode } from "react";
3
3
 
4
+ import { AuthenticatedAppLayoutShell } from "@/features/shell/components/authenticated-app-layout-shell";
5
+
4
6
  export const metadata: Metadata = {
5
7
  robots: {
6
8
  index: false,
@@ -13,5 +15,11 @@ export default function AuthenticatedAppLayout({
13
15
  }: {
14
16
  children: ReactNode;
15
17
  }) {
16
- return children;
18
+ return (
19
+ <AuthenticatedAppLayoutShell
20
+ appName={process.env.MW_TEMPLATE_APP_NAME || "Tenant App"}
21
+ >
22
+ {children}
23
+ </AuthenticatedAppLayoutShell>
24
+ );
17
25
  }
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { startTransition } from "react";
5
+ import Link from "next/link";
6
+ import { useRouter } from "next/navigation";
7
+ import { LayoutDashboard, LogOut, PlaySquare } from "lucide-react";
8
+ import type {
9
+ TenantCustomerMembership,
10
+ TenantCustomerSession,
11
+ } from "@minutework/web-auth";
12
+ import {
13
+ useMinuteWorkAuth,
14
+ useMinuteWorkSession,
15
+ } from "@minutework/web-auth/react";
16
+
17
+ import { PanelFrame } from "@/design-system/patterns/panel-frame";
18
+ import { ThemeModeToggle } from "@/design-system/patterns/theme-mode-toggle";
19
+ import { Button } from "@/design-system/primitives/button";
20
+ import { appRoutes } from "@/lib/app-routes";
21
+
22
+ type AuthenticatedTenantCustomerSession = TenantCustomerSession & {
23
+ customer_membership: TenantCustomerMembership;
24
+ };
25
+
26
+ export function useAuthenticatedTenantCustomerSession(): AuthenticatedTenantCustomerSession {
27
+ const { session } = useMinuteWorkSession();
28
+
29
+ if (!session?.customer_membership) {
30
+ throw new Error(
31
+ "useAuthenticatedTenantCustomerSession must be rendered below AuthenticatedAppLayoutShell.",
32
+ );
33
+ }
34
+
35
+ return session as AuthenticatedTenantCustomerSession;
36
+ }
37
+
38
+ export function AuthenticatedAppLayoutShell({
39
+ appName,
40
+ children,
41
+ }: {
42
+ appName: string;
43
+ children: ReactNode;
44
+ }) {
45
+ const router = useRouter();
46
+ const { logout } = useMinuteWorkAuth();
47
+ const { session, loading, authenticated, emailVerificationRequired } =
48
+ useMinuteWorkSession();
49
+
50
+ function redirectToLogin() {
51
+ startTransition(() => {
52
+ router.replace(appRoutes.login);
53
+ router.refresh();
54
+ });
55
+ }
56
+
57
+ async function handleLogout() {
58
+ await logout().catch(() => undefined);
59
+ redirectToLogin();
60
+ }
61
+
62
+ if (loading) {
63
+ return (
64
+ <main className="min-h-screen bg-background text-foreground">
65
+ <div className="mx-auto flex min-h-screen max-w-5xl items-center px-6 py-10">
66
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="w-full">
67
+ <p className="text-sm text-muted-foreground">Loading session</p>
68
+ </PanelFrame>
69
+ </div>
70
+ </main>
71
+ );
72
+ }
73
+
74
+ if (
75
+ emailVerificationRequired ||
76
+ session?.email_verification_required ||
77
+ session?.customer_membership?.status === "pending_verification"
78
+ ) {
79
+ return (
80
+ <main className="min-h-screen bg-background text-foreground">
81
+ <div className="mx-auto flex min-h-screen max-w-5xl items-center px-6 py-10">
82
+ <PanelFrame
83
+ tone="floating"
84
+ radius="xl"
85
+ padding="lg"
86
+ className="w-full space-y-4"
87
+ >
88
+ <div className="space-y-2">
89
+ <h1 className="text-3xl font-semibold tracking-tight">
90
+ Email verification required
91
+ </h1>
92
+ <p className="text-sm leading-7 text-muted-foreground">
93
+ Finish verification from your email before opening the workspace.
94
+ </p>
95
+ </div>
96
+ <Button onClick={redirectToLogin} className="w-fit">
97
+ Open login
98
+ </Button>
99
+ </PanelFrame>
100
+ </div>
101
+ </main>
102
+ );
103
+ }
104
+
105
+ if (!authenticated || !session?.customer_membership) {
106
+ return (
107
+ <main className="min-h-screen bg-background text-foreground">
108
+ <div className="mx-auto flex min-h-screen max-w-5xl items-center px-6 py-10">
109
+ <PanelFrame
110
+ tone="floating"
111
+ radius="xl"
112
+ padding="lg"
113
+ className="w-full space-y-4"
114
+ >
115
+ <div className="space-y-2">
116
+ <h1 className="text-3xl font-semibold tracking-tight">
117
+ Log in to continue
118
+ </h1>
119
+ <p className="text-sm leading-7 text-muted-foreground">
120
+ This area is available to verified customers.
121
+ </p>
122
+ </div>
123
+ <Button onClick={redirectToLogin} className="w-fit">
124
+ Open login
125
+ </Button>
126
+ </PanelFrame>
127
+ </div>
128
+ </main>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <main className="min-h-screen bg-background text-foreground">
134
+ <div className="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-6 py-8">
135
+ <header className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
136
+ <div className="space-y-2">
137
+ <p className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
138
+ {session.tenant.name}
139
+ </p>
140
+ <h1 className="max-w-3xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
141
+ {appName}
142
+ </h1>
143
+ <p className="max-w-3xl text-base leading-7 text-muted-foreground sm:text-lg">
144
+ Signed in as {session.user?.email || session.user?.username}.
145
+ </p>
146
+ </div>
147
+
148
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
149
+ <ThemeModeToggle className="w-full sm:w-72" />
150
+ <Button
151
+ type="button"
152
+ variant="outline"
153
+ className="gap-2"
154
+ onClick={handleLogout}
155
+ >
156
+ <LogOut className="size-4" />
157
+ Log out
158
+ </Button>
159
+ </div>
160
+ </header>
161
+
162
+ <nav className="flex flex-wrap gap-2">
163
+ <Button asChild variant="default">
164
+ <Link href={appRoutes.appHome}>
165
+ <LayoutDashboard className="size-4" />
166
+ Dashboard
167
+ </Link>
168
+ </Button>
169
+ <Button asChild variant="outline">
170
+ <Link href={appRoutes.demo}>
171
+ <PlaySquare className="size-4" />
172
+ Demo
173
+ </Link>
174
+ </Button>
175
+ </nav>
176
+
177
+ {children}
178
+ </div>
179
+ </main>
180
+ );
181
+ }