@sjolystinnovation/app-kit 0.6.0 → 0.7.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 +95 -0
- package/package.json +11 -2
- package/src/api/index.ts +1 -0
- package/src/api/requestRevalidate.ts +15 -0
- package/src/auth/createAuthOptions.ts +1 -6
- package/src/server/index.ts +4 -0
- package/src/server/publicApi.ts +104 -0
- package/src/server/revalidateRoute.ts +77 -0
- package/src/session/expiry.ts +6 -0
- package/src/session/react/AppSessionProvider.tsx +23 -0
- package/src/session/react/RoleGate.tsx +66 -0
- package/src/session/react/index.ts +4 -0
- package/src/toast/index.ts +11 -0
package/README.md
CHANGED
|
@@ -260,6 +260,55 @@ export const getRecipe = (id: number) =>
|
|
|
260
260
|
request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
|
|
261
261
|
```
|
|
262
262
|
|
|
263
|
+
`requestRevalidate(target, endpoint?)` asks the app's revalidate route to purge the pages behind
|
|
264
|
+
`target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
|
|
265
|
+
It never throws. On failure the pages' own revalidate window is the fallback.
|
|
266
|
+
|
|
267
|
+
### `@sjolystinnovation/app-kit/server`
|
|
268
|
+
|
|
269
|
+
For server components and route handlers only. It imports `next/cache` and `next/server`.
|
|
270
|
+
|
|
271
|
+
`createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
|
|
272
|
+
|
|
273
|
+
- `publicGet(path, { tags? })` gives the data, or null when the API answers 404. An unreachable or
|
|
274
|
+
failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
|
|
275
|
+
of a working one.
|
|
276
|
+
- `publicGetOptional(path, { tags? })` gives null on any failure, for data a page can do without.
|
|
277
|
+
- `publicGetWithMeta(path, { tags? })` also gives `lastModified`, from the `Last-Modified` header.
|
|
278
|
+
|
|
279
|
+
Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
|
|
280
|
+
throws. Each path starts with a single `/`, or the call throws. A path with a `.` or `..` segment,
|
|
281
|
+
encoded or not, gives null without a request, the same as a 404. Run a slug or other user input
|
|
282
|
+
through `encodeURIComponent` before putting it in a path.
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
// src/lib/publicApi.ts
|
|
286
|
+
import { requireEnv } from "@sjolystinnovation/app-kit";
|
|
287
|
+
import { createPublicApi } from "@sjolystinnovation/app-kit/server";
|
|
288
|
+
|
|
289
|
+
export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicApi(
|
|
290
|
+
requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
|
|
291
|
+
);
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
|
|
295
|
+
Each target maps to the page paths it purges. A path with a dynamic segment, such as
|
|
296
|
+
`/builds/[slug]`, purges every page of that route. The target name is purged as a cache tag too, so
|
|
297
|
+
tag the fetches behind it with the same name. The handler answers 403 unless the caller has a live
|
|
298
|
+
session with `role`: signed in within the last seven days, with no refused refresh and an access
|
|
299
|
+
token that has not expired. It answers 415 unless the body is sent as JSON, which
|
|
300
|
+
`requestRevalidate` does, and 400 for a target it does not know.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
// src/app/api/revalidate/route.ts
|
|
304
|
+
import { createRevalidateRoute } from "@sjolystinnovation/app-kit/server";
|
|
305
|
+
|
|
306
|
+
export const POST = createRevalidateRoute({
|
|
307
|
+
role: "Admin",
|
|
308
|
+
targets: { builds: ["/builds", "/builds/[slug]", "/sitemap.xml"], legal: ["/terms", "/privacy"] },
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
263
312
|
### `@sjolystinnovation/app-kit/session`
|
|
264
313
|
|
|
265
314
|
Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
|
|
@@ -368,6 +417,21 @@ layout that needs its own decision, such as a role check, so it agrees with the
|
|
|
368
417
|
|
|
369
418
|
`SessionGate` is the type of the result.
|
|
370
419
|
|
|
420
|
+
### `AppSessionProvider`
|
|
421
|
+
|
|
422
|
+
From `@sjolystinnovation/app-kit/session/react`. next-auth's `SessionProvider`, reading the session
|
|
423
|
+
again every four minutes. Each read lets the jwt callback renew the access token before it expires,
|
|
424
|
+
so an open tab does not find a dead session on the next save. It also keeps what `useSession`
|
|
425
|
+
reports in this tab up to date. next-auth polls only once a session exists.
|
|
426
|
+
|
|
427
|
+
```tsx
|
|
428
|
+
<AppSessionProvider>{children}</AppSessionProvider>
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
| Prop | Type | Description |
|
|
432
|
+
| --- | --- | --- |
|
|
433
|
+
| `refetchInterval` | `number` | Seconds between reads. Defaults to 240. Keep it under five minutes, the time before expiry when a refresh falls due. |
|
|
434
|
+
|
|
371
435
|
### `ProtectedGate`
|
|
372
436
|
|
|
373
437
|
From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It renders the
|
|
@@ -385,6 +449,25 @@ arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
|
|
|
385
449
|
| `loginHref` | `string` | Where the redirect goes. An app with a locale in the path builds it for the current locale. |
|
|
386
450
|
| `fallback` | `ReactNode` | What shows while the page may not render yet. Defaults to nothing. |
|
|
387
451
|
|
|
452
|
+
### `RoleGate`
|
|
453
|
+
|
|
454
|
+
From `@sjolystinnovation/app-kit/session/react`. Renders its children only for a user with `role`.
|
|
455
|
+
Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session there is
|
|
456
|
+
no user and so no roles. The page stays up only on the path where the session last had the role.
|
|
457
|
+
|
|
458
|
+
```tsx
|
|
459
|
+
<RoleGate role="Admin" fallback={<p>Loading…</p>} denied={<p>You do not have access.</p>}>
|
|
460
|
+
{children}
|
|
461
|
+
</RoleGate>
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
| Prop | Type | Description |
|
|
465
|
+
| --- | --- | --- |
|
|
466
|
+
| `role` | `string` | The role the page needs, as it appears in `session.user.roles`. |
|
|
467
|
+
| `fallback` | `ReactNode` | What shows while the session or its roles are not known yet. Defaults to nothing. |
|
|
468
|
+
| `denied` | `ReactNode` | What a signed-in user without the role sees. Give this or `deniedHref`. |
|
|
469
|
+
| `deniedHref` | `string` | Where a signed-in user without the role is sent instead. |
|
|
470
|
+
|
|
388
471
|
### `SessionExpiryGuard`
|
|
389
472
|
|
|
390
473
|
From `@sjolystinnovation/app-kit/session/react`. Render it once, inside the `SessionProvider`. It
|
|
@@ -520,6 +603,16 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
|
520
603
|
|
|
521
604
|
Pass the two toggle labels in the app's language. The defaults are English.
|
|
522
605
|
|
|
606
|
+
### `toast`
|
|
607
|
+
|
|
608
|
+
From `@sjolystinnovation/app-kit/toast`. sonner's `toast`, except that `toast.error` shows nothing
|
|
609
|
+
while the re-sign-in prompt is open. The prompt already says why the request failed. Every other
|
|
610
|
+
toast passes through, and so do the error states of `toast.promise`.
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
import { toast } from "@sjolystinnovation/app-kit/toast";
|
|
614
|
+
```
|
|
615
|
+
|
|
523
616
|
## Theming
|
|
524
617
|
|
|
525
618
|
The components draw every colour, corner radius and label size from theme variables with a light
|
|
@@ -587,6 +680,8 @@ with it and has to set the ones it uses itself.
|
|
|
587
680
|
|
|
588
681
|
- Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
|
|
589
682
|
app installs itself.
|
|
683
|
+
- For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
|
|
684
|
+
copy, because two copies keep two separate toast stores.
|
|
590
685
|
- `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
|
|
591
686
|
- For `@sjolystinnovation/app-kit/ui` and the components in `session/react`: Tailwind CSS 4 and
|
|
592
687
|
`@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjolystinnovation/app-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Sjølyst Innovation AS",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"./forms": "./src/forms/index.ts",
|
|
22
22
|
"./auth": "./src/auth/index.ts",
|
|
23
23
|
"./api": "./src/api/index.ts",
|
|
24
|
+
"./server": "./src/server/index.ts",
|
|
24
25
|
"./ui": "./src/ui/index.ts",
|
|
26
|
+
"./toast": "./src/toast/index.ts",
|
|
25
27
|
"./styles.css": "./src/styles.css"
|
|
26
28
|
},
|
|
27
29
|
"files": [
|
|
@@ -41,7 +43,13 @@
|
|
|
41
43
|
"next": "^16",
|
|
42
44
|
"next-auth": "^4.24",
|
|
43
45
|
"react": "^19",
|
|
44
|
-
"react-dom": "^19"
|
|
46
|
+
"react-dom": "^19",
|
|
47
|
+
"sonner": "^2"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"sonner": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
45
53
|
},
|
|
46
54
|
"devDependencies": {
|
|
47
55
|
"@heroicons/react": "^2.2.0",
|
|
@@ -57,6 +65,7 @@
|
|
|
57
65
|
"next-auth": "^4.24.15",
|
|
58
66
|
"react": "^19.3.0",
|
|
59
67
|
"react-dom": "^19.3.0",
|
|
68
|
+
"sonner": "^2.0.8",
|
|
60
69
|
"typescript": "^6.0.3",
|
|
61
70
|
"vitest": "^5.0.3"
|
|
62
71
|
},
|
package/src/api/index.ts
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asks the app's revalidate route to purge the pages behind `target`, so an admin edit shows at
|
|
3
|
+
* once. Best effort: on failure the pages' revalidate window is the fallback.
|
|
4
|
+
*/
|
|
5
|
+
export async function requestRevalidate(target: string, endpoint = "/api/revalidate"): Promise<void> {
|
|
6
|
+
try {
|
|
7
|
+
await fetch(endpoint, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "Content-Type": "application/json" },
|
|
10
|
+
body: JSON.stringify({ target }),
|
|
11
|
+
});
|
|
12
|
+
} catch {
|
|
13
|
+
// Best effort, see above.
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { NextAuthOptions, Session, User } from "next-auth";
|
|
|
4
4
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
5
5
|
import type { JWT } from "next-auth/jwt";
|
|
6
6
|
import { resolveJwtSecret, type SessionConfig } from "../config";
|
|
7
|
-
import { SESSION_ERRORS, SIGN_IN_ERRORS, isTerminalSessionError } from "../session/expiry";
|
|
7
|
+
import { ABSOLUTE_SESSION_MAX_AGE, CLOCK_TOLERANCE_S, SESSION_ERRORS, SIGN_IN_ERRORS, isTerminalSessionError } from "../session/expiry";
|
|
8
8
|
|
|
9
9
|
/** The signed-in user, as the session carries it. */
|
|
10
10
|
export interface SessionUser {
|
|
@@ -60,8 +60,6 @@ export type AuthOptionsSettings = {
|
|
|
60
60
|
}
|
|
61
61
|
);
|
|
62
62
|
|
|
63
|
-
const ABSOLUTE_SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
64
|
-
|
|
65
63
|
// Refresh ahead of expiry, so the token is still fresh when the user saves.
|
|
66
64
|
const MAX_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
67
65
|
|
|
@@ -72,9 +70,6 @@ const MIN_REFRESH_GAP_MS = 30 * 1000;
|
|
|
72
70
|
// How long a refresh result is remembered. It is also the wait before a failed refresh is retried.
|
|
73
71
|
const REMEMBER_ROTATION_MS = 60 * 1000;
|
|
74
72
|
|
|
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
73
|
// Sign-in and session reads wait for the API, so a hung connection must not hold them forever.
|
|
79
74
|
const API_TIMEOUT_MS = 10_000;
|
|
80
75
|
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createPublicApi, PublicApiError } from "./publicApi";
|
|
2
|
+
export type { PublicApiOptions, PublicGetOptions, PublicResponse } from "./publicApi";
|
|
3
|
+
export { createRevalidateRoute } from "./revalidateRoute";
|
|
4
|
+
export type { RevalidateRouteOptions } from "./revalidateRoute";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export interface PublicResponse<T> {
|
|
2
|
+
data: T;
|
|
3
|
+
/** From the `Last-Modified` header, when the endpoint reports one. */
|
|
4
|
+
lastModified: Date | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PublicGetOptions {
|
|
8
|
+
/** Cache tags, so a revalidate route can purge this fetch by tag. */
|
|
9
|
+
tags?: string[] | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PublicApiOptions {
|
|
13
|
+
/** Seconds a cached response stays fresh. Defaults to 60. */
|
|
14
|
+
revalidate?: number | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The API could not be reached, or answered with no usable content. */
|
|
18
|
+
export class PublicApiError extends Error {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly status: number | null;
|
|
21
|
+
|
|
22
|
+
constructor(path: string, status: number | null, options?: { cause?: unknown }) {
|
|
23
|
+
super(`GET ${path} failed${status == null ? "" : ` with ${status}`}`, options);
|
|
24
|
+
this.name = "PublicApiError";
|
|
25
|
+
this.path = path;
|
|
26
|
+
this.status = status;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Reads `Last-Modified`, ignoring a header that is missing or not a date. */
|
|
31
|
+
function lastModifiedOf(response: Response): Date | null {
|
|
32
|
+
const header = response.headers.get("last-modified");
|
|
33
|
+
if (!header) return null;
|
|
34
|
+
const date = new Date(header);
|
|
35
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Cached GETs against the API's anonymous endpoints, for server components and route handlers.
|
|
40
|
+
*
|
|
41
|
+
* A 404 gives null. An unreachable or failing API throws `PublicApiError`, so a failed fetch is
|
|
42
|
+
* never cached as an empty page in place of a working one.
|
|
43
|
+
*/
|
|
44
|
+
export function createPublicApi(baseUrl: string, options: PublicApiOptions = {}) {
|
|
45
|
+
if (!baseUrl) {
|
|
46
|
+
throw new Error("createPublicApi needs the API base URL, and baseUrl is empty.");
|
|
47
|
+
}
|
|
48
|
+
const revalidate = options.revalidate ?? 60;
|
|
49
|
+
const base = new URL(baseUrl);
|
|
50
|
+
const basePath = base.pathname.replace(/\/$/, "");
|
|
51
|
+
|
|
52
|
+
// The path is joined onto the base URL. A path that does not start with a single "/" is a bug in
|
|
53
|
+
// the caller, so it throws. A path with a dot segment, often a slug from the URL, names no resource,
|
|
54
|
+
// so it gives null, the same as a 404. A slug goes through encodeURIComponent first.
|
|
55
|
+
function urlFor(path: string): string | null {
|
|
56
|
+
if (!path.startsWith("/") || path.startsWith("//")) {
|
|
57
|
+
throw new Error(`Public API path must start with a single "/" under the base URL: ${path}`);
|
|
58
|
+
}
|
|
59
|
+
const segments = path.split(/[?#]/, 1)[0]!.replace(/%2e/gi, ".").split("/");
|
|
60
|
+
if (segments.some((segment) => segment === "." || segment === "..")) return null;
|
|
61
|
+
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
|
|
62
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(`${basePath}/`)) return null;
|
|
63
|
+
return url.toString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function publicGetWithMeta<T>(path: string, opts: PublicGetOptions = {}): Promise<PublicResponse<T> | null> {
|
|
67
|
+
const url = urlFor(path);
|
|
68
|
+
if (url == null) return null;
|
|
69
|
+
let response: Response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetch(url, {
|
|
72
|
+
next: opts.tags ? { revalidate, tags: opts.tags } : { revalidate },
|
|
73
|
+
});
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
throw new PublicApiError(path, null, { cause });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (response.status === 404) return null;
|
|
79
|
+
if (!response.ok) throw new PublicApiError(path, response.status);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
return { data: (await response.json()) as T, lastModified: lastModifiedOf(response) };
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
throw new PublicApiError(path, response.status, { cause });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The data alone. Null when the resource does not exist. */
|
|
89
|
+
async function publicGet<T>(path: string, opts: PublicGetOptions = {}): Promise<T | null> {
|
|
90
|
+
return (await publicGetWithMeta<T>(path, opts))?.data ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** For data a page can do without. Null on any failure, so the page still renders. */
|
|
94
|
+
async function publicGetOptional<T>(path: string, opts: PublicGetOptions = {}): Promise<T | null> {
|
|
95
|
+
try {
|
|
96
|
+
return await publicGet<T>(path, opts);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof PublicApiError) return null;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { publicGet, publicGetOptional, publicGetWithMeta };
|
|
104
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
2
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
3
|
+
import jwt from "jsonwebtoken";
|
|
4
|
+
import { getToken, type JWT } from "next-auth/jwt";
|
|
5
|
+
import { ABSOLUTE_SESSION_MAX_AGE, CLOCK_TOLERANCE_S, isTerminalSessionError } from "../session/expiry";
|
|
6
|
+
|
|
7
|
+
export interface RevalidateRouteOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Each target the client may send, with the page paths it purges. A path with a dynamic segment,
|
|
10
|
+
* such as `/builds/[slug]`, purges every page of that route. The target name is also purged as a
|
|
11
|
+
* cache tag, so tag fetches with it.
|
|
12
|
+
*/
|
|
13
|
+
targets: Record<string, readonly string[]>;
|
|
14
|
+
/** The role allowed to purge, as it appears in the session user's roles. */
|
|
15
|
+
role: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Token = JWT | null;
|
|
19
|
+
|
|
20
|
+
const rolesOf = (token: Token): string[] => (token?.user as { roles?: string[] } | undefined)?.roles ?? [];
|
|
21
|
+
|
|
22
|
+
// getToken only decrypts the cookie, without the jwt callback. So the checks that callback makes are
|
|
23
|
+
// made here: a refused refresh still carries its roles, and the cookie outlives the seven days. The
|
|
24
|
+
// roles must also come from an access token that has not expired, so a copied cookie of a user who
|
|
25
|
+
// lost the role stops working within the token's lifetime.
|
|
26
|
+
function isLive(token: Token): boolean {
|
|
27
|
+
if (!token || isTerminalSessionError(token.error as string | undefined)) return false;
|
|
28
|
+
const loginAt = token.loginAt;
|
|
29
|
+
if (typeof loginAt !== "number" || Date.now() - loginAt > ABSOLUTE_SESSION_MAX_AGE) return false;
|
|
30
|
+
return accessTokenValid(token.accessToken);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The cookie is encrypted and authenticated with NEXTAUTH_SECRET, so the access token in it is the
|
|
34
|
+
// one the API issued, and its expiry can be read without verifying the signature again.
|
|
35
|
+
function accessTokenValid(accessToken: unknown): boolean {
|
|
36
|
+
if (typeof accessToken !== "string") return false;
|
|
37
|
+
const claims = jwt.decode(accessToken);
|
|
38
|
+
const exp = claims && typeof claims === "object" ? claims.exp : undefined;
|
|
39
|
+
return typeof exp === "number" && exp * 1000 + CLOCK_TOLERANCE_S * 1000 > Date.now();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A `POST` route handler that purges cached pages after an edit, for `app/api/revalidate/route.ts`.
|
|
44
|
+
* It answers 403 unless the caller has a live session with `role`, 415 unless the body is JSON, and
|
|
45
|
+
* 400 for a target it does not know.
|
|
46
|
+
*/
|
|
47
|
+
export function createRevalidateRoute({ targets, role }: RevalidateRouteOptions) {
|
|
48
|
+
return async function POST(req: NextRequest): Promise<NextResponse> {
|
|
49
|
+
// getToken reads NEXTAUTH_SECRET itself, the same way next-auth does. It is asked for both cookie
|
|
50
|
+
// names, because behind a TLS proxy next-auth can sign in with the secure cookie while getToken
|
|
51
|
+
// would look for the plain one.
|
|
52
|
+
const token = (await getToken({ req, secureCookie: true })) ?? (await getToken({ req, secureCookie: false }));
|
|
53
|
+
if (!isLive(token) || !rolesOf(token).includes(role)) {
|
|
54
|
+
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// JSON only, so a plain form on a sibling subdomain cannot post here with the session cookie.
|
|
58
|
+
if (!req.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
59
|
+
return NextResponse.json({ message: "Expected JSON" }, { status: 415 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body: unknown = await req.json().catch(() => null);
|
|
63
|
+
const target = (body as { target?: unknown } | null)?.target;
|
|
64
|
+
if (typeof target !== "string" || !Object.hasOwn(targets, target)) {
|
|
65
|
+
return NextResponse.json({ message: "Unknown target" }, { status: 400 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const path of targets[target] ?? []) {
|
|
69
|
+
if (path.includes("[")) revalidatePath(path, "page");
|
|
70
|
+
else revalidatePath(path);
|
|
71
|
+
}
|
|
72
|
+
// By tag as well, because a path purge misses fetches that another route rendered. expire 0
|
|
73
|
+
// makes them stale at once.
|
|
74
|
+
revalidateTag(target, { expire: 0 });
|
|
75
|
+
return NextResponse.json({ revalidated: true, target });
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/session/expiry.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** How long a session lasts after sign-in, however often it is refreshed. Seven days. */
|
|
2
|
+
export const ABSOLUTE_SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
/** How far this server's clock may differ from the API's when a token's times are checked. */
|
|
5
|
+
export const CLOCK_TOLERANCE_S = 60;
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Errors the jwt callback sets when the session cannot be recovered without a new sign-in. A
|
|
3
9
|
* transient refresh failure, such as a network error or an API restart, sets no error, so the
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SessionProvider } from "next-auth/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
/** Four minutes, shorter than the five minutes before expiry when a refresh falls due. */
|
|
7
|
+
const SESSION_POLL_SECONDS = 4 * 60;
|
|
8
|
+
|
|
9
|
+
export interface AppSessionProviderProps {
|
|
10
|
+
/** Seconds between session reads. Defaults to four minutes. */
|
|
11
|
+
refetchInterval?: number | undefined;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* next-auth's `SessionProvider`, reading the session again on a timer. Each read lets the jwt
|
|
17
|
+
* callback renew the access token ahead of expiry, so an open tab stays usable and does not find a
|
|
18
|
+
* dead session on the next save. It also keeps what `useSession` reports in this tab up to date.
|
|
19
|
+
* next-auth polls only once a session exists, so signed-out visitors cost nothing.
|
|
20
|
+
*/
|
|
21
|
+
export function AppSessionProvider({ refetchInterval = SESSION_POLL_SECONDS, children }: AppSessionProviderProps) {
|
|
22
|
+
return <SessionProvider refetchInterval={refetchInterval}>{children}</SessionProvider>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
4
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
5
|
+
import { useSessionGate } from "./useSessionGate";
|
|
6
|
+
|
|
7
|
+
interface RoleGateBase {
|
|
8
|
+
/** The role the page needs, as it appears in `session.user.roles`. */
|
|
9
|
+
role: string;
|
|
10
|
+
/** What shows while the session or its roles are not known yet. */
|
|
11
|
+
fallback?: ReactNode | undefined;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Shows `denied` to a signed-in user without the role. */
|
|
16
|
+
interface RoleGateWithDenied extends RoleGateBase {
|
|
17
|
+
denied: ReactNode;
|
|
18
|
+
deniedHref?: never;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Sends a signed-in user without the role to `deniedHref`. */
|
|
22
|
+
interface RoleGateWithDeniedHref extends RoleGateBase {
|
|
23
|
+
deniedHref: string;
|
|
24
|
+
denied?: never;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RoleGateProps = RoleGateWithDenied | RoleGateWithDeniedHref;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders its children only for a user with `role`. Place it inside `ProtectedGate`, which decides
|
|
31
|
+
* whether the session may render at all.
|
|
32
|
+
*
|
|
33
|
+
* While the re-sign-in prompt recovers a signed-out session there is no user, so the roles are
|
|
34
|
+
* unknown. The page stays up only on the path where the session last had the role.
|
|
35
|
+
*/
|
|
36
|
+
export function RoleGate(props: RoleGateProps) {
|
|
37
|
+
const { role, fallback = null, children } = props;
|
|
38
|
+
const { session, recovering, mayRender } = useSessionGate();
|
|
39
|
+
const pathname = usePathname();
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
|
|
42
|
+
const roles = (session?.user as { roles?: string[] } | undefined)?.roles ?? [];
|
|
43
|
+
const hasRole = roles.includes(role);
|
|
44
|
+
const rolesKnown = session?.user != null;
|
|
45
|
+
|
|
46
|
+
// The path where the session last had the role. A user without it clears the pass. Set during
|
|
47
|
+
// render, the same way useSessionGate tracks the path it last passed. It starts as undefined, not
|
|
48
|
+
// null, because usePathname can return null.
|
|
49
|
+
const [roleAt, setRoleAt] = useState<string | null | undefined>(undefined);
|
|
50
|
+
if (hasRole && roleAt !== pathname) setRoleAt(pathname);
|
|
51
|
+
if (rolesKnown && !hasRole && roleAt !== undefined) setRoleAt(undefined);
|
|
52
|
+
const keepThroughRecovery = recovering && roleAt === pathname;
|
|
53
|
+
|
|
54
|
+
const allowed = hasRole || keepThroughRecovery;
|
|
55
|
+
const denied = mayRender && rolesKnown && !allowed;
|
|
56
|
+
const deniedHref = "deniedHref" in props ? props.deniedHref : undefined;
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
// replace, so Back does not return to a page that only redirects again.
|
|
60
|
+
if (denied && deniedHref != null) router.replace(deniedHref);
|
|
61
|
+
}, [denied, deniedHref, router]);
|
|
62
|
+
|
|
63
|
+
if (mayRender && allowed) return children;
|
|
64
|
+
if (denied && "denied" in props) return props.denied;
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
@@ -6,3 +6,7 @@ export { defaultSessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
|
|
|
6
6
|
export type { SessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
|
|
7
7
|
export { useSessionGate } from "./useSessionGate";
|
|
8
8
|
export type { SessionGate } from "./useSessionGate";
|
|
9
|
+
export { AppSessionProvider } from "./AppSessionProvider";
|
|
10
|
+
export type { AppSessionProviderProps } from "./AppSessionProvider";
|
|
11
|
+
export { RoleGate } from "./RoleGate";
|
|
12
|
+
export type { RoleGateProps } from "./RoleGate";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { toast as sonnerToast } from "sonner";
|
|
2
|
+
import { getSessionPromptOpen } from "../session/expiry";
|
|
3
|
+
|
|
4
|
+
// While the re-sign-in prompt is open it already tells the user why a request failed, so error
|
|
5
|
+
// toasts stay quiet. Every other toast passes through.
|
|
6
|
+
const error: typeof sonnerToast.error = (...args) => (getSessionPromptOpen() ? "" : sonnerToast.error(...args));
|
|
7
|
+
|
|
8
|
+
/** sonner's `toast`, except that error toasts stay quiet while the re-sign-in prompt is open. */
|
|
9
|
+
export const toast: typeof sonnerToast = new Proxy(sonnerToast, {
|
|
10
|
+
get: (target, property, receiver) => (property === "error" ? error : Reflect.get(target, property, receiver)),
|
|
11
|
+
});
|