@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 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.6.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
@@ -1,2 +1,3 @@
1
1
  export { createApiClient } from "./createApiClient";
2
2
  export { request } from "./request";
3
+ export { requestRevalidate } from "./requestRevalidate";
@@ -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
+ }
@@ -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
+ });