minutework 0.1.37 → 0.1.38
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/assets/claude-local/CLAUDE.md.template +11 -0
- package/assets/claude-local/skills/README.md +4 -0
- package/assets/claude-local/skills/app-pack-authoring/SKILL.md +14 -0
- package/assets/claude-local/skills/generated-workspace-architecture/SKILL.md +14 -0
- package/assets/claude-local/skills/standalone-mobile-client/SKILL.md +16 -0
- package/assets/templates/mobile-app/.env.example +5 -0
- package/assets/templates/mobile-app/AGENTS.md +11 -5
- package/assets/templates/mobile-app/README.md +26 -9
- package/assets/templates/mobile-app/app/(auth)/login.tsx +108 -14
- package/assets/templates/mobile-app/src/mw/client.ts +19 -1
- package/assets/templates/mobile-app/src/mw/env.ts +6 -2
- package/assets/templates/mobile-app/template.json +1 -1
- package/assets/templates/next-tenant-app/README.md +7 -0
- package/assets/templates/next-tenant-app/package.json +1 -1
- package/assets/templates/next-tenant-app/src/features/auth/components/login-screen.tsx +3 -3
- package/assets/templates/next-tenant-app/src/features/shell/components/authenticated-app-layout-shell.tsx +4 -9
- package/package.json +1 -1
|
@@ -9,11 +9,22 @@ private authenticated workspace under `/app`.
|
|
|
9
9
|
It uses `@minutework/web-auth` through a thin `src/mw/` substrate and
|
|
10
10
|
same-origin `/_mw` routes; do not add per-app auth/gateway BFF routes or
|
|
11
11
|
browser-stored tokens.
|
|
12
|
+
For web password auth ergonomics, call `signUp` / `signInWithPassword` from
|
|
13
|
+
`@minutework/web-auth`; these are cookie-backed web aliases and do not return
|
|
14
|
+
access or refresh tokens. Use `useMinuteWorkSession().status` for guards with
|
|
15
|
+
this precedence: `loading` -> `verification_required` -> `authenticated` ->
|
|
16
|
+
`unauthenticated`.
|
|
12
17
|
|
|
13
18
|
`mobile` is the standalone Expo/React Native client starter. It talks directly
|
|
14
19
|
to the platform with native device-flow auth and bearer tokens; it is not a
|
|
15
20
|
`tenant-app` web SDK cookie client and not a `sidecar`. It ships through the
|
|
16
21
|
developer's own EAS/App Store/Play Store pipeline, not `minutework deploy`.
|
|
22
|
+
Mobile may use `@minutework/native-auth` `signInWithPassword`, but that method
|
|
23
|
+
returns/stores a native access/refresh token pair. Set
|
|
24
|
+
`EXPO_PUBLIC_MW_TENANT_ID` to the non-secret tenant UUID so native password and
|
|
25
|
+
browser-assisted flows send tenant context. The same method name in
|
|
26
|
+
`@minutework/web-auth` is same-origin cookie auth for tenant web and does not
|
|
27
|
+
return tokens.
|
|
17
28
|
|
|
18
29
|
## Refresh Managed Guidance
|
|
19
30
|
|
|
@@ -17,6 +17,10 @@ receive:
|
|
|
17
17
|
- `tenant-app` is the combined public plus private web starter
|
|
18
18
|
- `tenant-app` auth/data uses `@minutework/web-auth` and thin `src/mw/`
|
|
19
19
|
substrate, not per-app auth/gateway BFF routes
|
|
20
|
+
- `tenant-app` uses cookie-backed `signUp` / `signInWithPassword` aliases and
|
|
21
|
+
`useMinuteWorkSession().status`; mobile uses `@minutework/native-auth`
|
|
22
|
+
bearer-token storage for its same-named password method and sends the
|
|
23
|
+
configured `EXPO_PUBLIC_MW_TENANT_ID` tenant context
|
|
20
24
|
- `mw.core.site` is a runtime baseline capability
|
|
21
25
|
- tenant public authoring is runtime/CMS-backed through `mw.core.site`
|
|
22
26
|
- Vuilder-owned public sites use `vuilder-public-site` and public-dj CMS
|
|
@@ -27,6 +27,14 @@ An `app pack` is the shipped product unit.
|
|
|
27
27
|
- The template may render a local `/login` page that calls SDK hooks. The SDK
|
|
28
28
|
also exposes hosted UI helpers for same-origin `/_mw/login`,
|
|
29
29
|
`/_mw/signup`, and `/_mw/verify-email`; both choices remain SDK-only.
|
|
30
|
+
- For Supabase-like naming, web product UI may call
|
|
31
|
+
`signUp` / `signInWithPassword` from `@minutework/web-auth`. In `tenant-app`
|
|
32
|
+
these aliases remain cookie-backed web customer auth and do not return
|
|
33
|
+
bearer tokens.
|
|
34
|
+
- Use `useMinuteWorkSession().status` for web guards instead of hand-rolled
|
|
35
|
+
`loading && !authenticated` branches. Status precedence is `loading` ->
|
|
36
|
+
`verification_required` -> `authenticated` -> `unauthenticated`; keep
|
|
37
|
+
request errors separate from status.
|
|
30
38
|
- Client-side `/app` session checks are UX gating only. Real authorization is
|
|
31
39
|
enforced server-side by platform `/_mw` routes and runtime dispatch checks:
|
|
32
40
|
active customer membership, email verification, app publication, and
|
|
@@ -39,6 +47,12 @@ An `app pack` is the shipped product unit.
|
|
|
39
47
|
- The authenticated `tenant-app` principal is `tenant_customer`. Do not model
|
|
40
48
|
the generated customer app as an operator/platform-session app, and do not
|
|
41
49
|
persist bearer/session/runtime tokens in browser JavaScript.
|
|
50
|
+
- Native/mobile password auth belongs in `@minutework/native-auth`, not
|
|
51
|
+
`tenant-app`. Its `signInWithPassword(...)` method shares the web alias name
|
|
52
|
+
but returns/stores a native bearer access/refresh token pair for the platform
|
|
53
|
+
native API. Configure `EXPO_PUBLIC_MW_TENANT_ID` with the non-secret tenant
|
|
54
|
+
UUID so native password and browser-assisted flows send explicit tenant
|
|
55
|
+
context.
|
|
42
56
|
- Use `sidecar` for internal APIs, workers, integrations, or Python-heavy compute.
|
|
43
57
|
- For member-facing collaboration and operator workflows, check shell fit first
|
|
44
58
|
before proposing standalone `tenant-app` routes or dashboards.
|
|
@@ -32,6 +32,13 @@ the monorepo or a live tenant runtime.
|
|
|
32
32
|
- A local `/login` page may call SDK hooks, while SDK hosted UI helpers point
|
|
33
33
|
to same-origin `/_mw/login`, `/_mw/signup`, and `/_mw/verify-email`; both
|
|
34
34
|
are valid only when they stay on the SDK/`/_mw` contract.
|
|
35
|
+
- For Supabase-like naming in `tenant-app`, prefer
|
|
36
|
+
`signUp` / `signInWithPassword` from `@minutework/web-auth`. These are web
|
|
37
|
+
aliases over the same cookie-backed customer session; they do not return
|
|
38
|
+
access or refresh tokens.
|
|
39
|
+
- Use `useMinuteWorkSession().status` for web app guards. Its precedence is
|
|
40
|
+
`loading` -> `verification_required` -> `authenticated` ->
|
|
41
|
+
`unauthenticated`; keep `error` as a separate field.
|
|
35
42
|
- Client-side app guards are UX only. Platform `/_mw` routes and runtime
|
|
36
43
|
dispatch enforce active customer membership, email verification, app
|
|
37
44
|
publication, and manifest exposure server-side.
|
|
@@ -45,6 +52,13 @@ the monorepo or a live tenant runtime.
|
|
|
45
52
|
platform-issued native session token, rather than the `tenant-app` web SDK /
|
|
46
53
|
hosted-cookie session. The mobile starter remains npm-owned standalone app
|
|
47
54
|
code and should not be added to the generated root `pnpm-workspace.yaml`.
|
|
55
|
+
- Mobile may call `@minutework/native-auth` `signInWithPassword(...)`, but this
|
|
56
|
+
is native/member password auth that returns and stores a bearer access/refresh
|
|
57
|
+
token pair through the starter's secure storage. Configure
|
|
58
|
+
`EXPO_PUBLIC_MW_TENANT_ID` with the non-secret tenant UUID so native password
|
|
59
|
+
and browser-assisted flows send explicit tenant context. Do not copy
|
|
60
|
+
tenant-app web auth code into mobile, and do not expect the web SDK's
|
|
61
|
+
same-named method to return tokens.
|
|
48
62
|
- If the `mobile` starter is enabled (`starters.mobile.enabled`), read
|
|
49
63
|
`standalone-mobile-client/SKILL.md` before proposing the mobile surface.
|
|
50
64
|
- `vuilder-public-site` is a Vuilder-owned public-site authoring workspace for
|
|
@@ -80,10 +80,26 @@ The native token flow mirrors the existing CLI developer-token flow
|
|
|
80
80
|
freshly returned pair. Re-run the browser-assisted authorize step only when
|
|
81
81
|
the refresh token itself is invalid or revoked.
|
|
82
82
|
|
|
83
|
+
The native SDK also exposes `signInWithPassword({ email, password, tenantId? })`
|
|
84
|
+
for member password sign-in. That method posts credentials to the native
|
|
85
|
+
password-grant endpoint, persists the returned access/refresh pair through the
|
|
86
|
+
starter's secure storage adapter, and returns a `NativeTokenPair`.
|
|
87
|
+
The generated Expo starter wires `tenantId` from `EXPO_PUBLIC_MW_TENANT_ID`, a
|
|
88
|
+
non-secret tenant UUID. Keep that configured and pass it through password and
|
|
89
|
+
browser-assisted authorize flows; tenant membership is still enforced by the
|
|
90
|
+
platform before a token pair is issued.
|
|
91
|
+
|
|
83
92
|
The `tenant-app` web SDK / hosted-cookie path is **web-only**. The mobile app
|
|
84
93
|
does not ride the `tenant-app` cookie jar, and it does not read or forward
|
|
85
94
|
CSRF -- native uses the bearer token directly against the platform.
|
|
86
95
|
|
|
96
|
+
`@minutework/web-auth` and `@minutework/native-auth` intentionally share the
|
|
97
|
+
`signInWithPassword` name for developer ergonomics, but the mechanisms are
|
|
98
|
+
different. Web `signInWithPassword` sets/uses same-origin cookies and returns a
|
|
99
|
+
tenant customer session. Native `signInWithPassword` returns/stores a bearer
|
|
100
|
+
access/refresh token pair. Do not copy code between those surfaces without
|
|
101
|
+
switching SDKs and session expectations.
|
|
102
|
+
|
|
87
103
|
### Token binding (what is and isn't enforced)
|
|
88
104
|
|
|
89
105
|
The token is bound to one tenant + membership, and that binding **is** enforced:
|
|
@@ -14,5 +14,10 @@
|
|
|
14
14
|
# The app calls /api/v1/native/... here.
|
|
15
15
|
EXPO_PUBLIC_MW_PLATFORM_BASE_URL=https://<your-platform-base-url>
|
|
16
16
|
|
|
17
|
+
# Non-secret tenant UUID this native app signs into. Native password sign-in
|
|
18
|
+
# requires an explicit tenant context, and the browser-assisted authorize flow
|
|
19
|
+
# sends this value too.
|
|
20
|
+
EXPO_PUBLIC_MW_TENANT_ID=00000000-0000-0000-0000-000000000000
|
|
21
|
+
|
|
17
22
|
# Optional display name shown in the UI. Defaults to "MinuteWork".
|
|
18
23
|
# EXPO_PUBLIC_MW_APP_NAME=MinuteWork
|
|
@@ -37,11 +37,17 @@ flow).
|
|
|
37
37
|
## Status
|
|
38
38
|
|
|
39
39
|
`src/mw/client.ts` and `src/mw/session.ts` are **implemented**: a real
|
|
40
|
-
browser-assisted PKCE device flow against
|
|
41
|
-
(`/api/v1/native/session/*`), with tokens
|
|
42
|
-
`expo-secure-store`. Wire your UI against
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
native password sign-in method plus a browser-assisted PKCE device flow against
|
|
41
|
+
the platform native session endpoints (`/api/v1/native/session/*`), with tokens
|
|
42
|
+
persisted in the device keychain via `expo-secure-store`. Wire your UI against
|
|
43
|
+
the documented client methods (`signInWithPassword`, `authorize` -> `exchange`,
|
|
44
|
+
`loadSession`, `logout`). `signInWithPassword` here returns/stores a native
|
|
45
|
+
bearer token pair; the same method name in `@minutework/web-auth` is
|
|
46
|
+
cookie-backed web auth and does not return tokens. Set
|
|
47
|
+
`EXPO_PUBLIC_MW_TENANT_ID` to the non-secret tenant UUID; the starter passes it
|
|
48
|
+
as tenant context for both password sign-in and browser-assisted authorize. Set
|
|
49
|
+
`app.json` `expo.scheme` to your own unique scheme — that is the OAuth-style
|
|
50
|
+
redirect target for the browser-assisted flow.
|
|
45
51
|
|
|
46
52
|
The starter is standalone npm-owned app code, even when generated beside
|
|
47
53
|
`tenant-app` in a root pnpm workspace. Run mobile commands from `mobile/` with
|
|
@@ -13,7 +13,7 @@ plain screens use `StyleSheet` only so there is nothing to rip out.
|
|
|
13
13
|
|
|
14
14
|
| Path | Owner | Notes |
|
|
15
15
|
| --- | --- | --- |
|
|
16
|
-
| `src/mw/env.ts` | **MinuteWork substrate** | Validates `EXPO_PUBLIC_MW_PLATFORM_BASE_URL` (+ optional app name) |
|
|
16
|
+
| `src/mw/env.ts` | **MinuteWork substrate** | Validates `EXPO_PUBLIC_MW_PLATFORM_BASE_URL`, `EXPO_PUBLIC_MW_TENANT_ID` (+ optional app name) |
|
|
17
17
|
| `src/mw/endpoints.ts` | **MinuteWork substrate** | Builds platform `/api/v1/native/...` URLs |
|
|
18
18
|
| `src/mw/contracts.ts` | **MinuteWork substrate** | Zod schemas for native session + token payloads |
|
|
19
19
|
| `src/mw/client.ts` | **MinuteWork substrate** | Native token client — real browser-assisted PKCE device flow |
|
|
@@ -43,6 +43,12 @@ This app authenticates as a **direct platform native client**:
|
|
|
43
43
|
`Authorization: Bearer <access>`, and on `401` mints a fresh pair at
|
|
44
44
|
`POST /api/v1/native/session/refresh/` (rotating — persist the new pair) before
|
|
45
45
|
retrying. `POST /api/v1/native/session/logout/` revokes the token family.
|
|
46
|
+
- `mwClient.signInWithPassword({ email, password })` is also available for
|
|
47
|
+
native/member password sign-in. The starter injects
|
|
48
|
+
`EXPO_PUBLIC_MW_TENANT_ID` as `tenantId` because the native password-grant
|
|
49
|
+
endpoint requires explicit tenant context. It stores the returned
|
|
50
|
+
access/refresh pair through `src/mw/session.ts` and returns that native token
|
|
51
|
+
pair.
|
|
46
52
|
|
|
47
53
|
This is **NOT** the Next.js `tenant-app` model. The tenant-app uses a
|
|
48
54
|
server-owned **BFF session cookie** (`platform_session_bff`) because a browser
|
|
@@ -52,6 +58,11 @@ token instead of a cookie. **Do not** try to reuse the BFF cookie path here, and
|
|
|
52
58
|
**do not** build a parallel/local auth stack (no JWT minting, no local user
|
|
53
59
|
table) — the platform owns identity.
|
|
54
60
|
|
|
61
|
+
The web and native SDKs intentionally share the `signInWithPassword` name, but
|
|
62
|
+
the mechanism is different: `@minutework/web-auth` sets/uses a same-origin web
|
|
63
|
+
cookie and returns a tenant customer session; `@minutework/native-auth` stores
|
|
64
|
+
and returns a bearer-token pair for the native platform API.
|
|
65
|
+
|
|
55
66
|
The token is bound to a single tenant + membership, and that binding **is**
|
|
56
67
|
enforced. The optional `audience` and `device_id` fields are **informational
|
|
57
68
|
only**: the platform carries them through the flow but does **not** validate or
|
|
@@ -61,9 +72,13 @@ compare them, so do not rely on them as a security boundary.
|
|
|
61
72
|
|
|
62
73
|
`src/mw/client.ts` and `src/mw/session.ts` are **implemented**. The client:
|
|
63
74
|
|
|
64
|
-
-
|
|
75
|
+
- signs in with member credentials through `signInWithPassword(...)` when you
|
|
76
|
+
choose the native password flow, sending the configured tenant UUID,
|
|
77
|
+
- generates a PKCE `code_verifier` + S256 `code_challenge` (`expo-crypto`) when
|
|
78
|
+
you choose the browser-assisted flow,
|
|
65
79
|
- opens the platform `authorize` URL in a system browser
|
|
66
|
-
(`expo-web-browser`'s `openAuthSessionAsync`) with an anti-forgery `state
|
|
80
|
+
(`expo-web-browser`'s `openAuthSessionAsync`) with an anti-forgery `state`
|
|
81
|
+
and the configured tenant UUID,
|
|
67
82
|
- captures the returned one-time `code` from the deep-link redirect, exchanges
|
|
68
83
|
it (plus the `code_verifier`) for a token pair, and stores it in the device
|
|
69
84
|
keychain (`expo-secure-store`),
|
|
@@ -84,18 +99,20 @@ The full flow is documented in the doc-comment at the top of `src/mw/client.ts`.
|
|
|
84
99
|
|
|
85
100
|
## Environment
|
|
86
101
|
|
|
87
|
-
|
|
88
|
-
`
|
|
89
|
-
|
|
90
|
-
anything you plan to ship, copy `.env.example` to `.env` and set:
|
|
102
|
+
`src/mw/env.ts` defaults `EXPO_PUBLIC_MW_PLATFORM_BASE_URL` to
|
|
103
|
+
`http://127.0.0.1:8000` when omitted, but native auth still requires a tenant
|
|
104
|
+
UUID. Copy `.env.example` to `.env` and set:
|
|
91
105
|
|
|
92
106
|
```
|
|
93
107
|
EXPO_PUBLIC_MW_PLATFORM_BASE_URL=https://<your-platform-base-url>
|
|
108
|
+
EXPO_PUBLIC_MW_TENANT_ID=<tenant-uuid>
|
|
94
109
|
```
|
|
95
110
|
|
|
96
111
|
Only `EXPO_PUBLIC_*` variables are bundled into the app, and they are **not
|
|
97
|
-
secret** — never put keys/tokens in them.
|
|
98
|
-
|
|
112
|
+
secret** — never put keys/tokens in them. The tenant UUID is not a secret; it
|
|
113
|
+
only selects which tenant the platform should authenticate against. Platform
|
|
114
|
+
membership checks still decide whether the user can receive a native token.
|
|
115
|
+
`src/mw/env.ts` validates these values at startup with zod.
|
|
99
116
|
|
|
100
117
|
## Running locally
|
|
101
118
|
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
// DEVELOPER-OWNED — replace freely. Only src/mw/ is MinuteWork substrate.
|
|
2
2
|
//
|
|
3
3
|
// This screen is intentionally plain and is meant to be REWRITTEN. It exists to
|
|
4
|
-
// show the
|
|
4
|
+
// show the two native auth seams you care about: password sign-in and
|
|
5
5
|
// browser-assisted native sign-in through `src/mw/client.ts`.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Both paths return/store a platform bearer token pair through the native auth
|
|
8
|
+
// client, then route into the authed stack. Wire your own UI/UX around these
|
|
9
|
+
// calls.
|
|
10
10
|
import { useState } from "react";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
Alert,
|
|
13
|
+
Pressable,
|
|
14
|
+
StyleSheet,
|
|
15
|
+
Text,
|
|
16
|
+
TextInput,
|
|
17
|
+
View,
|
|
18
|
+
} from "react-native";
|
|
12
19
|
import { router } from "expo-router";
|
|
13
20
|
|
|
14
21
|
import { mwClient } from "@/mw/client";
|
|
@@ -16,14 +23,13 @@ import { mwEnv } from "@/mw/env";
|
|
|
16
23
|
|
|
17
24
|
export default function LoginScreen() {
|
|
18
25
|
const [busy, setBusy] = useState(false);
|
|
26
|
+
const [email, setEmail] = useState("");
|
|
27
|
+
const [password, setPassword] = useState("");
|
|
19
28
|
|
|
20
|
-
async function
|
|
29
|
+
async function finishSignIn(action: () => Promise<unknown>) {
|
|
21
30
|
setBusy(true);
|
|
22
31
|
try {
|
|
23
|
-
|
|
24
|
-
// (stored in the keychain by the client), then route into the authed stack.
|
|
25
|
-
const { code, codeVerifier, redirectUri } = await mwClient.authorize();
|
|
26
|
-
await mwClient.exchange(code, codeVerifier, redirectUri);
|
|
32
|
+
await action();
|
|
27
33
|
router.replace("/(app)");
|
|
28
34
|
} catch (error) {
|
|
29
35
|
Alert.alert(
|
|
@@ -35,22 +41,82 @@ export default function LoginScreen() {
|
|
|
35
41
|
}
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
async function onPasswordSignIn() {
|
|
45
|
+
await finishSignIn(() =>
|
|
46
|
+
mwClient.signInWithPassword({
|
|
47
|
+
email,
|
|
48
|
+
password,
|
|
49
|
+
tenantId: mwEnv.tenantId,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function onBrowserSignIn() {
|
|
55
|
+
await finishSignIn(async () => {
|
|
56
|
+
// Device flow: authorize (browser) -> exchange code+verifier for tokens
|
|
57
|
+
// (stored in the keychain by the client), then route into the authed stack.
|
|
58
|
+
const { code, codeVerifier, redirectUri } = await mwClient.authorize({
|
|
59
|
+
tenantId: mwEnv.tenantId,
|
|
60
|
+
});
|
|
61
|
+
await mwClient.exchange(code, codeVerifier, redirectUri);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
return (
|
|
39
66
|
<View style={styles.container}>
|
|
40
67
|
<Text style={styles.title}>{mwEnv.appName}</Text>
|
|
41
68
|
<Text style={styles.subtitle}>Sign in to continue</Text>
|
|
42
69
|
|
|
70
|
+
<View style={styles.form}>
|
|
71
|
+
<TextInput
|
|
72
|
+
autoCapitalize="none"
|
|
73
|
+
autoComplete="email"
|
|
74
|
+
autoCorrect={false}
|
|
75
|
+
editable={!busy}
|
|
76
|
+
inputMode="email"
|
|
77
|
+
onChangeText={setEmail}
|
|
78
|
+
placeholder="Email"
|
|
79
|
+
style={styles.input}
|
|
80
|
+
textContentType="username"
|
|
81
|
+
value={email}
|
|
82
|
+
/>
|
|
83
|
+
<TextInput
|
|
84
|
+
autoCapitalize="none"
|
|
85
|
+
editable={!busy}
|
|
86
|
+
onChangeText={setPassword}
|
|
87
|
+
placeholder="Password"
|
|
88
|
+
secureTextEntry
|
|
89
|
+
style={styles.input}
|
|
90
|
+
textContentType="password"
|
|
91
|
+
value={password}
|
|
92
|
+
/>
|
|
93
|
+
</View>
|
|
94
|
+
|
|
43
95
|
<Pressable
|
|
44
96
|
accessibilityRole="button"
|
|
45
|
-
disabled={busy}
|
|
46
|
-
onPress={
|
|
97
|
+
disabled={busy || !email || !password}
|
|
98
|
+
onPress={onPasswordSignIn}
|
|
47
99
|
style={({ pressed }) => [
|
|
48
100
|
styles.button,
|
|
49
|
-
(pressed || busy) && styles.buttonPressed,
|
|
101
|
+
(pressed || busy || !email || !password) && styles.buttonPressed,
|
|
50
102
|
]}
|
|
51
103
|
>
|
|
52
104
|
<Text style={styles.buttonText}>
|
|
53
|
-
{busy ? "
|
|
105
|
+
{busy ? "Signing in..." : "Sign in with password"}
|
|
106
|
+
</Text>
|
|
107
|
+
</Pressable>
|
|
108
|
+
|
|
109
|
+
<Pressable
|
|
110
|
+
accessibilityRole="button"
|
|
111
|
+
disabled={busy}
|
|
112
|
+
onPress={onBrowserSignIn}
|
|
113
|
+
style={({ pressed }) => [
|
|
114
|
+
styles.secondaryButton,
|
|
115
|
+
(pressed || busy) && styles.buttonPressed,
|
|
116
|
+
]}
|
|
117
|
+
>
|
|
118
|
+
<Text style={styles.secondaryButtonText}>
|
|
119
|
+
{busy ? "Opening..." : "Continue in browser"}
|
|
54
120
|
</Text>
|
|
55
121
|
</Pressable>
|
|
56
122
|
</View>
|
|
@@ -74,11 +140,25 @@ const styles = StyleSheet.create({
|
|
|
74
140
|
opacity: 0.7,
|
|
75
141
|
marginBottom: 12,
|
|
76
142
|
},
|
|
143
|
+
form: {
|
|
144
|
+
width: "100%",
|
|
145
|
+
gap: 10,
|
|
146
|
+
},
|
|
147
|
+
input: {
|
|
148
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
149
|
+
borderColor: "#d1d5db",
|
|
150
|
+
borderRadius: 10,
|
|
151
|
+
fontSize: 16,
|
|
152
|
+
paddingHorizontal: 14,
|
|
153
|
+
paddingVertical: 12,
|
|
154
|
+
},
|
|
77
155
|
button: {
|
|
78
156
|
backgroundColor: "#111827",
|
|
79
157
|
paddingVertical: 14,
|
|
80
158
|
paddingHorizontal: 24,
|
|
81
159
|
borderRadius: 10,
|
|
160
|
+
width: "100%",
|
|
161
|
+
alignItems: "center",
|
|
82
162
|
},
|
|
83
163
|
buttonPressed: {
|
|
84
164
|
opacity: 0.6,
|
|
@@ -88,4 +168,18 @@ const styles = StyleSheet.create({
|
|
|
88
168
|
fontSize: 16,
|
|
89
169
|
fontWeight: "600",
|
|
90
170
|
},
|
|
171
|
+
secondaryButton: {
|
|
172
|
+
borderColor: "#111827",
|
|
173
|
+
borderRadius: 10,
|
|
174
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
175
|
+
paddingHorizontal: 24,
|
|
176
|
+
paddingVertical: 14,
|
|
177
|
+
width: "100%",
|
|
178
|
+
alignItems: "center",
|
|
179
|
+
},
|
|
180
|
+
secondaryButtonText: {
|
|
181
|
+
color: "#111827",
|
|
182
|
+
fontSize: 16,
|
|
183
|
+
fontWeight: "600",
|
|
184
|
+
},
|
|
91
185
|
});
|
|
@@ -3,8 +3,10 @@ import * as Linking from "expo-linking";
|
|
|
3
3
|
import * as WebBrowser from "expo-web-browser";
|
|
4
4
|
import {
|
|
5
5
|
createNativeAuthClient,
|
|
6
|
+
type NativeAuthorizeOptions,
|
|
6
7
|
type NativeBrowser,
|
|
7
8
|
type NativeCrypto,
|
|
9
|
+
type NativePasswordGrantOptions,
|
|
8
10
|
} from "@minutework/native-auth";
|
|
9
11
|
|
|
10
12
|
import { mwEnv } from "@/mw/env";
|
|
@@ -38,11 +40,27 @@ const nativeBrowser: NativeBrowser = {
|
|
|
38
40
|
},
|
|
39
41
|
};
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
const nativeAuthClient = createNativeAuthClient({
|
|
42
44
|
platformBaseUrl: mwEnv.platformBaseUrl,
|
|
43
45
|
storage: mwSession,
|
|
44
46
|
crypto: nativeCrypto,
|
|
45
47
|
browser: nativeBrowser,
|
|
46
48
|
});
|
|
47
49
|
|
|
50
|
+
export const mwClient = {
|
|
51
|
+
...nativeAuthClient,
|
|
52
|
+
authorize(options: NativeAuthorizeOptions = {}) {
|
|
53
|
+
return nativeAuthClient.authorize({
|
|
54
|
+
...options,
|
|
55
|
+
tenantId: mwEnv.tenantId,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
signInWithPassword(options: NativePasswordGrantOptions) {
|
|
59
|
+
return nativeAuthClient.signInWithPassword({
|
|
60
|
+
...options,
|
|
61
|
+
tenantId: mwEnv.tenantId,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
} satisfies typeof nativeAuthClient;
|
|
65
|
+
|
|
48
66
|
export type MwClient = typeof mwClient;
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// SECURITY: EXPO_PUBLIC_* values ship inside the app bundle and are readable by
|
|
11
11
|
// anyone with the binary. Only put non-secret configuration here (a base URL,
|
|
12
|
-
// a display name). Never put API keys, client secrets, or tokens
|
|
12
|
+
// tenant UUID, a display name). Never put API keys, client secrets, or tokens
|
|
13
|
+
// in EXPO_PUBLIC_*.
|
|
13
14
|
|
|
14
15
|
import { z } from "zod";
|
|
15
16
|
|
|
@@ -33,6 +34,7 @@ const optionalNameSchema = z.preprocess((value) => {
|
|
|
33
34
|
|
|
34
35
|
const envSchema = z.object({
|
|
35
36
|
platformBaseUrl: baseUrlSchema,
|
|
37
|
+
tenantId: z.string().uuid(),
|
|
36
38
|
appName: optionalNameSchema,
|
|
37
39
|
});
|
|
38
40
|
|
|
@@ -45,6 +47,7 @@ const parsed = envSchema.safeParse({
|
|
|
45
47
|
configuredPlatformBaseUrl.trim().length > 0
|
|
46
48
|
? configuredPlatformBaseUrl
|
|
47
49
|
: DEFAULT_LOCAL_PLATFORM_BASE_URL,
|
|
50
|
+
tenantId: process.env.EXPO_PUBLIC_MW_TENANT_ID,
|
|
48
51
|
appName: process.env.EXPO_PUBLIC_MW_APP_NAME,
|
|
49
52
|
});
|
|
50
53
|
|
|
@@ -58,7 +61,8 @@ if (!parsed.success) {
|
|
|
58
61
|
|
|
59
62
|
throw new Error(
|
|
60
63
|
`Invalid Expo public environment for mobile-app: ${issues}. ` +
|
|
61
|
-
"Set EXPO_PUBLIC_MW_PLATFORM_BASE_URL to the MinuteWork platform URL
|
|
64
|
+
"Set EXPO_PUBLIC_MW_PLATFORM_BASE_URL to the MinuteWork platform URL " +
|
|
65
|
+
"and EXPO_PUBLIC_MW_TENANT_ID to this native app's tenant UUID.",
|
|
62
66
|
);
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
"reference/mwv3-dj6-docs/auth_and_credential_contract.md"
|
|
15
15
|
],
|
|
16
16
|
"deployable": false,
|
|
17
|
-
"notes": "Bring-your-own-UI Expo (React Native + Expo Router) starter. Direct platform native API client (bearer token to /api/v1/native/...), NOT a tenant-app BFF cookie client. Distribution is the developer's EAS pipeline, not `minutework deploy`. Only src/mw/ is MinuteWork substrate; everything else is developer-owned. src/mw/client.ts implements the real browser-assisted PKCE device flow against the platform native session endpoints; the OAuth-style redirect target is the app.json expo.scheme. This manifest is validated by tools/template/validate-template.mjs, not the strict shared template.schema.json (mobile is not a web/sidecar deployable kind)."
|
|
17
|
+
"notes": "Bring-your-own-UI Expo (React Native + Expo Router) starter. Direct platform native API client (bearer token to /api/v1/native/...), NOT a tenant-app BFF cookie client. Distribution is the developer's EAS pipeline, not `minutework deploy`. Only src/mw/ is MinuteWork substrate; everything else is developer-owned. src/mw/client.ts implements native password sign-in plus the real browser-assisted PKCE device flow against the platform native session endpoints; set EXPO_PUBLIC_MW_TENANT_ID to the non-secret tenant UUID so both flows send tenant context. The OAuth-style redirect target is the app.json expo.scheme. This manifest is validated by tools/template/validate-template.mjs, not the strict shared template.schema.json (mobile is not a web/sidecar deployable kind)."
|
|
18
18
|
}
|
|
@@ -26,6 +26,13 @@ The local `/login` route is product UI that calls SDK hooks. The SDK also
|
|
|
26
26
|
provides hosted UI helpers for same-origin `/_mw/login`, `/_mw/signup`, and
|
|
27
27
|
`/_mw/verify-email` URLs; both paths stay on the same SDK contract.
|
|
28
28
|
|
|
29
|
+
For Supabase-like naming, the template uses `signUp` and
|
|
30
|
+
`signInWithPassword` from `@minutework/web-auth/react`. These aliases still use
|
|
31
|
+
the same cookie-backed web customer session and do not return access or refresh
|
|
32
|
+
tokens. App guards should read `useMinuteWorkSession().status`, whose stable
|
|
33
|
+
precedence is `loading` -> `verification_required` -> `authenticated` ->
|
|
34
|
+
`unauthenticated`; keep `error` as a separate field.
|
|
35
|
+
|
|
29
36
|
Client-side `/app` session checks are only UX gating. Authorization is enforced
|
|
30
37
|
by the platform `/_mw` routes and runtime dispatch: active customer membership,
|
|
31
38
|
email verification, app publication, and manifest `web_customer_exposed`
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"design-system:visual": "playwright test -c tools/design-system/playwright.config.mjs"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@minutework/web-auth": "^0.1.
|
|
30
|
+
"@minutework/web-auth": "^0.1.1",
|
|
31
31
|
"@radix-ui/react-slot": "^1.2.4",
|
|
32
32
|
"class-variance-authority": "^0.7.1",
|
|
33
33
|
"clsx": "^2.1.1",
|
|
@@ -17,7 +17,7 @@ type AuthMode = "login" | "signup";
|
|
|
17
17
|
export function LoginScreen({ appName }: { appName: string }) {
|
|
18
18
|
const router = useRouter();
|
|
19
19
|
const searchParams = useSearchParams();
|
|
20
|
-
const {
|
|
20
|
+
const { signInWithPassword, signUp } = useMinuteWorkAuth();
|
|
21
21
|
const [mode, setMode] = useState<AuthMode>("login");
|
|
22
22
|
const [email, setEmail] = useState("");
|
|
23
23
|
const [displayName, setDisplayName] = useState("");
|
|
@@ -34,7 +34,7 @@ export function LoginScreen({ appName }: { appName: string }) {
|
|
|
34
34
|
|
|
35
35
|
try {
|
|
36
36
|
if (mode === "signup") {
|
|
37
|
-
await
|
|
37
|
+
await signUp({
|
|
38
38
|
email,
|
|
39
39
|
password,
|
|
40
40
|
displayName,
|
|
@@ -45,7 +45,7 @@ export function LoginScreen({ appName }: { appName: string }) {
|
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
await
|
|
48
|
+
await signInWithPassword({ email, password });
|
|
49
49
|
startTransition(() => {
|
|
50
50
|
router.replace(appRoutes.appHome);
|
|
51
51
|
router.refresh();
|
|
@@ -44,8 +44,7 @@ export function AuthenticatedAppLayoutShell({
|
|
|
44
44
|
}) {
|
|
45
45
|
const router = useRouter();
|
|
46
46
|
const { logout } = useMinuteWorkAuth();
|
|
47
|
-
const { session,
|
|
48
|
-
useMinuteWorkSession();
|
|
47
|
+
const { session, status } = useMinuteWorkSession();
|
|
49
48
|
|
|
50
49
|
function redirectToLogin() {
|
|
51
50
|
startTransition(() => {
|
|
@@ -59,7 +58,7 @@ export function AuthenticatedAppLayoutShell({
|
|
|
59
58
|
redirectToLogin();
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
if (loading) {
|
|
61
|
+
if (status === "loading") {
|
|
63
62
|
return (
|
|
64
63
|
<main className="min-h-screen bg-background text-foreground">
|
|
65
64
|
<div className="mx-auto flex min-h-screen max-w-5xl items-center px-6 py-10">
|
|
@@ -71,11 +70,7 @@ export function AuthenticatedAppLayoutShell({
|
|
|
71
70
|
);
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
if (
|
|
75
|
-
emailVerificationRequired ||
|
|
76
|
-
session?.email_verification_required ||
|
|
77
|
-
session?.customer_membership?.status === "pending_verification"
|
|
78
|
-
) {
|
|
73
|
+
if (status === "verification_required") {
|
|
79
74
|
return (
|
|
80
75
|
<main className="min-h-screen bg-background text-foreground">
|
|
81
76
|
<div className="mx-auto flex min-h-screen max-w-5xl items-center px-6 py-10">
|
|
@@ -102,7 +97,7 @@ export function AuthenticatedAppLayoutShell({
|
|
|
102
97
|
);
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
if (
|
|
100
|
+
if (status !== "authenticated" || !session?.customer_membership) {
|
|
106
101
|
return (
|
|
107
102
|
<main className="min-h-screen bg-background text-foreground">
|
|
108
103
|
<div className="mx-auto flex min-h-screen max-w-5xl items-center px-6 py-10">
|