@sjolystinnovation/app-kit 0.5.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 +219 -43
- 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/ProtectedGate.tsx +34 -0
- package/src/session/react/RoleGate.tsx +66 -0
- package/src/session/react/SessionExpiryGuard.tsx +294 -0
- package/src/session/react/index.ts +10 -0
- package/src/session/react/sessionExpiryGuardStrings.ts +26 -0
- package/src/styles.css +16 -3
- package/src/toast/index.ts +11 -0
- package/src/ui/Alert.tsx +4 -3
- package/src/ui/CredentialsForm.tsx +113 -0
- package/src/ui/PasswordInput.tsx +11 -29
- 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
package/README.md
CHANGED
|
@@ -28,8 +28,8 @@ const nextConfig: NextConfig = {
|
|
|
28
28
|
};
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
The components in `@sjolystinnovation/app-kit/ui` are styled with Tailwind CSS.
|
|
32
|
-
package's stylesheet after Tailwind:
|
|
31
|
+
The components in `@sjolystinnovation/app-kit/ui` and `session/react` are styled with Tailwind CSS.
|
|
32
|
+
Import the package's stylesheet after Tailwind:
|
|
33
33
|
|
|
34
34
|
```css
|
|
35
35
|
/* src/app/globals.css */
|
|
@@ -260,40 +260,108 @@ 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
|
|
266
315
|
callbacks.
|
|
267
316
|
|
|
268
|
-
The gate reads `session.error`. `
|
|
269
|
-
|
|
317
|
+
The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
|
|
318
|
+
countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
|
|
319
|
+
to fill in the prompt. `createAuthOptions` sets all of these. An app with its own next-auth options sets them in its
|
|
320
|
+
callbacks:
|
|
270
321
|
|
|
271
322
|
```ts
|
|
323
|
+
const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
|
324
|
+
|
|
272
325
|
callbacks: {
|
|
326
|
+
jwt({ token, user }) {
|
|
327
|
+
if (user) token.loginAt = Date.now();
|
|
328
|
+
// A token without a sign-in time counts as expired.
|
|
329
|
+
if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
|
|
330
|
+
token.error = SESSION_ERRORS.absoluteExpiry;
|
|
331
|
+
}
|
|
332
|
+
// Also set token.error when a refresh is refused.
|
|
333
|
+
return token;
|
|
334
|
+
},
|
|
273
335
|
session({ session, token }) {
|
|
336
|
+
session.user.id = token.sub ?? "";
|
|
274
337
|
session.error = token.error;
|
|
338
|
+
session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
|
|
275
339
|
return session;
|
|
276
340
|
},
|
|
277
341
|
},
|
|
278
342
|
```
|
|
279
343
|
|
|
280
|
-
For that to compile,
|
|
344
|
+
For that to compile, these fields have to be on next-auth's `Session` and `JWT` types. Add them to
|
|
281
345
|
the app's existing next-auth type declarations, or create them:
|
|
282
346
|
|
|
283
347
|
```ts
|
|
284
348
|
// src/types/next-auth.d.ts
|
|
285
349
|
import "next-auth";
|
|
286
350
|
import "next-auth/jwt";
|
|
351
|
+
import type { DefaultSession } from "next-auth";
|
|
287
352
|
|
|
288
353
|
declare module "next-auth" {
|
|
289
354
|
interface Session {
|
|
355
|
+
user: { id: string } & DefaultSession["user"];
|
|
290
356
|
error?: string | undefined;
|
|
357
|
+
absoluteExpiresAt?: number | undefined;
|
|
291
358
|
}
|
|
292
359
|
}
|
|
293
360
|
|
|
294
361
|
declare module "next-auth/jwt" {
|
|
295
362
|
interface JWT {
|
|
296
363
|
error?: string | undefined;
|
|
364
|
+
loginAt?: number | undefined;
|
|
297
365
|
}
|
|
298
366
|
}
|
|
299
367
|
```
|
|
@@ -335,37 +403,8 @@ A session that turns signed out with no prompt open blanks the page. A page the
|
|
|
335
403
|
dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
|
|
336
404
|
them blanks a page that another one kept.
|
|
337
405
|
|
|
338
|
-
The hook does not navigate.
|
|
339
|
-
|
|
340
|
-
for the current locale:
|
|
341
|
-
|
|
342
|
-
```tsx
|
|
343
|
-
"use client";
|
|
344
|
-
|
|
345
|
-
import { useSessionGate } from "@sjolystinnovation/app-kit/session/react";
|
|
346
|
-
import { useRouter } from "next/navigation";
|
|
347
|
-
import { useEffect } from "react";
|
|
348
|
-
|
|
349
|
-
export function ProtectedGate({
|
|
350
|
-
loginHref,
|
|
351
|
-
children,
|
|
352
|
-
}: {
|
|
353
|
-
loginHref: string;
|
|
354
|
-
children: React.ReactNode;
|
|
355
|
-
}) {
|
|
356
|
-
const router = useRouter();
|
|
357
|
-
const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
|
|
358
|
-
|
|
359
|
-
useEffect(() => {
|
|
360
|
-
if (promptOpen) return;
|
|
361
|
-
if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
|
|
362
|
-
router.push(loginHref);
|
|
363
|
-
}
|
|
364
|
-
}, [status, usable, wasUsable, promptOpen, router, loginHref]);
|
|
365
|
-
|
|
366
|
-
return mayRender ? children : <p>Loading…</p>;
|
|
367
|
-
}
|
|
368
|
-
```
|
|
406
|
+
The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
|
|
407
|
+
layout that needs its own decision, such as a role check, so it agrees with the gate.
|
|
369
408
|
|
|
370
409
|
| Field | Description |
|
|
371
410
|
| --- | --- |
|
|
@@ -378,6 +417,84 @@ export function ProtectedGate({
|
|
|
378
417
|
|
|
379
418
|
`SessionGate` is the type of the result.
|
|
380
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
|
+
|
|
435
|
+
### `ProtectedGate`
|
|
436
|
+
|
|
437
|
+
From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It renders the
|
|
438
|
+
page only when `useSessionGate` allows it, and sends a signed-out visitor or a session dead on
|
|
439
|
+
arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
|
|
440
|
+
|
|
441
|
+
```tsx
|
|
442
|
+
<ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
|
|
443
|
+
{children}
|
|
444
|
+
</ProtectedGate>
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
| Prop | Type | Description |
|
|
448
|
+
| --- | --- | --- |
|
|
449
|
+
| `loginHref` | `string` | Where the redirect goes. An app with a locale in the path builds it for the current locale. |
|
|
450
|
+
| `fallback` | `ReactNode` | What shows while the page may not render yet. Defaults to nothing. |
|
|
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
|
+
|
|
471
|
+
### `SessionExpiryGuard`
|
|
472
|
+
|
|
473
|
+
From `@sjolystinnovation/app-kit/session/react`. Render it once, inside the `SessionProvider`. It
|
|
474
|
+
shows:
|
|
475
|
+
|
|
476
|
+
- a banner in the last 30 minutes before `session.absoluteExpiresAt`, with the minutes left. That is
|
|
477
|
+
seven days after sign-in with `createAuthOptions`.
|
|
478
|
+
- a banner when the session is dead
|
|
479
|
+
- the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
|
|
480
|
+
|
|
481
|
+
A sign-in in the prompt keeps the page and its form. A sign-in in the prompt as a different user is
|
|
482
|
+
signed out and sent to `loginHref`, so the prompt never hands the page to another user.
|
|
483
|
+
|
|
484
|
+
```tsx
|
|
485
|
+
<SessionExpiryGuard
|
|
486
|
+
loginHref="/login"
|
|
487
|
+
onRestored={() => toast.success("You are signed in again.")}
|
|
488
|
+
/>
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
| Prop | Type | Description |
|
|
492
|
+
| --- | --- | --- |
|
|
493
|
+
| `loginHref` | `string` | Where a sign-in as a different user is sent after it is signed out. |
|
|
494
|
+
| `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
|
|
495
|
+
| `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
|
|
496
|
+
| `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
|
|
497
|
+
|
|
381
498
|
### `useFormDraft(config, options)`
|
|
382
499
|
|
|
383
500
|
From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
|
|
@@ -415,6 +532,40 @@ Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook t
|
|
|
415
532
|
`draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
|
|
416
533
|
removed when its form next reads it.
|
|
417
534
|
|
|
535
|
+
### `CredentialsForm`
|
|
536
|
+
|
|
537
|
+
From `@sjolystinnovation/app-kit/ui`. Email and password sign-in through next-auth's credentials
|
|
538
|
+
provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
|
|
539
|
+
the prompt.
|
|
540
|
+
|
|
541
|
+
```tsx
|
|
542
|
+
<CredentialsForm onSignedIn={() => router.push("/")} strings={{ signIn: "Logg inn" }} />
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
| Prop | Type | Description |
|
|
546
|
+
| --- | --- | --- |
|
|
547
|
+
| `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected` with a message to refuse the sign-in. |
|
|
548
|
+
| `initialEmail` | `string` | The email the field starts with. |
|
|
549
|
+
| `children` | `ReactNode` | Extra buttons next to the submit button. |
|
|
550
|
+
| `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
|
|
551
|
+
| `onPendingChange` | `(pending: boolean) => void` | Runs when a sign-in starts and ends, such as to keep a dialog open until it finishes. |
|
|
552
|
+
|
|
553
|
+
A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
|
|
554
|
+
`invalidCredentials` for any other error code. A request next-auth refuses without an error code,
|
|
555
|
+
or one that throws, shows `somethingWentWrong`.
|
|
556
|
+
|
|
557
|
+
`TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
|
|
558
|
+
key is optional and may be `undefined`, and a missing or undefined key shows the English default.
|
|
559
|
+
|
|
560
|
+
### `TextInput`
|
|
561
|
+
|
|
562
|
+
From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look and props as
|
|
563
|
+
`PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
|
|
564
|
+
|
|
565
|
+
```tsx
|
|
566
|
+
<TextInput label="Email" name="email" type="email" required error={errors.email} />
|
|
567
|
+
```
|
|
568
|
+
|
|
418
569
|
### `Alert`
|
|
419
570
|
|
|
420
571
|
```tsx
|
|
@@ -426,7 +577,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
|
|
|
426
577
|
| Prop | Type | Default | Description |
|
|
427
578
|
| --- | --- | --- | --- |
|
|
428
579
|
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
|
|
429
|
-
| `role` | `"alert" \| "status"` | `"alert"` |
|
|
580
|
+
| `role` | `"alert" \| "status" \| "none"` | `"alert"` | A screen reader announces `status` without interrupting, on every change of its text. Use `none` when a separate live region announces the text. |
|
|
430
581
|
| `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables. A clashing class wins or loses by the order Tailwind emits them in. |
|
|
431
582
|
| `children` | `ReactNode` | | Content. |
|
|
432
583
|
|
|
@@ -452,6 +603,16 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
|
452
603
|
|
|
453
604
|
Pass the two toggle labels in the app's language. The defaults are English.
|
|
454
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
|
+
|
|
455
616
|
## Theming
|
|
456
617
|
|
|
457
618
|
The components draw every colour, corner radius and label size from theme variables with a light
|
|
@@ -495,10 +656,22 @@ selector such as `.dark` for a scoped theme:
|
|
|
495
656
|
| `--input-focus-ring-width` | `2px` |
|
|
496
657
|
| `--color-input-toggle` | gray 400, the show password icon |
|
|
497
658
|
| `--color-input-toggle-hover` | gray 600 |
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
`--color-
|
|
659
|
+
| `--radius-button` | `var(--radius-lg)` |
|
|
660
|
+
| `--color-button-primary-bg` | `var(--color-primary)` |
|
|
661
|
+
| `--color-button-primary-text` | `var(--color-primary-foreground)` |
|
|
662
|
+
| `--color-button-secondary-border` | gray 300 |
|
|
663
|
+
| `--color-button-secondary-text` | gray 700 |
|
|
664
|
+
| `--color-button-secondary-hover-bg` | gray 50 |
|
|
665
|
+
| `--radius-dialog` | `var(--radius-xl)` |
|
|
666
|
+
| `--color-dialog-backdrop` | black at 40% |
|
|
667
|
+
| `--color-dialog-bg` | white |
|
|
668
|
+
| `--color-dialog-title` | gray 900 |
|
|
669
|
+
| `--color-dialog-text` | gray 600 |
|
|
670
|
+
|
|
671
|
+
A default that points at another variable is read once for the whole page. The focus ring and the
|
|
672
|
+
primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
|
|
673
|
+
changes those also sets `--color-input-focus-ring`, `--color-button-primary-bg` and
|
|
674
|
+
`--color-button-primary-text` in the same selector.
|
|
502
675
|
|
|
503
676
|
An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
|
|
504
677
|
with it and has to set the ones it uses itself.
|
|
@@ -507,9 +680,12 @@ with it and has to set the ones it uses itself.
|
|
|
507
680
|
|
|
508
681
|
- Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
|
|
509
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.
|
|
510
685
|
- `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
|
|
511
|
-
- For `@sjolystinnovation/app-kit/ui
|
|
512
|
-
the theme's `primary`
|
|
686
|
+
- For `@sjolystinnovation/app-kit/ui` and the components in `session/react`: Tailwind CSS 4 and
|
|
687
|
+
`@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
|
|
688
|
+
`primary-foreground` colours, unless their own variables are set.
|
|
513
689
|
- TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
|
|
514
690
|
`node16` and `nodenext` reject.
|
|
515
691
|
|
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
|
+
}
|