@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.
- package/README.md +301 -72
- package/package.json +10 -4
- package/src/api/createApiClient.ts +43 -0
- package/src/api/index.ts +2 -0
- package/src/api/request.ts +22 -0
- package/src/auth/createAuthOptions.ts +313 -0
- package/src/auth/index.ts +2 -0
- package/src/config.ts +20 -9
- package/src/errors.ts +12 -8
- package/src/forms/useFormDraft.ts +20 -22
- package/src/index.ts +1 -1
- package/src/session/expiry.ts +17 -6
- package/src/session/index.ts +1 -0
- package/src/session/react/ProtectedGate.tsx +34 -0
- package/src/session/react/SessionExpiryGuard.tsx +294 -0
- package/src/session/react/index.ts +6 -0
- package/src/session/react/sessionExpiryGuardStrings.ts +26 -0
- package/src/session/react/useSessionGate.ts +6 -8
- package/src/styles.css +16 -3
- package/src/ui/Alert.tsx +6 -6
- package/src/ui/CredentialsForm.tsx +113 -0
- package/src/ui/PasswordInput.tsx +12 -31
- package/src/ui/TextInput.tsx +28 -0
- package/src/ui/credentialsFormStrings.ts +29 -0
- package/src/ui/field.tsx +51 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/withDefaults.ts +14 -0
|
@@ -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
|
+
}
|
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
|
|
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"
|
|
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
|
|
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.
|
|
35
|
-
* which match
|
|
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
|
-
*
|
|
47
|
-
*
|
|
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
|
|
50
|
-
*
|
|
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
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
37
|
-
*
|
|
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
|
|
9
|
-
// enough that
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
67
|
-
* overwrites what the API returned. Saving starts
|
|
68
|
-
* memory, so
|
|
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
|
-
//
|
|
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
|
|
90
|
-
//
|
|
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
|
|
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
|
-
//
|
|
96
|
-
//
|
|
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
|
|
103
|
-
//
|
|
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
|
|
120
|
-
//
|
|
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
|
|
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";
|
package/src/session/expiry.ts
CHANGED
|
@@ -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
|
|
23
|
-
// that raises it is a plain module
|
|
24
|
-
//
|
|
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
|
-
//
|
|
27
|
-
// versions of the package
|
|
28
|
-
//
|
|
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>;
|
package/src/session/index.ts
CHANGED
|
@@ -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
|
+
}
|