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.
- package/EXTERNAL_ALPHA.md +8 -6
- package/README.md +1 -1
- package/assets/claude-local/CLAUDE.md.template +12 -8
- package/assets/templates/mobile-app/package.json +1 -0
- package/assets/templates/mobile-app/src/mw/client.ts +29 -232
- package/assets/templates/mobile-app/src/mw/session.ts +6 -2
- package/assets/templates/next-tenant-app/.env.example +2 -8
- package/assets/templates/next-tenant-app/README.md +4 -0
- package/assets/templates/next-tenant-app/src/app/app/demo/page.tsx +1 -7
- package/assets/templates/next-tenant-app/src/app/app/layout.tsx +9 -1
- package/assets/templates/next-tenant-app/src/features/shell/components/authenticated-app-layout-shell.tsx +181 -0
- package/assets/templates/next-tenant-app/src/features/shell/components/private-app-shell.tsx +3 -113
- package/assets/templates/next-tenant-app/src/mw/mock.test.ts +2 -1
- package/assets/templates/next-tenant-app/src/mw/mock.ts +3 -1
- package/assets/templates/next-tenant-app/tools/template/validate-route-contract.mjs +120 -1
- package/assets/templates/next-tenant-app/tools/template/with-public-site-fixture.mjs +28 -114
- package/assets/templates/next-tenant-app/vitest.config.ts +2 -6
- package/dist/runtime-package.d.ts +2 -1
- package/dist/runtime-package.js +12 -4
- package/dist/runtime-package.js.map +1 -1
- package/dist/workspace-bootstrap.js +2 -5
- package/dist/workspace-bootstrap.js.map +1 -1
- package/package.json +1 -1
- package/vendor/workspace-mcp/types.d.ts +27 -0
- package/assets/templates/mobile-app/src/mw/contracts.ts +0 -79
- 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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
`
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
194
|
-
-
|
|
195
|
-
`
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
type
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
import {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
27
|
+
return digestBase64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
196
28
|
},
|
|
29
|
+
};
|
|
197
30
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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 {
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
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
|
+
}
|