@sjolystinnovation/app-kit 0.4.0 → 0.6.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.
@@ -0,0 +1,313 @@
1
+ import axios, { type AxiosInstance } from "axios";
2
+ import jwt from "jsonwebtoken";
3
+ import type { NextAuthOptions, Session, User } from "next-auth";
4
+ import CredentialsProvider from "next-auth/providers/credentials";
5
+ import type { JWT } from "next-auth/jwt";
6
+ import { resolveJwtSecret, type SessionConfig } from "../config";
7
+ import { SESSION_ERRORS, SIGN_IN_ERRORS, isTerminalSessionError } from "../session/expiry";
8
+
9
+ /** The signed-in user, as the session carries it. */
10
+ export interface SessionUser {
11
+ id: string;
12
+ name: string;
13
+ email: string;
14
+ roles: string[];
15
+ }
16
+
17
+ interface TokenPair {
18
+ token: string;
19
+ refreshToken: string;
20
+ }
21
+
22
+ interface ApiClaims {
23
+ sub: string;
24
+ unique_name: string;
25
+ role?: string | string[];
26
+ exp: number;
27
+ iat: number;
28
+ }
29
+
30
+ // The fields this module stores in next-auth's JWT. An intersection still compiles when the app
31
+ // declares the same fields itself.
32
+ type AppToken = JWT & {
33
+ accessToken?: string;
34
+ refreshToken?: string;
35
+ refreshAt?: number;
36
+ loginAt?: number;
37
+ user?: SessionUser;
38
+ error?: string;
39
+ };
40
+
41
+ interface AuthorizedUser extends User {
42
+ roles: string[];
43
+ accessToken: string;
44
+ refreshToken: string;
45
+ }
46
+
47
+ export type AuthOptionsSettings = {
48
+ /** The server environment, usually `process.env`. It holds the API JWT secret. */
49
+ env: Record<string, string | undefined>;
50
+ } & (
51
+ | {
52
+ /** Base URL of the API, such as `https://api.example.com/api`. */
53
+ apiUrl: string;
54
+ http?: never;
55
+ }
56
+ | {
57
+ /** An HTTP client with its own base URL, for tests. */
58
+ http: AxiosInstance;
59
+ apiUrl?: never;
60
+ }
61
+ );
62
+
63
+ const ABSOLUTE_SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
64
+
65
+ // Refresh ahead of expiry, so the token is still fresh when the user saves.
66
+ const MAX_REFRESH_SKEW_MS = 5 * 60 * 1000;
67
+
68
+ // The soonest a refresh can be due. The floor stops a short-lived token from refreshing on every
69
+ // session read.
70
+ const MIN_REFRESH_GAP_MS = 30 * 1000;
71
+
72
+ // How long a refresh result is remembered. It is also the wait before a failed refresh is retried.
73
+ const REMEMBER_ROTATION_MS = 60 * 1000;
74
+
75
+ // How far this server's clock may differ from the API's when a token's times are checked.
76
+ const CLOCK_TOLERANCE_S = 60;
77
+
78
+ // Sign-in and session reads wait for the API, so a hung connection must not hold them forever.
79
+ const API_TIMEOUT_MS = 10_000;
80
+
81
+ function rolesOf(claims: ApiClaims): string[] {
82
+ const raw = claims.role;
83
+ return Array.isArray(raw) ? raw : raw ? [raw] : [];
84
+ }
85
+
86
+ // The skew is capped at a quarter of the token's lifetime, so a short-lived token is not refreshed
87
+ // on every read. A token without a lifetime gets the floor.
88
+ function nextRefreshAt(claims: ApiClaims): number {
89
+ const expiresAt = claims.exp * 1000;
90
+ const lifetimeMs = Math.max(0, (claims.exp - claims.iat) * 1000);
91
+ const refreshAt = expiresAt - Math.min(MAX_REFRESH_SKEW_MS, lifetimeMs / 4);
92
+ if (!Number.isFinite(refreshAt)) return Date.now() + MIN_REFRESH_GAP_MS;
93
+ return Math.max(Date.now() + MIN_REFRESH_GAP_MS, refreshAt);
94
+ }
95
+
96
+ // No answer, or a gateway that could not reach the API. A plain 500 is left out, because the API
97
+ // may fail after it has checked the password.
98
+ function apiUnavailable(error: unknown): boolean {
99
+ if (!axios.isAxiosError(error)) return false;
100
+ const status = error.response?.status;
101
+ return status === undefined || status === 502 || status === 503 || status === 504;
102
+ }
103
+
104
+ // What a log line may contain. The error itself carries the request, which holds the password or
105
+ // tokens.
106
+ function failureShape(error: unknown) {
107
+ return {
108
+ status: axios.isAxiosError(error) ? error.response?.status : undefined,
109
+ code: axios.isAxiosError(error) ? error.code : undefined,
110
+ message: error instanceof Error ? error.message : String(error),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * next-auth options for email and password sign-in against the API. The API issues a short-lived
116
+ * access token and a refresh token that changes on every refresh. The session refreshes ahead of
117
+ * expiry, rides out temporary API failures, and ends seven days after sign-in.
118
+ */
119
+ export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSettings): NextAuthOptions {
120
+ const { env } = settings;
121
+ if (settings.http === undefined && !settings.apiUrl) {
122
+ throw new Error("createAuthOptions needs the API base URL, and apiUrl is empty.");
123
+ }
124
+ const http = settings.http ?? axios.create({ baseURL: settings.apiUrl, allowAbsoluteUrls: false });
125
+
126
+ const readClaims = (token: string) =>
127
+ jwt.verify(token, resolveJwtSecret(config, env), {
128
+ clockTolerance: CLOCK_TOLERANCE_S,
129
+ }) as unknown as ApiClaims;
130
+
131
+ async function postTokens(path: string, body: object): Promise<TokenPair> {
132
+ const response = await http.post<TokenPair>(path, body, { timeout: API_TIMEOUT_MS });
133
+ return response.data;
134
+ }
135
+
136
+ // Parallel session reads carry the same refresh token. The API accepts each refresh token once.
137
+ // It treats a second use as replay and revokes every session the user has. So all reads of one
138
+ // token share one refresh. The result is kept for a while, because a slow read can arrive with
139
+ // the old token after the refresh is done.
140
+ //
141
+ // The map lives in one server process. Replicas do not share it.
142
+ const inFlight = new Map<string, Promise<AppToken>>();
143
+
144
+ function refreshOnce(token: AppToken): Promise<AppToken> {
145
+ const key = token.refreshToken as string;
146
+ const existing = inFlight.get(key);
147
+ if (existing) return existing;
148
+
149
+ const pending = rotate(token);
150
+ inFlight.set(key, pending);
151
+ // The minute counts from the answer, so a slow refresh is remembered as long as a fast one.
152
+ void pending.then(() => {
153
+ // unref, so the timer cannot keep a serverless invocation alive.
154
+ const expiry = setTimeout(() => inFlight.delete(key), REMEMBER_ROTATION_MS) as unknown as {
155
+ unref?: () => void;
156
+ };
157
+ expiry.unref?.();
158
+ });
159
+ return pending;
160
+ }
161
+
162
+ async function rotate(token: AppToken): Promise<AppToken> {
163
+ let refreshed: TokenPair;
164
+ try {
165
+ refreshed = await postTokens(
166
+ "/auth/refresh-token",
167
+ { token: token.accessToken, refreshToken: token.refreshToken },
168
+ );
169
+ } catch (error) {
170
+ // The API refused the token pair: a bad access token, or a refresh token that is unknown,
171
+ // expired or already used. Only this ends the session.
172
+ const status = axios.isAxiosError(error) ? error.response?.status : undefined;
173
+ if (status === 400 || status === 401) {
174
+ return { ...token, error: SESSION_ERRORS.refreshRejected };
175
+ }
176
+ // A network error or an API restart keeps the session. A later read tries again.
177
+ console.error("Refresh token request failed, keeping the session", failureShape(error));
178
+ return { ...token, refreshAt: Date.now() + REMEMBER_ROTATION_MS };
179
+ }
180
+
181
+ // A success without tokens is treated as a temporary failure, and the session keeps its own.
182
+ if (!refreshed?.token || !refreshed.refreshToken) {
183
+ console.error("Refresh token response had no tokens, keeping the session");
184
+ return { ...token, refreshAt: Date.now() + REMEMBER_ROTATION_MS };
185
+ }
186
+
187
+ // The API has already replaced the old tokens. Keep the new ones whatever happens below,
188
+ // because the old refresh token now counts as replay.
189
+ const next: AppToken = {
190
+ ...token,
191
+ accessToken: refreshed.token,
192
+ refreshToken: refreshed.refreshToken,
193
+ };
194
+ delete next.error;
195
+
196
+ try {
197
+ const claims = readClaims(refreshed.token);
198
+ next.refreshAt = nextRefreshAt(claims);
199
+ next.user = { ...(token.user as SessionUser), roles: rolesOf(claims) };
200
+ } catch (error) {
201
+ // The token is unreadable, for example after a secret change. Retry soon with the new
202
+ // refresh token.
203
+ console.error("Could not read the refreshed access token, retrying shortly", failureShape(error));
204
+ next.refreshAt = Date.now() + REMEMBER_ROTATION_MS;
205
+ }
206
+
207
+ return next;
208
+ }
209
+
210
+ return {
211
+ providers: [
212
+ CredentialsProvider({
213
+ name: "Credentials",
214
+ credentials: {
215
+ email: { label: "Email", type: "email" },
216
+ password: { label: "Password", type: "password" },
217
+ },
218
+ async authorize(credentials) {
219
+ if (!credentials) {
220
+ throw new Error("Credentials are missing");
221
+ }
222
+ try {
223
+ const tokens = await postTokens(
224
+ "/auth/login",
225
+ { email: credentials.email, password: credentials.password },
226
+ );
227
+ if (!tokens?.token || !tokens.refreshToken) {
228
+ throw new Error("Login response had no tokens");
229
+ }
230
+ const claims = readClaims(tokens.token);
231
+ const user: AuthorizedUser = {
232
+ id: claims.sub,
233
+ name: claims.unique_name,
234
+ email: credentials.email,
235
+ roles: rolesOf(claims),
236
+ accessToken: tokens.token,
237
+ refreshToken: tokens.refreshToken,
238
+ };
239
+ return user;
240
+ } catch (error) {
241
+ console.error("Sign-in failed", failureShape(error));
242
+ // No cause: next-auth logs this error, and the cause carries the password.
243
+ // eslint-disable-next-line preserve-caught-error
244
+ throw new Error(
245
+ apiUnavailable(error) ? SIGN_IN_ERRORS.unavailable : SIGN_IN_ERRORS.invalidCredentials,
246
+ );
247
+ }
248
+ },
249
+ }),
250
+ ],
251
+ pages: {
252
+ signIn: config.loginRoute,
253
+ },
254
+ session: {
255
+ maxAge: ABSOLUTE_SESSION_MAX_AGE / 1000,
256
+ },
257
+ callbacks: {
258
+ async jwt({ token: current, user }) {
259
+ let token = current as AppToken;
260
+ if (user) {
261
+ const signedIn = user as AuthorizedUser;
262
+ const claims = readClaims(signedIn.accessToken);
263
+ token = {
264
+ ...token,
265
+ accessToken: signedIn.accessToken,
266
+ refreshToken: signedIn.refreshToken,
267
+ refreshAt: nextRefreshAt(claims),
268
+ loginAt: Date.now(),
269
+ user: {
270
+ id: signedIn.id,
271
+ name: signedIn.name ?? "",
272
+ email: signedIn.email ?? "",
273
+ roles: signedIn.roles,
274
+ },
275
+ };
276
+ delete token.error;
277
+ }
278
+
279
+ // A session without a sign-in time counts as expired.
280
+ if (token.loginAt === undefined || Date.now() - token.loginAt > ABSOLUTE_SESSION_MAX_AGE) {
281
+ return { ...token, error: SESSION_ERRORS.absoluteExpiry };
282
+ }
283
+
284
+ // Only a new sign-in recovers a terminal session, so the API is not asked again.
285
+ if (isTerminalSessionError(token.error)) {
286
+ return token;
287
+ }
288
+
289
+ if (token.refreshAt && Date.now() < token.refreshAt) {
290
+ return token;
291
+ }
292
+
293
+ if (!token.refreshToken) {
294
+ return { ...token, error: SESSION_ERRORS.noRefreshToken };
295
+ }
296
+
297
+ return refreshOnce(token);
298
+ },
299
+ async session({ session, token: current }) {
300
+ const token = current as AppToken;
301
+ // Cast, so it compiles against the app's own Session declaration.
302
+ return {
303
+ ...session,
304
+ user: token.user,
305
+ accessToken: token.accessToken,
306
+ error: token.error,
307
+ // Lets the UI warn before the seven days run out.
308
+ absoluteExpiresAt: token.loginAt ? token.loginAt + ABSOLUTE_SESSION_MAX_AGE : undefined,
309
+ } as Session;
310
+ },
311
+ },
312
+ };
313
+ }
@@ -0,0 +1,2 @@
1
+ export { createAuthOptions } from "./createAuthOptions";
2
+ export type { AuthOptionsSettings, SessionUser } from "./createAuthOptions";
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The values that differ between the apps using this package. Everything else in the session layer
3
- * is identical across them, so it stays hardcoded rather than becoming an option.
3
+ * is the same in every app, so it is hardcoded.
4
4
  */
5
5
  export interface SessionConfig {
6
6
  /**
@@ -10,7 +10,7 @@ export interface SessionConfig {
10
10
  jwtSecretEnvVar: string;
11
11
  /**
12
12
  * Where an expired or rejected session sends the browser. An app with a locale prefix uses
13
- * something like "/en/login", one without uses "/login".
13
+ * something like "/en/login". An app without one uses "/login".
14
14
  */
15
15
  loginRoute: string;
16
16
  /**
@@ -23,7 +23,7 @@ export interface SessionConfig {
23
23
 
24
24
  /**
25
25
  * Declares an app's config as a typed constant. Throws when `draftStoragePrefix` is not a bare name,
26
- * so a bad value fails when the app starts rather than when a form first opens.
26
+ * so a bad value fails at app start, before any form opens.
27
27
  */
28
28
  export function defineSessionConfig(config: SessionConfig): SessionConfig {
29
29
  draftPrefix(config);
@@ -31,8 +31,8 @@ export function defineSessionConfig(config: SessionConfig): SessionConfig {
31
31
  }
32
32
 
33
33
  /**
34
- * The key prefix for form drafts. A colon or an empty name builds keys such as "app:draft::draft:",
35
- * which match none of the drafts already saved under "app:draft:".
34
+ * The key prefix for form drafts. Throws on a colon or an empty name. Those build keys such as
35
+ * "app:draft::draft:", which match no draft saved under "app:draft:".
36
36
  */
37
37
  export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">): string {
38
38
  const name = config.draftStoragePrefix;
@@ -43,11 +43,22 @@ export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">):
43
43
  }
44
44
 
45
45
  /**
46
- * Reads the API JWT secret named by the config. Throws rather than returning undefined, because a
47
- * missing secret silently breaks token verification at runtime instead of at boot.
46
+ * Returns `value`, or throws when it is unset or empty. Pass `process.env.NAME` directly, so Next.js
47
+ * can inline public variables at build time.
48
+ */
49
+ export function requireEnv(name: string, value: string | undefined): string {
50
+ if (!value) {
51
+ throw new Error(`Missing ${name}.`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ /**
57
+ * Reads the API JWT secret named by the config. Throws when it is missing, with a message that names
58
+ * the variable.
48
59
  *
49
- * The caller passes the environment, usually `process.env`. Reading it here would put
50
- * `@types/node` in the type surface of a package that ends up in a browser bundle.
60
+ * The caller passes the environment, usually `process.env`, so this entry point needs no Node
61
+ * types and stays safe to import in browser code.
51
62
  */
52
63
  export function resolveJwtSecret(
53
64
  config: SessionConfig,
package/src/errors.ts CHANGED
@@ -9,7 +9,7 @@ export type StatusMessages = Partial<Record<number, string>>;
9
9
 
10
10
  /**
11
11
  * Axios codes for a request that never got a response from the network. A browser reports every
12
- * network failure as ERR_NETWORK. On the server, Node reports the cause instead.
12
+ * network failure as ERR_NETWORK. On the server, Node reports the specific cause.
13
13
  */
14
14
  const NO_RESPONSE_CODES = new Set([
15
15
  "ERR_NETWORK",
@@ -26,15 +26,19 @@ const NO_RESPONSE_CODES = new Set([
26
26
  /**
27
27
  * Turns a failed request into one line a user can read.
28
28
  *
29
- * On a 4xx response the body's own message comes first: a text body, then `message`, the first
30
- * validation error and `detail` from a JSON body. After that come `statusMessages` for the status
31
- * and `fallback`. On a 5xx the caller's status message comes first, because the body may be a
32
- * proxy's "Bad Gateway" rather than the API's own words. A problem details `title` is never used:
33
- * ASP.NET Core fills it with generic text such as "Bad Request", which says less than `fallback`.
29
+ * On a 4xx response the order is:
30
+ * 1. The body's own message: a text body, or `message`, the first validation error or `detail` from
31
+ * a JSON body.
32
+ * 2. `statusMessages` for the status.
33
+ * 3. `fallback`.
34
+ *
35
+ * On a 5xx the caller's status message comes first, because the body may come from a proxy, such
36
+ * as "Bad Gateway". A problem details `title` is never used. ASP.NET Core fills it with generic text
37
+ * such as "Bad Request", which says less than `fallback`.
34
38
  *
35
39
  * Only single line text counts as a message. Axios's own message, such as "Request failed with
36
- * status code 500", is never shown, and a cancelled request gives `fallback`. Any other `Error`
37
- * gives its message.
40
+ * status code 500", is never shown. A cancelled request gives `fallback`. Any other `Error` gives
41
+ * its message.
38
42
  */
39
43
  export function formatApiError(
40
44
  error: unknown,
@@ -5,9 +5,8 @@ import { draftPrefix, type SessionConfig } from "../config";
5
5
 
6
6
  const WRITE_DELAY_MS = 500;
7
7
 
8
- // How long a draft is offered. Long enough to outlive an expired session or a closed tab, short
9
- // enough that week-old work is not offered as if it were current. An expired draft is removed when
10
- // its form next reads it.
8
+ // How long a draft is offered. Long enough to outlive an expired session or a closed tab. Short
9
+ // enough that stale work is not offered as current. A form removes its expired draft on next read.
11
10
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
12
11
 
13
12
  interface Envelope<T> {
@@ -35,10 +34,10 @@ function read<T>(storageKey: string): T | null {
35
34
 
36
35
  export interface FormDraftOptions<T> {
37
36
  /**
38
- * The signed-in user's id. A browser profile can be shared, so the draft is stored per user.
39
- * Without that, the next person to sign in is offered the previous one's work. The last known
40
- * owner is kept, because a lost cookie reports nobody and that is the moment the draft matters
41
- * most. Nothing is stored until an owner is known.
37
+ * The signed-in user's id. A browser profile can be shared, so drafts are stored per user. That
38
+ * way nobody is offered another user's work. The hook keeps the last known owner. A lost cookie
39
+ * reports nobody, and that is when the draft matters most. Nothing is stored until an owner is
40
+ * known.
42
41
  */
43
42
  owner: string | undefined;
44
43
  /** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
@@ -63,16 +62,15 @@ export interface FormDraft<T> {
63
62
  * Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
64
63
  * Values are written half a second after the last change, so only that last half second is at risk.
65
64
  *
66
- * A draft found at mount is offered for restore rather than applied, so an edit form never silently
67
- * overwrites what the API returned. Saving starts immediately either way. The offer is held in
68
- * memory, so the newest work is always the thing in storage.
65
+ * A draft found at mount is offered for restore, not applied. So an edit form never silently
66
+ * overwrites what the API returned. Saving starts at once, whether or not the offer is answered.
67
+ * The offer is held in memory, so storage always holds the newest work.
69
68
  */
70
69
  export function useFormDraft<T>(
71
70
  config: Pick<SessionConfig, "draftStoragePrefix">,
72
71
  { owner, scope, value }: FormDraftOptions<T>,
73
72
  ): FormDraft<T> {
74
- // Adjusted during render rather than in an effect, which is the supported way to derive state
75
- // from changing inputs.
73
+ // Set during render, which is React's supported way to derive state from changing inputs.
76
74
  const [lastOwner, setLastOwner] = useState<string | undefined>(undefined);
77
75
  if (owner && owner !== lastOwner) setLastOwner(owner);
78
76
 
@@ -86,21 +84,21 @@ export function useFormDraft<T>(
86
84
  const writeTimer = useRef<number | null>(null);
87
85
 
88
86
  useEffect(() => {
89
- // Re-base on a key change too. The values on screen belong to the old key, and writing them
90
- // under the new one would put this form's work in another form's draft.
87
+ // Re-base on a key change too. The values on screen belong to the old key and must not land
88
+ // in the new key's draft.
91
89
  untouched.current = json;
92
- // Reading the store is an external-system read, which is what an effect is for.
90
+ // Reading storage is an external-system read, which belongs in an effect.
93
91
  // eslint-disable-next-line react-hooks/set-state-in-effect
94
92
  setPending(key == null ? null : read<T>(key));
95
- // Keyed on the draft key alone on purpose. Re-reading whenever the values change would
96
- // re-offer a draft the user has already answered.
93
+ // Runs on a key change only. Re-reading on every value change would re-offer a draft the user
94
+ // has already answered.
97
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
98
96
  }, [key]);
99
97
 
100
98
  useEffect(() => {
101
99
  if (key == null) return;
102
- // Write only once the form differs from how it opened. Writing the pristine form would
103
- // overwrite the draft being offered before the user has answered.
100
+ // Write only once the form differs from how it opened, so the pristine form never overwrites
101
+ // a draft that is still on offer.
104
102
  if (json === untouched.current) return;
105
103
  const timer = window.setTimeout(() => {
106
104
  try {
@@ -116,14 +114,14 @@ export function useFormDraft<T>(
116
114
 
117
115
  const clear = useCallback(() => {
118
116
  if (key == null) return;
119
- // Cancel a write that is already scheduled, and treat the current values as the new baseline.
120
- // Otherwise a save is followed by the draft being written back.
117
+ // Cancel any scheduled write and make the current values the baseline, so a save is never
118
+ // followed by a fresh draft write.
121
119
  if (writeTimer.current != null) window.clearTimeout(writeTimer.current);
122
120
  untouched.current = json;
123
121
  try {
124
122
  window.localStorage.removeItem(key);
125
123
  } catch {
126
- // The draft is a convenience, not state anything depends on.
124
+ // The draft is a convenience. Nothing depends on it.
127
125
  }
128
126
  }, [key, json]);
129
127
 
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { defineSessionConfig, resolveJwtSecret } from "./config";
1
+ export { defineSessionConfig, requireEnv, resolveJwtSecret } from "./config";
2
2
  export type { SessionConfig } from "./config";
3
3
  export { formatApiError } from "./errors";
4
4
  export type { StatusMessages } from "./errors";
@@ -9,6 +9,17 @@ export const SESSION_ERRORS = {
9
9
  noRefreshToken: "NoRefreshToken",
10
10
  } as const;
11
11
 
12
+ /**
13
+ * The codes a failed sign-in reports in next-auth's `error`. Wrong credentials and every API answer
14
+ * look the same, so the message never hints that a password was right. Only an API that does not
15
+ * answer, or a gateway error, reports `unavailable`. That depends on the server, never on the
16
+ * password.
17
+ */
18
+ export const SIGN_IN_ERRORS = {
19
+ invalidCredentials: "InvalidCredentials",
20
+ unavailable: "SignInUnavailable",
21
+ } as const;
22
+
12
23
  /** One of the values in `SESSION_ERRORS`. */
13
24
  export type SessionError = (typeof SESSION_ERRORS)[keyof typeof SESSION_ERRORS];
14
25
 
@@ -19,13 +30,13 @@ export function isTerminalSessionError(error: string | undefined): boolean {
19
30
  return error != null && TERMINAL.has(error);
20
31
  }
21
32
 
22
- // Whether the re-sign-in prompt is showing. It lives outside React because the axios interceptor
23
- // that raises it is a plain module, and because the page gate has to know: it must not redirect to
24
- // the login page while the prompt is recovering the session in place.
33
+ // Whether the re-sign-in prompt is showing. It lives outside React for two reasons:
34
+ // 1. The axios interceptor that raises it is a plain module.
35
+ // 2. The page gate reads it, so it never redirects to login while the prompt recovers the session.
25
36
  //
26
- // Kept on globalThis under a registered symbol, so two copies of this module, such as two installed
27
- // versions of the package, still share one prompt. Separate state would let a prompt raised through
28
- // one copy go unseen by a gate reading the other, and the gate would redirect away from the form.
37
+ // It sits on globalThis under a registered symbol, so two copies of this module share one prompt.
38
+ // Two installed versions of the package are one example. A gate reading one copy then sees a
39
+ // prompt raised through the other and keeps the form on screen.
29
40
  interface PromptState {
30
41
  open: boolean;
31
42
  listeners: Set<() => void>;
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  SESSION_ERRORS,
3
+ SIGN_IN_ERRORS,
3
4
  closeSessionPrompt,
4
5
  getSessionPromptOpen,
5
6
  isTerminalSessionError,
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useEffect, type ReactNode } from "react";
5
+ import { useSessionGate } from "./useSessionGate";
6
+
7
+ export interface ProtectedGateProps {
8
+ /** Where a signed-out visitor, or a session dead on arrival, is sent. */
9
+ loginHref: string;
10
+ /** What shows while the page may not render yet. */
11
+ fallback?: ReactNode | undefined;
12
+ children: ReactNode;
13
+ }
14
+
15
+ /**
16
+ * Renders a protected page only on a usable session, and keeps it rendered while the re-sign-in
17
+ * prompt recovers the session. Never redirects while the prompt is open.
18
+ */
19
+ export function ProtectedGate({ loginHref, fallback = null, children }: ProtectedGateProps) {
20
+ const router = useRouter();
21
+ const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
22
+
23
+ useEffect(() => {
24
+ // A page opened on a dead session must not render, or the user starts work they cannot save.
25
+ // A session that dies later keeps the page, and the prompt recovers it in place.
26
+ if (promptOpen) return;
27
+ if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
28
+ // replace, so Back does not return to a page that only redirects again.
29
+ router.replace(loginHref);
30
+ }
31
+ }, [status, usable, wasUsable, promptOpen, router, loginHref]);
32
+
33
+ return mayRender ? children : fallback;
34
+ }