@tribe-nest/forge 2.2.0 → 3.2.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/package.json +1 -1
- package/src/client/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCourseAccess.ts +1 -1
- package/src/index.ts +10 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +41 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +44 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/safeRedirect.ts +41 -0
package/package.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
import axios, { type AxiosInstance } from "axios";
|
|
1
|
+
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Access-token refresh (backend §7 cutover). Access tokens live ~15 minutes;
|
|
5
|
+
* when a request 401s the client exchanges the refresh token at `path` for a
|
|
6
|
+
* new pair and retries once. The refresh token ROTATES on every exchange, so
|
|
7
|
+
* `onRefreshed` must persist the new one.
|
|
8
|
+
*/
|
|
9
|
+
export interface ForgeClientRefreshOptions {
|
|
10
|
+
/** Exchange endpoint for this lane, e.g. "/public/sessions/refresh". */
|
|
11
|
+
path: string;
|
|
12
|
+
/** The current refresh token, or null when anonymous (no refresh attempted). */
|
|
13
|
+
getRefreshToken: () => string | null;
|
|
14
|
+
/** Persist the rotated pair and update whatever backs `getToken`. */
|
|
15
|
+
onRefreshed: (tokens: { token: string; refreshToken: string }) => void;
|
|
16
|
+
/** The chain is dead (revoked/expired/reused) — clear stored tokens. */
|
|
17
|
+
onFailed: () => void;
|
|
18
|
+
}
|
|
2
19
|
|
|
3
20
|
export interface ForgeClientOptions {
|
|
4
21
|
/** Base URL of the TribeNest public API, e.g. https://api.tribenest.co */
|
|
@@ -13,6 +30,8 @@ export interface ForgeClientOptions {
|
|
|
13
30
|
* place (Component 1) this is sent on every request to scope reads to the tenant.
|
|
14
31
|
*/
|
|
15
32
|
publishableKey?: string;
|
|
33
|
+
/** Enable 401-triggered refresh-and-retry for this client's auth lane. */
|
|
34
|
+
refresh?: ForgeClientRefreshOptions;
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
/**
|
|
@@ -22,7 +41,12 @@ export interface ForgeClientOptions {
|
|
|
22
41
|
* the base URL is injected (no `process.env`), the token is read per-request via a
|
|
23
42
|
* closure, and `localStorage` access is SSR-guarded so the client is safe on Workers.
|
|
24
43
|
*/
|
|
25
|
-
export const createForgeClient = ({
|
|
44
|
+
export const createForgeClient = ({
|
|
45
|
+
baseURL,
|
|
46
|
+
getToken,
|
|
47
|
+
publishableKey,
|
|
48
|
+
refresh,
|
|
49
|
+
}: ForgeClientOptions): AxiosInstance => {
|
|
26
50
|
const client = axios.create({ baseURL });
|
|
27
51
|
|
|
28
52
|
client.interceptors.request.use((config) => {
|
|
@@ -44,6 +68,65 @@ export const createForgeClient = ({ baseURL, getToken, publishableKey }: ForgeCl
|
|
|
44
68
|
return config;
|
|
45
69
|
});
|
|
46
70
|
|
|
71
|
+
if (refresh) {
|
|
72
|
+
// Single-flight: concurrent 401s (a page of queries expiring together) must
|
|
73
|
+
// produce ONE exchange — the refresh token rotates on use, so parallel
|
|
74
|
+
// exchanges would race each other through the server's grace window.
|
|
75
|
+
let inFlight: Promise<string | null> | null = null;
|
|
76
|
+
|
|
77
|
+
const exchange = (): Promise<string | null> => {
|
|
78
|
+
if (!inFlight) {
|
|
79
|
+
inFlight = (async () => {
|
|
80
|
+
const refreshToken = refresh.getRefreshToken();
|
|
81
|
+
if (!refreshToken) return null;
|
|
82
|
+
try {
|
|
83
|
+
// Bare axios on purpose — the shared client would recurse through
|
|
84
|
+
// this very interceptor.
|
|
85
|
+
const res = await axios.post(
|
|
86
|
+
`${baseURL}${refresh.path}`,
|
|
87
|
+
{ refreshToken },
|
|
88
|
+
publishableKey ? { headers: { "x-forge-key": publishableKey } } : undefined,
|
|
89
|
+
);
|
|
90
|
+
const next = res.data as { token: string; refreshToken: string };
|
|
91
|
+
refresh.onRefreshed(next);
|
|
92
|
+
return next.token;
|
|
93
|
+
} catch {
|
|
94
|
+
refresh.onFailed();
|
|
95
|
+
return null;
|
|
96
|
+
} finally {
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
inFlight = null;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
}
|
|
103
|
+
return inFlight;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
client.interceptors.response.use(
|
|
107
|
+
(response) => response,
|
|
108
|
+
async (error: AxiosError) => {
|
|
109
|
+
const config = error.config as (typeof error.config & { _retriedAfterRefresh?: boolean }) | undefined;
|
|
110
|
+
if (
|
|
111
|
+
error.response?.status === 401 &&
|
|
112
|
+
config &&
|
|
113
|
+
!config._retriedAfterRefresh &&
|
|
114
|
+
!config.url?.includes(refresh.path) &&
|
|
115
|
+
refresh.getRefreshToken()
|
|
116
|
+
) {
|
|
117
|
+
const token = await exchange();
|
|
118
|
+
if (token) {
|
|
119
|
+
config._retriedAfterRefresh = true;
|
|
120
|
+
config.headers = config.headers ?? {};
|
|
121
|
+
config.headers["authorization"] = `Bearer ${token}`;
|
|
122
|
+
return client.request(config);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return Promise.reject(error);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
47
130
|
return client;
|
|
48
131
|
};
|
|
49
132
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// localStorage keys for the two auth lanes a Forge site can carry. They live
|
|
2
|
+
// here (not in the auth contexts) so the client factory and both contexts can
|
|
3
|
+
// share them without an import cycle. The fan/member and app-user lanes are
|
|
4
|
+
// deliberately separate pairs — an app-user session must never collide with a
|
|
5
|
+
// profile session on the same site.
|
|
6
|
+
//
|
|
7
|
+
// Access tokens are ~15 minutes now (backend §7 cutover); the refresh token is
|
|
8
|
+
// the long-lived credential and ROTATES on every exchange — always store the
|
|
9
|
+
// one that came back, never re-use the one you sent.
|
|
10
|
+
|
|
11
|
+
export const PUBLIC_ACCESS_TOKEN_KEY = "public-access-token";
|
|
12
|
+
export const PUBLIC_REFRESH_TOKEN_KEY = "public-refresh-token";
|
|
13
|
+
|
|
14
|
+
export const APP_ACCESS_TOKEN_KEY = "app-access-token";
|
|
15
|
+
export const APP_REFRESH_TOKEN_KEY = "app-refresh-token";
|
|
16
|
+
|
|
17
|
+
const canStore = () => typeof localStorage !== "undefined";
|
|
18
|
+
|
|
19
|
+
export const readStoredToken = (key: string): string | null => (canStore() ? localStorage.getItem(key) : null);
|
|
20
|
+
|
|
21
|
+
export const storeTokenPair = (accessKey: string, refreshKey: string, token: string, refreshToken?: string) => {
|
|
22
|
+
if (!canStore()) return;
|
|
23
|
+
localStorage.setItem(accessKey, token);
|
|
24
|
+
if (refreshToken) localStorage.setItem(refreshKey, refreshToken);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const clearTokenPair = (accessKey: string, refreshKey: string) => {
|
|
28
|
+
if (!canStore()) return;
|
|
29
|
+
localStorage.removeItem(accessKey);
|
|
30
|
+
localStorage.removeItem(refreshKey);
|
|
31
|
+
};
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
3
3
|
import { useForge } from "../provider/ForgeProvider";
|
|
4
4
|
import { createForgeClient } from "../client/createForgeClient";
|
|
5
|
+
import {
|
|
6
|
+
APP_ACCESS_TOKEN_KEY as APP_ACCESS_KEY,
|
|
7
|
+
APP_REFRESH_TOKEN_KEY,
|
|
8
|
+
clearTokenPair,
|
|
9
|
+
readStoredToken,
|
|
10
|
+
storeTokenPair,
|
|
11
|
+
} from "../client/tokenStorage";
|
|
5
12
|
|
|
6
13
|
// App-user auth for a mini-app frontend (account_associations type='app'). This
|
|
7
14
|
// is the app's OWN user base — distinct from TribeNest fans (usePublicAuth) and
|
|
@@ -9,7 +16,8 @@ import { createForgeClient } from "../client/createForgeClient";
|
|
|
9
16
|
// client (its own localStorage key) so an app-user session never collides with a
|
|
10
17
|
// profile session on the same site. Backed by /public/app-sessions/*.
|
|
11
18
|
|
|
12
|
-
|
|
19
|
+
// Kept for compat — the canonical constant lives in client/tokenStorage.ts.
|
|
20
|
+
export const APP_ACCESS_TOKEN_KEY = APP_ACCESS_KEY;
|
|
13
21
|
|
|
14
22
|
export interface AppAuthUser {
|
|
15
23
|
id: string;
|
|
@@ -38,9 +46,19 @@ export interface AppSignupInput {
|
|
|
38
46
|
lastName?: string;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
/**
|
|
50
|
+
* What BOTH `signup` and `login` return: the platform emailed a 6-digit code and
|
|
51
|
+
* issued no session. Collect the code and call `verifyCode`. Neither entry point
|
|
52
|
+
* has a variant that logs a user straight in.
|
|
53
|
+
*/
|
|
54
|
+
export type AppAuthChallenge = { status: "verify_email"; email: string };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The outcome of redeeming the code. `active` means signed in (token stored).
|
|
58
|
+
* `pending` means the address is proven but this app requires OWNER APPROVAL, so
|
|
59
|
+
* there is still no session — show a "waiting for approval" screen.
|
|
60
|
+
*/
|
|
61
|
+
export type AppAuthResult = { status: "pending"; user: AppAuthUser } | { status: "active"; user: AppAuthUser };
|
|
44
62
|
|
|
45
63
|
interface AppAuthContextType {
|
|
46
64
|
appId?: string;
|
|
@@ -49,10 +67,26 @@ interface AppAuthContextType {
|
|
|
49
67
|
isInitialized: boolean;
|
|
50
68
|
isLoading: boolean;
|
|
51
69
|
errorMessage: string | null;
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Step 1 of 2. Creates the account and emails a 6-digit code; it NEVER logs the
|
|
72
|
+
* user in. Always returns `{ status: "verify_email" }` — including for an email
|
|
73
|
+
* the platform has never seen, so the response can't be used to discover which
|
|
74
|
+
* addresses are already registered. Collect the code, then call `verifyCode`.
|
|
75
|
+
*/
|
|
76
|
+
signup: (input: AppSignupInput) => Promise<AppAuthChallenge>;
|
|
77
|
+
/**
|
|
78
|
+
* Step 1 of 2. Checks the password and emails a 6-digit code — it does NOT
|
|
79
|
+
* return a session. Collect the code and call `verifyCode`. (App login is
|
|
80
|
+
* two-factor by design; a wrong password still fails here, before any code.)
|
|
81
|
+
*/
|
|
82
|
+
login: (input: { email: string; password: string }) => Promise<AppAuthChallenge>;
|
|
83
|
+
/**
|
|
84
|
+
* Step 2 of 2 for BOTH signup and login. Redeems the emailed code. On success
|
|
85
|
+
* the user is signed in (`active`) unless this app requires owner approval, in
|
|
86
|
+
* which case the address is proven but the session still waits (`pending`) —
|
|
87
|
+
* proving your email is not the owner approving you.
|
|
88
|
+
*/
|
|
89
|
+
verifyCode: (input: { email: string; code: string }) => Promise<AppAuthResult>;
|
|
56
90
|
logout: () => Promise<void>;
|
|
57
91
|
refetch: () => Promise<void>;
|
|
58
92
|
clearError: () => void;
|
|
@@ -80,7 +114,26 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
|
|
|
80
114
|
// client (which carries the profile/member token).
|
|
81
115
|
const tokenRef = useRef<string | null>(null);
|
|
82
116
|
const client = useMemo(
|
|
83
|
-
() =>
|
|
117
|
+
() =>
|
|
118
|
+
createForgeClient({
|
|
119
|
+
baseURL: apiUrl,
|
|
120
|
+
publishableKey,
|
|
121
|
+
getToken: () => tokenRef.current,
|
|
122
|
+
// App-user lane (backend §7): same 15-minute access tokens, own refresh
|
|
123
|
+
// chain on its own storage keys so it never collides with a fan session.
|
|
124
|
+
refresh: {
|
|
125
|
+
path: "/public/app-sessions/refresh",
|
|
126
|
+
getRefreshToken: () => readStoredToken(APP_REFRESH_TOKEN_KEY),
|
|
127
|
+
onRefreshed: ({ token: next, refreshToken }) => {
|
|
128
|
+
tokenRef.current = next;
|
|
129
|
+
storeTokenPair(APP_ACCESS_TOKEN_KEY, APP_REFRESH_TOKEN_KEY, next, refreshToken);
|
|
130
|
+
},
|
|
131
|
+
onFailed: () => {
|
|
132
|
+
tokenRef.current = null;
|
|
133
|
+
clearTokenPair(APP_ACCESS_TOKEN_KEY, APP_REFRESH_TOKEN_KEY);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
84
137
|
[apiUrl, publishableKey],
|
|
85
138
|
);
|
|
86
139
|
|
|
@@ -127,13 +180,41 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
|
|
|
127
180
|
setErrorMessage(null);
|
|
128
181
|
try {
|
|
129
182
|
const res = await client.post("/public/app-sessions/signup", { appId, ...input });
|
|
130
|
-
const {
|
|
183
|
+
const { email } = res.data as { status: "verify_email"; email: string };
|
|
184
|
+
// No token here, by design — see AppAuthChallenge.
|
|
185
|
+
return { status: "verify_email", email };
|
|
186
|
+
} catch (err) {
|
|
187
|
+
setErrorMessage(extractError(err));
|
|
188
|
+
throw err;
|
|
189
|
+
} finally {
|
|
190
|
+
setIsLoading(false);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
[appId, client],
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const verifyCode = useCallback<AppAuthContextType["verifyCode"]>(
|
|
197
|
+
async ({ email, code }) => {
|
|
198
|
+
if (!appId) throw new Error("AppAuthProvider: no appId in Forge context.");
|
|
199
|
+
setIsLoading(true);
|
|
200
|
+
setErrorMessage(null);
|
|
201
|
+
try {
|
|
202
|
+
const res = await client.post("/public/app-sessions/verify", { appId, email, code });
|
|
203
|
+
const { status, account, token, refreshToken } = res.data as {
|
|
204
|
+
status: "pending" | "active";
|
|
205
|
+
account: AppAuthUser;
|
|
206
|
+
token?: string;
|
|
207
|
+
refreshToken?: string;
|
|
208
|
+
};
|
|
131
209
|
if (status === "active" && token) {
|
|
132
210
|
setToken(token);
|
|
211
|
+
if (refreshToken && typeof localStorage !== "undefined") {
|
|
212
|
+
localStorage.setItem(APP_REFRESH_TOKEN_KEY, refreshToken);
|
|
213
|
+
}
|
|
133
214
|
setUser(account);
|
|
134
215
|
return { status: "active", user: account };
|
|
135
216
|
}
|
|
136
|
-
//
|
|
217
|
+
// Email proven, but the owner still has to approve — no token issued.
|
|
137
218
|
return { status: "pending", user: account };
|
|
138
219
|
} catch (err) {
|
|
139
220
|
setErrorMessage(extractError(err));
|
|
@@ -152,8 +233,10 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
|
|
|
152
233
|
setErrorMessage(null);
|
|
153
234
|
try {
|
|
154
235
|
const res = await client.post("/public/app-sessions", { appId, ...input });
|
|
155
|
-
|
|
156
|
-
|
|
236
|
+
// The password was checked, but no session is issued until the emailed
|
|
237
|
+
// code is redeemed — see AppAuthChallenge.
|
|
238
|
+
const { email } = res.data as { status: "verify_email"; email: string };
|
|
239
|
+
return { status: "verify_email", email };
|
|
157
240
|
} catch (err) {
|
|
158
241
|
setErrorMessage(extractError(err));
|
|
159
242
|
throw err;
|
|
@@ -161,7 +244,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
|
|
|
161
244
|
setIsLoading(false);
|
|
162
245
|
}
|
|
163
246
|
},
|
|
164
|
-
[appId, client
|
|
247
|
+
[appId, client],
|
|
165
248
|
);
|
|
166
249
|
|
|
167
250
|
const logout = useCallback<AppAuthContextType["logout"]>(async () => {
|
|
@@ -171,6 +254,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
|
|
|
171
254
|
// best-effort — clear locally regardless
|
|
172
255
|
}
|
|
173
256
|
setToken(null);
|
|
257
|
+
if (typeof localStorage !== "undefined") localStorage.removeItem(APP_REFRESH_TOKEN_KEY);
|
|
174
258
|
setUser(null);
|
|
175
259
|
}, [client, setToken]);
|
|
176
260
|
|
|
@@ -184,6 +268,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
|
|
|
184
268
|
errorMessage,
|
|
185
269
|
signup,
|
|
186
270
|
login,
|
|
271
|
+
verifyCode,
|
|
187
272
|
logout,
|
|
188
273
|
refetch,
|
|
189
274
|
clearError: () => setErrorMessage(null),
|
|
@@ -192,7 +277,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
|
|
|
192
277
|
// crash in a render guard is worse than a hidden button.
|
|
193
278
|
hasPermission: (permission: string) => (user?.permissions ?? []).includes(permission),
|
|
194
279
|
}),
|
|
195
|
-
[appId, user, isInitialized, isLoading, errorMessage, signup, login, logout, refetch],
|
|
280
|
+
[appId, user, isInitialized, isLoading, errorMessage, signup, login, verifyCode, logout, refetch],
|
|
196
281
|
);
|
|
197
282
|
|
|
198
283
|
return <AppAuthContext.Provider value={value}>{children}</AppAuthContext.Provider>;
|
|
@@ -23,8 +23,16 @@ enum Types {
|
|
|
23
23
|
UpdateUser = "UPDATE_USER",
|
|
24
24
|
UpdateErrorMessage = "UPDATE_ERROR_MESSAGE",
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
import {
|
|
27
|
+
PUBLIC_ACCESS_TOKEN_KEY,
|
|
28
|
+
PUBLIC_REFRESH_TOKEN_KEY,
|
|
29
|
+
clearTokenPair,
|
|
30
|
+
storeTokenPair,
|
|
31
|
+
} from "../client/tokenStorage";
|
|
32
|
+
|
|
33
|
+
// Kept for compat — the canonical constants live in client/tokenStorage.ts.
|
|
34
|
+
export const ACCESS_TOKEN_KEY = PUBLIC_ACCESS_TOKEN_KEY;
|
|
35
|
+
export { PUBLIC_ACCESS_TOKEN_KEY };
|
|
28
36
|
|
|
29
37
|
type AuthPayload = {
|
|
30
38
|
[Types.Initial]: { isAuthenticated: boolean; user: PublicAuthUser | null };
|
|
@@ -198,8 +206,8 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
|
|
|
198
206
|
|
|
199
207
|
try {
|
|
200
208
|
const response = await client.post("/public/sessions", data);
|
|
201
|
-
const { token, account, smartLinkPath } = response.data;
|
|
202
|
-
|
|
209
|
+
const { token, refreshToken, account, smartLinkPath } = response.data;
|
|
210
|
+
storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
|
|
203
211
|
setToken(token);
|
|
204
212
|
dispatch({ type: Types.Login, payload: { user: account } });
|
|
205
213
|
return { smartLinkPath };
|
|
@@ -220,8 +228,8 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
|
|
|
220
228
|
|
|
221
229
|
try {
|
|
222
230
|
const response = await client.post("/public/sessions/verify", { email, code, profileId });
|
|
223
|
-
const { token, account, smartLinkPath } = response.data;
|
|
224
|
-
|
|
231
|
+
const { token, refreshToken, account, smartLinkPath } = response.data;
|
|
232
|
+
storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
|
|
225
233
|
setToken(token);
|
|
226
234
|
dispatch({ type: Types.Login, payload: { user: account } });
|
|
227
235
|
return { smartLinkPath };
|
|
@@ -264,8 +272,36 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
|
|
|
264
272
|
const response = await client.post("/public/accounts", {
|
|
265
273
|
...data,
|
|
266
274
|
});
|
|
267
|
-
|
|
268
|
-
|
|
275
|
+
// No token: signup issues no session until the emailed code is redeemed
|
|
276
|
+
// (see PublicAuthChallenge). Keep the caller's data — `verifyRegistration`
|
|
277
|
+
// needs the coupon/consent flags again to finish the job.
|
|
278
|
+
const { email } = response.data as { status: "verify_email"; email: string };
|
|
279
|
+
dispatch({ type: Types.Loading, payload: { isLoading: false } });
|
|
280
|
+
return { status: "verify_email" as const, email };
|
|
281
|
+
} catch (err: unknown) {
|
|
282
|
+
const error = err as ApiError;
|
|
283
|
+
dispatch({ type: Types.Loading, payload: { isLoading: false } });
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const verifyRegistration = async (data: {
|
|
289
|
+
email: string;
|
|
290
|
+
code: string;
|
|
291
|
+
profileId?: string;
|
|
292
|
+
couponCode?: string;
|
|
293
|
+
acceptedTerms?: boolean;
|
|
294
|
+
acceptedPrivacy?: boolean;
|
|
295
|
+
}) => {
|
|
296
|
+
dispatch({ type: Types.Loading, payload: { isLoading: true } });
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const response = await client.post("/public/accounts/verify", {
|
|
300
|
+
...data,
|
|
301
|
+
profileId: data.profileId ?? profileId,
|
|
302
|
+
});
|
|
303
|
+
const { token, refreshToken, account, smartLinkPath } = response.data;
|
|
304
|
+
storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
|
|
269
305
|
setToken(token);
|
|
270
306
|
|
|
271
307
|
dispatch({ type: Types.Register, payload: { user: account } });
|
|
@@ -279,7 +315,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
|
|
|
279
315
|
|
|
280
316
|
const logout = async (persist = true) => {
|
|
281
317
|
try {
|
|
282
|
-
|
|
318
|
+
clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
|
|
283
319
|
dispatch({ type: Types.Logout });
|
|
284
320
|
if (persist) await client.delete("/sessions");
|
|
285
321
|
} catch (error) {
|
|
@@ -308,6 +344,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
|
|
|
308
344
|
resendLoginCode,
|
|
309
345
|
logout,
|
|
310
346
|
register,
|
|
347
|
+
verifyRegistration,
|
|
311
348
|
updateLocalUser,
|
|
312
349
|
clearErrorMessage,
|
|
313
350
|
refetchUser,
|
|
@@ -38,7 +38,7 @@ export function useClaimCourseAccess() {
|
|
|
38
38
|
const { client } = useForge();
|
|
39
39
|
|
|
40
40
|
return useMutation<
|
|
41
|
-
{ token?: string; accessId?: string },
|
|
41
|
+
{ token?: string; refreshToken?: string; accessId?: string },
|
|
42
42
|
unknown,
|
|
43
43
|
{ token: string; password: string; firstName: string; lastName: string }
|
|
44
44
|
>({
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,12 @@ export type { ForgeClientOptions } from "./client/createForgeClient";
|
|
|
11
11
|
// + theme). Most sites import just this.
|
|
12
12
|
export { ForgeProvider, type ForgeProviderProps } from "./provider/ForgeAppProvider";
|
|
13
13
|
// Low-level pieces for hand-composing the tree.
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
ForgeClientProvider,
|
|
16
|
+
useForge,
|
|
17
|
+
type ForgeClientProviderProps,
|
|
18
|
+
type ForgeContextValue,
|
|
19
|
+
} from "./provider/ForgeProvider";
|
|
15
20
|
export { SiteConfigProvider, useInitialSiteConfig } from "./provider/SiteConfigProvider";
|
|
16
21
|
|
|
17
22
|
// All domain types + enums/consts (models) and WebPage.
|
|
@@ -30,6 +35,8 @@ export {
|
|
|
30
35
|
PublicAuthContext,
|
|
31
36
|
} from "./contexts/PublicAuthContext";
|
|
32
37
|
export type { AuthActions } from "./contexts/PublicAuthContext";
|
|
38
|
+
export { PUBLIC_REFRESH_TOKEN_KEY, APP_REFRESH_TOKEN_KEY } from "./client/tokenStorage";
|
|
39
|
+
export { safeRedirectPath } from "./utils/safeRedirect";
|
|
33
40
|
// App-user auth (mini-apps) + the /admin guard.
|
|
34
41
|
export {
|
|
35
42
|
useAppAuth,
|
|
@@ -37,7 +44,8 @@ export {
|
|
|
37
44
|
APP_ACCESS_TOKEN_KEY,
|
|
38
45
|
type AppAuthUser,
|
|
39
46
|
type AppSignupInput,
|
|
40
|
-
type
|
|
47
|
+
type AppAuthChallenge,
|
|
48
|
+
type AppAuthResult,
|
|
41
49
|
} from "./contexts/AppAuthContext";
|
|
42
50
|
export { useAppAdminGuard, type AppAdminGuardResult } from "./contexts/useAppAdminGuard";
|
|
43
51
|
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { AxiosInstance } from "axios";
|
|
3
3
|
import { createForgeClient } from "../client/createForgeClient";
|
|
4
|
+
import {
|
|
5
|
+
PUBLIC_ACCESS_TOKEN_KEY,
|
|
6
|
+
PUBLIC_REFRESH_TOKEN_KEY,
|
|
7
|
+
clearTokenPair,
|
|
8
|
+
readStoredToken,
|
|
9
|
+
storeTokenPair,
|
|
10
|
+
} from "../client/tokenStorage";
|
|
4
11
|
|
|
5
12
|
export interface ForgeContextValue {
|
|
6
13
|
/** Base URL of the TribeNest public API. */
|
|
@@ -69,7 +76,29 @@ export const ForgeClientProvider = ({
|
|
|
69
76
|
}, []);
|
|
70
77
|
|
|
71
78
|
const client = useMemo(
|
|
72
|
-
() =>
|
|
79
|
+
() =>
|
|
80
|
+
createForgeClient({
|
|
81
|
+
baseURL: apiUrl,
|
|
82
|
+
publishableKey,
|
|
83
|
+
getToken: () => tokenRef.current,
|
|
84
|
+
// Fan/member lane (backend §7): 15-minute access tokens, refresh token
|
|
85
|
+
// rotated per exchange. On a dead chain the stored pair is cleared so
|
|
86
|
+
// the next /me lands cleanly logged-out instead of looping on 401s.
|
|
87
|
+
refresh: {
|
|
88
|
+
path: "/public/sessions/refresh",
|
|
89
|
+
getRefreshToken: () => readStoredToken(PUBLIC_REFRESH_TOKEN_KEY),
|
|
90
|
+
onRefreshed: ({ token: next, refreshToken }) => {
|
|
91
|
+
tokenRef.current = next;
|
|
92
|
+
setTokenState(next);
|
|
93
|
+
storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, next, refreshToken);
|
|
94
|
+
},
|
|
95
|
+
onFailed: () => {
|
|
96
|
+
tokenRef.current = null;
|
|
97
|
+
setTokenState(null);
|
|
98
|
+
clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
73
102
|
[apiUrl, publishableKey],
|
|
74
103
|
);
|
|
75
104
|
|