@sjolystinnovation/app-kit 0.21.0 → 0.22.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.
Files changed (74) hide show
  1. package/README.md +184 -265
  2. package/package.json +1 -1
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +9 -3
  5. package/src/admin/CompanySettingsForm.tsx +2 -1
  6. package/src/admin/LegalPagesEditor.tsx +5 -4
  7. package/src/admin/LocaleTabs.tsx +4 -3
  8. package/src/admin/StatHistoryChart.tsx +4 -3
  9. package/src/admin/adminUsersPanelStrings.ts +3 -3
  10. package/src/admin/legalPagesEditorStrings.ts +1 -1
  11. package/src/admin/statHistoryChartStrings.ts +3 -2
  12. package/src/api/apiFieldErrors.ts +4 -4
  13. package/src/api/company.ts +4 -3
  14. package/src/api/createApiClient.ts +3 -2
  15. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  16. package/src/api/index.ts +2 -1
  17. package/src/api/request.ts +2 -2
  18. package/src/api/revalidateTargets.ts +7 -5
  19. package/src/api/services.ts +14 -13
  20. package/src/auth/createAuthOptions.ts +7 -5
  21. package/src/branding/BrandingManager.tsx +10 -5
  22. package/src/branding/BrandingProvider.tsx +5 -3
  23. package/src/branding/index.ts +1 -2
  24. package/src/branding/server.ts +10 -9
  25. package/src/branding/types.ts +3 -3
  26. package/src/config.ts +10 -10
  27. package/src/forms/useFormDraft.ts +9 -7
  28. package/src/index.ts +1 -3
  29. package/src/legal/LegalPageView.tsx +6 -5
  30. package/src/markdown/Markdown.tsx +12 -10
  31. package/src/markdown/index.ts +1 -1
  32. package/src/next-auth.d.ts +11 -9
  33. package/src/next-config/headers.ts +96 -0
  34. package/src/next-config/index.ts +18 -107
  35. package/src/payments/PaymentReturn.tsx +9 -7
  36. package/src/payments/formatNok.ts +1 -1
  37. package/src/payments/usePollUntilFinal.ts +7 -5
  38. package/src/seo/JsonLd.tsx +1 -1
  39. package/src/seo/environment.ts +3 -3
  40. package/src/seo/index.ts +0 -1
  41. package/src/seo/og/index.tsx +5 -4
  42. package/src/seo/schema.ts +6 -2
  43. package/src/seo/site.ts +44 -32
  44. package/src/seo/sitemap.ts +2 -2
  45. package/src/server/publicApi.ts +13 -9
  46. package/src/server/revalidateRoute.ts +10 -8
  47. package/src/session/expiry.ts +8 -5
  48. package/src/session/react/AppSessionProvider.tsx +3 -2
  49. package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
  50. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  51. package/src/session/react/ProtectedGate.tsx +3 -2
  52. package/src/session/react/RoleGate.tsx +4 -3
  53. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  54. package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
  55. package/src/session/react/index.ts +8 -2
  56. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  57. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  58. package/src/session/react/useSessionGate.ts +3 -3
  59. package/src/testing/index.ts +5 -5
  60. package/src/ui/Alert.tsx +6 -4
  61. package/src/ui/ContentImage.tsx +12 -11
  62. package/src/ui/ErrorState.tsx +5 -3
  63. package/src/ui/NotFoundState.tsx +9 -7
  64. package/src/ui/PasswordInput.tsx +6 -5
  65. package/src/ui/ResetPasswordFlow.tsx +10 -8
  66. package/src/ui/TextArea.tsx +6 -4
  67. package/src/ui/TextInput.tsx +4 -2
  68. package/src/ui/Toggle.tsx +3 -3
  69. package/src/ui/field.tsx +5 -3
  70. package/src/ui/index.ts +2 -10
  71. package/src/ui/resetPasswordFlowStrings.ts +7 -7
  72. package/src/validation/fieldErrors.ts +4 -3
  73. package/src/validation/password.ts +5 -5
  74. package/src/validation/passwordRules.ts +2 -2
@@ -2,22 +2,23 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import type { Branding } from "./types";
4
4
 
5
+ /** Options for `brandingIcon`. */
5
6
  export interface BrandingIconOptions {
6
- /** Loads the branding, such as a cached GET of `/branding`. Throws when the API cannot be reached. */
7
+ /** A function that loads the branding, such as a cached GET of `/branding`. It throws when the API cannot be reached. */
7
8
  load: () => Promise<Branding | null | undefined>;
8
9
  /** The bundled icon for when none is set, such as `public/icon.png`. */
9
10
  fallbackPath: string;
10
- /** Its content type, such as `image/png` or `image/svg+xml`. */
11
+ /** The bundled icon's content type, such as `image/png` or `image/svg+xml`. */
11
12
  fallbackType: string;
12
13
  }
13
14
 
14
15
  /**
15
- * The response for an `app/icon.ts` route: the favicon set in admin settings when it is a PNG,
16
- * JPEG, WebP, GIF or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox CSP
17
- * that a CSP header from the app's `next.config` replaces.
18
- * When the branding cannot be loaded it answers 503 with `no-store`, not the
19
- * bundled icon, since a browser would cache that in place of the one set in settings. The route
20
- * needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment.
16
+ * Builds the response for an `app/icon.ts` route: the favicon set in admin settings when it is a
17
+ * PNG, JPEG, WebP, GIF, or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox
18
+ * CSP that a CSP header from the app's `next.config` replaces. When the branding cannot be loaded,
19
+ * it answers 503 with `no-store` instead of the bundled icon, since a browser would cache that in
20
+ * place of the one set in settings. The route needs `dynamic = "force-dynamic"`, or the build bakes
21
+ * in the icon of the moment.
21
22
  */
22
23
  export async function brandingIcon({ load, fallbackPath, fallbackType }: BrandingIconOptions): Promise<Response> {
23
24
  let branding: Branding | null | undefined;
@@ -47,7 +48,7 @@ const RASTER_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gi
47
48
  const HARDENING = { "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; sandbox" };
48
49
 
49
50
  /**
50
- * A short digest of icon bytes, for a URL that changes when the icon does, such as
51
+ * Computes a short digest of icon bytes, for a URL that changes when the icon does, such as
51
52
  * `/icon?v=<digest>` in the web app manifest. Android refreshes a home screen icon only when the
52
53
  * manifest changes.
53
54
  */
@@ -1,13 +1,13 @@
1
1
  /** The logo and favicon set in admin settings, as the API's `/branding` endpoint returns them. */
2
2
  export interface Branding {
3
- /** Base64 image bytes. */
3
+ /** The logo's image bytes, in base64. */
4
4
  logoData?: string | null | undefined;
5
5
  logoContentType?: string | null | undefined;
6
- /** Base64 image bytes. */
6
+ /** The favicon's image bytes, in base64. */
7
7
  iconData?: string | null | undefined;
8
8
  iconContentType?: string | null | undefined;
9
9
  }
10
10
 
11
- /** A `data:` URL for base64 image bytes, or null when either part is missing. */
11
+ /** Builds a `data:` URL for base64 image bytes. Returns null when either part is missing. */
12
12
  export const toDataUrl = (data?: string | null, contentType?: string | null): string | null =>
13
13
  data && contentType ? `data:${contentType};base64,${data}` : null;
package/src/config.ts CHANGED
@@ -4,18 +4,18 @@
4
4
  */
5
5
  export interface SessionConfig {
6
6
  /**
7
- * Name of the environment variable holding the API JWT secret, such as API_JWT_SECRET. Apps that
8
- * talk to more than one API, or that deploy several apps side by side, need distinct names.
7
+ * The name of the environment variable holding the API JWT secret, such as `API_JWT_SECRET`. Apps
8
+ * that talk to more than one API, or that deploy several apps side by side, need distinct names.
9
9
  */
10
10
  jwtSecretEnvVar: string;
11
11
  /**
12
- * Where an expired or rejected session sends the browser. An app with a locale prefix uses
13
- * something like "/en/login". An app without one uses "/login".
12
+ * The path an expired or rejected session sends the browser to. An app with a locale prefix uses
13
+ * something like `"/en/login"`. An app without one uses `"/login"`.
14
14
  */
15
15
  loginRoute: string;
16
16
  /**
17
- * Prefix for the localStorage keys holding form drafts, usually the app's name, such as
18
- * "example". Drafts are stored under `<prefix>:draft:<owner>:<scope>`, so the prefix itself
17
+ * The prefix for the localStorage keys holding form drafts, usually the app's name, such as
18
+ * `"example"`. Drafts are stored under `<prefix>:draft:<owner>:<scope>`, so the prefix itself
19
19
  * has no trailing colon.
20
20
  */
21
21
  draftStoragePrefix: string;
@@ -31,8 +31,8 @@ export function defineSessionConfig(config: SessionConfig): SessionConfig {
31
31
  }
32
32
 
33
33
  /**
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:".
34
+ * Gets 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;
@@ -57,8 +57,8 @@ export function requireEnv(name: string, value: string | undefined): string {
57
57
  * Reads the API JWT secret named by the config. Throws when it is missing, with a message that names
58
58
  * the variable.
59
59
  *
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.
60
+ * The caller passes the environment, usually `process.env`, so this module needs no Node types and
61
+ * stays safe to import in browser code. `createAuthOptions` uses it. It is not part of the public API.
62
62
  */
63
63
  export function resolveJwtSecret(
64
64
  config: SessionConfig,
@@ -32,6 +32,7 @@ function read<T>(storageKey: string): T | null {
32
32
  }
33
33
  }
34
34
 
35
+ /** Options for `useFormDraft`. */
35
36
  export interface FormDraftOptions<T> {
36
37
  /**
37
38
  * The signed-in user's id. A browser profile can be shared, so drafts are stored per user. That
@@ -40,27 +41,28 @@ export interface FormDraftOptions<T> {
40
41
  * known.
41
42
  */
42
43
  owner: string | undefined;
43
- /** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
44
+ /** The key for the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
44
45
  scope: string;
45
46
  /** The form's current values. They must survive `JSON.stringify`. */
46
47
  value: T;
47
48
  }
48
49
 
50
+ /** What `useFormDraft` returns. */
49
51
  export interface FormDraft<T> {
50
52
  /**
51
- * The stored draft, while it is still waiting to be restored or dismissed. Read once the owner is
52
- * known and again whenever the owner or scope changes, so it can appear after mount.
53
+ * The stored draft, while it is still waiting to be restored or dismissed. The hook reads it once
54
+ * the owner is known and again whenever the owner or scope changes, so it can appear after mount.
53
55
  */
54
56
  pending: T | null;
55
- /** Stop offering the draft. The caller applies the values it wants. */
57
+ /** Stops offering the draft. The caller applies the values it wants. */
56
58
  dismiss: () => void;
57
- /** Drop the stored draft after a successful save. */
59
+ /** Drops the stored draft. Call it after a successful save. */
58
60
  clear: () => void;
59
61
  }
60
62
 
61
63
  /**
62
- * Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
63
- * Values are written half a second after the last change, so only that last half second is at risk.
64
+ * Keeps a form's values in localStorage so a sign-out, reload, or closed tab does not lose them. The
65
+ * hook writes them half a second after the last change, so only that last half second is at risk.
64
66
  *
65
67
  * A draft found at mount is offered for restore, not applied. So an edit form never silently
66
68
  * overwrites what the API returned. Saving starts at once, whether or not the offer is answered.
package/src/index.ts CHANGED
@@ -1,4 +1,2 @@
1
- export { defineSessionConfig, requireEnv, resolveJwtSecret } from "./config";
1
+ export { defineSessionConfig, requireEnv } from "./config";
2
2
  export type { SessionConfig } from "./config";
3
- export { formatApiError } from "./errors";
4
- export type { StatusMessages } from "./errors";
@@ -6,11 +6,11 @@ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
6
6
 
7
7
  /** The legal page's text around the content. Pass the app's own language. The defaults are English. */
8
8
  export interface LegalPageViewStrings {
9
- /** Before the date the page last changed. */
9
+ /** The text before the date the page last changed. */
10
10
  updated: string;
11
- /** Shown in place of the content when nobody has written the page yet. */
11
+ /** The text shown in place of the content when nobody has written the page yet. */
12
12
  missing: string;
13
- /** The link back to the front page. */
13
+ /** The text of the link back to the front page. */
14
14
  toFrontPage: string;
15
15
  }
16
16
 
@@ -20,19 +20,20 @@ export const defaultLegalPageViewStrings: LegalPageViewStrings = {
20
20
  toFrontPage: "Back to the front page",
21
21
  };
22
22
 
23
+ /** Props for `LegalPageView`. */
23
24
  export interface LegalPageViewProps {
24
25
  /** The page from the API, or null when nobody has written it yet. */
25
26
  page: LegalPage | null;
26
27
  /** The heading while there is no page. */
27
28
  title: string;
28
- /** The front page, for the link at the bottom. */
29
+ /** The front page's path, for the link at the bottom. */
29
30
  homeHref: string;
30
31
  /** The BCP 47 tag the date is written in, such as `nb-NO`. */
31
32
  dateLocale: string;
32
33
  strings?: TextOverrides<LegalPageViewStrings> | undefined;
33
34
  }
34
35
 
35
- /** A terms, privacy or cookies page as an admin wrote it: the title, when it last changed and the Markdown body. */
36
+ /** Renders a terms, privacy, or cookies page as an admin wrote it: the title, when it last changed, and the Markdown body. */
36
37
  export function LegalPageView({ page, title, homeHref, dateLocale, strings }: LegalPageViewProps) {
37
38
  const text = withDefaults(defaultLegalPageViewStrings, strings);
38
39
  // A malformed timestamp leaves the date out rather than failing the page.
@@ -5,20 +5,20 @@ import remarkGfm from "remark-gfm";
5
5
 
6
6
  type Tag = keyof React.JSX.IntrinsicElements;
7
7
 
8
- /** The props without the `node` react-markdown passes, which is no DOM attribute. */
8
+ /** Copies the props without the `node` that react-markdown passes, since `node` is no DOM attribute. */
9
9
  function domProps<P extends ExtraProps>(props: P): Omit<P, "node"> {
10
10
  const rest = { ...props };
11
11
  delete rest.node;
12
12
  return rest;
13
13
  }
14
14
 
15
- /** An app's element, given the props without `node`, so it can spread them onto a DOM element. */
15
+ /** Wraps an app's element so it gets the props without `node` and can spread them onto a DOM element. */
16
16
  function withoutNode(Element: ElementType) {
17
17
  const Wrapped = (props: ExtraProps) => <Element {...domProps(props)} />;
18
18
  return Wrapped;
19
19
  }
20
20
 
21
- /** An element with fixed classes. */
21
+ /** Creates an element with fixed classes. */
22
22
  function styled<T extends Tag>(Element: T, className: string) {
23
23
  const Styled = (props: ComponentPropsWithoutRef<T> & ExtraProps) => {
24
24
  const Component = Element as ElementType;
@@ -27,7 +27,7 @@ function styled<T extends Tag>(Element: T, className: string) {
27
27
  return Styled;
28
28
  }
29
29
 
30
- /** The default look. A heading level one becomes a level two, since the page has its own `h1`. */
30
+ /** The default elements. A level one heading becomes a level two heading, since the page has its own `h1`. */
31
31
  export const markdownComponents: Components = {
32
32
  h1: styled("h2", "text-lg font-semibold text-markdown-heading"),
33
33
  h2: styled("h2", "text-base font-semibold text-markdown-heading pt-2"),
@@ -54,20 +54,22 @@ export const markdownComponents: Components = {
54
54
 
55
55
  const isExternal = (href: string | undefined) => href != null && /^https?:\/\//i.test(href);
56
56
 
57
+ /** Props for `Markdown`. */
57
58
  export interface MarkdownProps {
58
- /** Markdown text. Empty, null or undefined renders nothing. */
59
+ /** The Markdown text. An empty string, null, or undefined renders nothing. */
59
60
  children: string | null | undefined;
60
- /** Replaces the default element for a tag, such as `{ p: MyParagraph }`. It gets the props without `node`. */
61
+ /** The elements that replace the default element for a tag, such as `{ p: MyParagraph }`. Each gets the props without `node`. */
61
62
  components?: Components | undefined;
62
- /** Opens links to other sites in a new tab, without passing the opener or referrer. */
63
+ /** Whether links to other sites open in a new tab, without passing the opener or referrer. Defaults to `false`. */
63
64
  externalLinks?: boolean | undefined;
64
- /** Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. */
65
+ /** The classes for a `div` around the output. Without it, the elements render bare, so a parent's `space-y-*` spaces them. */
65
66
  className?: string | undefined;
66
67
  }
67
68
 
68
69
  /**
69
- * Markdown from an editor or the API, with GitHub tables, lists and links. Raw HTML is never
70
- * rendered, and the output is sanitized, so the text cannot inject scripts, styles or event handlers.
70
+ * Renders Markdown from an editor or the API, with GitHub tables, lists, and links. Raw HTML is
71
+ * never rendered, and the output is sanitized, so the text cannot inject scripts, styles, or event
72
+ * handlers.
71
73
  */
72
74
  export function Markdown({ children, components, externalLinks = false, className }: MarkdownProps) {
73
75
  if (!children) return null;
@@ -1 +1 @@
1
- export { Markdown, markdownComponents, type MarkdownProps } from "./Markdown";
1
+ export { Markdown, type MarkdownProps } from "./Markdown";
@@ -2,23 +2,25 @@
2
2
  // them with `import "@sjolystinnovation/app-kit/next-auth";` in one of its own .d.ts files.
3
3
  import "next-auth";
4
4
  import "next-auth/jwt";
5
+ import type { SessionUser } from "./auth/createAuthOptions";
6
+ import type { SessionError } from "./session/expiry";
5
7
 
6
8
  declare module "next-auth" {
7
9
  interface Session {
8
- user: { id: string; name: string; email: string; roles: string[] };
10
+ user: SessionUser;
9
11
  accessToken: string;
10
- error?: string | undefined;
11
- absoluteExpiresAt?: number;
12
+ error?: SessionError | undefined;
13
+ absoluteExpiresAt?: number | undefined;
12
14
  }
13
15
  }
14
16
 
15
17
  declare module "next-auth/jwt" {
16
18
  interface JWT {
17
- accessToken?: string;
18
- refreshToken?: string;
19
- refreshAt?: number;
20
- loginAt?: number;
21
- user?: { id: string; name: string; email: string; roles: string[] };
22
- error?: string | undefined;
19
+ accessToken?: string | undefined;
20
+ refreshToken?: string | undefined;
21
+ refreshAt?: number | undefined;
22
+ loginAt?: number | undefined;
23
+ user?: SessionUser | undefined;
24
+ error?: SessionError | undefined;
23
25
  }
24
26
  }
@@ -0,0 +1,96 @@
1
+ /** A response header, as `next.config.ts` lists them. */
2
+ export interface Header {
3
+ key: string;
4
+ value: string;
5
+ }
6
+
7
+ /** Gets the API's origin, such as `https://api.example.com`. Returns `""` when the URL is missing or invalid. */
8
+ export function apiOrigin(apiUrl: string | undefined): string {
9
+ if (!apiUrl) return "";
10
+ try {
11
+ return new URL(apiUrl).origin;
12
+ } catch {
13
+ return "";
14
+ }
15
+ }
16
+
17
+ /** Gets the API's base URL with its path, such as `https://api.example.com/api`, without a trailing slash. Returns `""` when the URL is missing or invalid. */
18
+ export function apiBaseUrl(apiUrl: string | undefined): string {
19
+ if (!apiUrl) return "";
20
+ try {
21
+ return new URL(apiUrl).toString().replace(/\/$/, "");
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ /** The Content-Security-Policy options for `defineAppConfig`. */
28
+ export interface CspOptions {
29
+ /** The app's API URL, usually `process.env.NEXT_PUBLIC_API_URL`. The policy allows fetches to its origin. */
30
+ apiUrl: string | undefined;
31
+ /** Whether this is a development build. A development build also allows `'unsafe-eval'`, which hot reload needs. */
32
+ dev: boolean;
33
+ /** Whether to also allow `ws:` and `wss:` to the API, for a WebSocket such as SignalR. */
34
+ websocket?: boolean | undefined;
35
+ /** Whether to also allow images from the API origin. You do not need it when images go through the content images rewrite. */
36
+ imgFromApi?: boolean | undefined;
37
+ /** The extra sources for `connect-src`. */
38
+ connectSrc?: readonly string[] | undefined;
39
+ /** The extra sources for `img-src`. */
40
+ imgSrc?: readonly string[] | undefined;
41
+ /** The sources for `frame-src`. Defaults to `'none'`, since no page embeds a frame. */
42
+ frameSrc?: readonly string[] | undefined;
43
+ /** The sources for `object-src`. Defaults to `'none'`, since no page embeds an object. */
44
+ objectSrc?: readonly string[] | undefined;
45
+ }
46
+
47
+ const sources = (list: readonly (string | false | undefined)[]) => list.filter(Boolean).join(" ");
48
+
49
+ /**
50
+ * Builds the Content-Security-Policy for an app's pages. Scripts allow `'unsafe-inline'`, because a
51
+ * nonce would make every statically rendered page dynamic.
52
+ */
53
+ export function contentSecurityPolicy(o: CspOptions): string {
54
+ const origin = apiOrigin(o.apiUrl);
55
+ const wsOrigin = o.websocket ? origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:") : "";
56
+ return [
57
+ "default-src 'self'",
58
+ o.dev ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" : "script-src 'self' 'unsafe-inline'",
59
+ "style-src 'self' 'unsafe-inline'",
60
+ `img-src ${sources(["'self'", o.imgFromApi && origin, "data:", "blob:", ...(o.imgSrc ?? [])])}`,
61
+ `connect-src ${sources(["'self'", origin, wsOrigin, ...(o.connectSrc ?? [])])}`,
62
+ `frame-src ${sources(o.frameSrc ?? ["'none'"])}`,
63
+ `object-src ${sources(o.objectSrc ?? ["'none'"])}`,
64
+ "font-src 'self'",
65
+ "base-uri 'self'",
66
+ "form-action 'self'",
67
+ "frame-ancestors 'none'",
68
+ "upgrade-insecure-requests",
69
+ ].join("; ");
70
+ }
71
+
72
+ /** Builds the security headers for an app's pages, including the Content-Security-Policy. */
73
+ export function securityHeaders(o: CspOptions & { permissionsPolicy?: string | undefined }): Header[] {
74
+ return [
75
+ { key: "X-Content-Type-Options", value: "nosniff" },
76
+ { key: "X-Frame-Options", value: "DENY" },
77
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
78
+ { key: "X-DNS-Prefetch-Control", value: "on" },
79
+ { key: "Permissions-Policy", value: o.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()" },
80
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
81
+ { key: "Content-Security-Policy", value: contentSecurityPolicy(o) },
82
+ ];
83
+ }
84
+
85
+ /**
86
+ * Builds the rewrite that proxies content images, so the browser never contacts the API host. It is
87
+ * a rewrite rather than a route handler, so the query string and the Accept header, which webp
88
+ * needs, pass through. Returns no rewrite when the API URL is missing or invalid.
89
+ */
90
+ export function contentImagesRewrite(apiUrl: string | undefined, o: { source?: string | undefined; upstreamPath?: string | undefined } = {}) {
91
+ const base = apiBaseUrl(apiUrl);
92
+ if (!base) return [];
93
+ const source = o.source ?? "/content-images";
94
+ const upstream = o.upstreamPath ?? "/content-images";
95
+ return [{ source: `${source}/:path*`, destination: `${base}${upstream}/:path*` }];
96
+ }
@@ -1,128 +1,39 @@
1
1
  import type { NextConfig } from "next";
2
+ import { contentImagesRewrite, securityHeaders, type CspOptions, type Header } from "./headers";
2
3
 
3
- /** A response header, as `next.config.ts` lists them. */
4
- export interface Header {
5
- key: string;
6
- value: string;
7
- }
8
-
9
- /** The API's origin, such as `https://api.example.com`. Empty when the URL is missing or invalid. */
10
- export function apiOrigin(apiUrl: string | undefined): string {
11
- if (!apiUrl) return "";
12
- try {
13
- return new URL(apiUrl).origin;
14
- } catch {
15
- return "";
16
- }
17
- }
18
-
19
- /** The API's base URL with its path, such as `https://api.example.com/api`, without a trailing slash. Empty when missing or invalid. */
20
- export function apiBaseUrl(apiUrl: string | undefined): string {
21
- if (!apiUrl) return "";
22
- try {
23
- return new URL(apiUrl).toString().replace(/\/$/, "");
24
- } catch {
25
- return "";
26
- }
27
- }
28
-
29
- export interface CspOptions {
30
- /** The app's API URL, usually `process.env.NEXT_PUBLIC_API_URL`. Its origin may be fetched from. */
31
- apiUrl: string | undefined;
32
- /** Development builds also allow `'unsafe-eval'`, which hot reload needs. */
33
- dev: boolean;
34
- /** Also allow `ws:` and `wss:` to the API, for a WebSocket such as SignalR. */
35
- websocket?: boolean | undefined;
36
- /** Also allow images from the API origin. Not needed when images go through the content images rewrite. */
37
- imgFromApi?: boolean | undefined;
38
- /** Extra sources for `connect-src`. */
39
- connectSrc?: readonly string[] | undefined;
40
- /** Extra sources for `img-src`. */
41
- imgSrc?: readonly string[] | undefined;
42
- /** Sources for `frame-src`. Defaults to `'none'`, since no page embeds a frame. */
43
- frameSrc?: readonly string[] | undefined;
44
- /** Sources for `object-src`. Defaults to `'none'`, since no page embeds an object. */
45
- objectSrc?: readonly string[] | undefined;
46
- }
47
-
48
- const sources = (list: readonly (string | false | undefined)[]) => list.filter(Boolean).join(" ");
49
-
50
- /**
51
- * The Content-Security-Policy for an app's pages. Scripts allow `'unsafe-inline'`, because a nonce
52
- * would make every statically rendered page dynamic.
53
- */
54
- export function contentSecurityPolicy(o: CspOptions): string {
55
- const origin = apiOrigin(o.apiUrl);
56
- const wsOrigin = o.websocket ? origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:") : "";
57
- return [
58
- "default-src 'self'",
59
- o.dev ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" : "script-src 'self' 'unsafe-inline'",
60
- "style-src 'self' 'unsafe-inline'",
61
- `img-src ${sources(["'self'", o.imgFromApi && origin, "data:", "blob:", ...(o.imgSrc ?? [])])}`,
62
- `connect-src ${sources(["'self'", origin, wsOrigin, ...(o.connectSrc ?? [])])}`,
63
- `frame-src ${sources(o.frameSrc ?? ["'none'"])}`,
64
- `object-src ${sources(o.objectSrc ?? ["'none'"])}`,
65
- "font-src 'self'",
66
- "base-uri 'self'",
67
- "form-action 'self'",
68
- "frame-ancestors 'none'",
69
- "upgrade-insecure-requests",
70
- ].join("; ");
71
- }
72
-
73
- /** The security headers for an app's pages, including the Content-Security-Policy. */
74
- export function securityHeaders(o: CspOptions & { permissionsPolicy?: string | undefined }): Header[] {
75
- return [
76
- { key: "X-Content-Type-Options", value: "nosniff" },
77
- { key: "X-Frame-Options", value: "DENY" },
78
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
79
- { key: "X-DNS-Prefetch-Control", value: "on" },
80
- { key: "Permissions-Policy", value: o.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()" },
81
- { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
82
- { key: "Content-Security-Policy", value: contentSecurityPolicy(o) },
83
- ];
84
- }
85
-
86
- /**
87
- * Content images, proxied so the browser never contacts the API host. A rewrite rather than a route
88
- * handler, so the query string and the Accept header, which webp needs, pass through. No rewrite
89
- * when the API URL is missing or invalid.
90
- */
91
- export function contentImagesRewrite(apiUrl: string | undefined, o: { source?: string | undefined; upstreamPath?: string | undefined } = {}) {
92
- const base = apiBaseUrl(apiUrl);
93
- if (!base) return [];
94
- const source = o.source ?? "/content-images";
95
- const upstream = o.upstreamPath ?? "/content-images";
96
- return [{ source: `${source}/:path*`, destination: `${base}${upstream}/:path*` }];
97
- }
4
+ export type { CspOptions } from "./headers";
98
5
 
6
+ /** Options for `defineAppConfig`. */
99
7
  export interface AppConfigOptions extends CspOptions {
100
- /** Proxy `/content-images/*` to the API. See `contentImagesRewrite`. */
8
+ /**
9
+ * Whether to proxy `/content-images/*` to the API. The rewrite keeps the browser off the API host
10
+ * and passes the query string and the Accept header through.
11
+ */
101
12
  contentImages?: boolean | undefined;
102
13
  /**
103
- * Headers for particular paths. Every path gets the page headers, and these come after them, so
104
- * they replace a page header of the same name.
14
+ * The headers for particular paths. Every path gets the page headers, and these come after them,
15
+ * so they replace a page header of the same name.
105
16
  */
106
17
  pathHeaders?: readonly { source: string; headers: Header[] }[] | undefined;
107
- /** Replaces the default Permissions-Policy. */
18
+ /** A Permissions-Policy that replaces the default one. */
108
19
  permissionsPolicy?: string | undefined;
109
20
  /**
110
- * Seconds a cache may go on serving a page after it goes stale. Next's default is a year, long
111
- * enough for a browser to hand back an old page and fetch the current one behind it. Defaults to
112
- * 300, matching the client router cache's stale time.
21
+ * The number of seconds a cache may go on serving a page after it goes stale. Next's default is a
22
+ * year, long enough for a browser to hand back an old page and fetch the current one behind it.
23
+ * Defaults to `300`, matching the client router cache's stale time.
113
24
  */
114
25
  expireTime?: number | undefined;
115
26
  /**
116
- * Write revalidated pages to disk. Off by default: with a read-only root filesystem Next cannot
117
- * write to `.next/server/app`, so the incremental cache stays in memory.
27
+ * Whether to write revalidated pages to disk. Defaults to `false`. With a read-only root
28
+ * filesystem, Next cannot write to `.next/server/app`, so the incremental cache stays in memory.
118
29
  */
119
30
  isrFlushToDisk?: boolean | undefined;
120
31
  }
121
32
 
122
33
  /**
123
- * The shared Next config for an app: the standalone build, app-kit transpiled, no `X-Powered-By`,
124
- * the security headers on every page, and optionally the content images rewrite. `extra` is merged
125
- * over it, with `transpilePackages` and `images` combined.
34
+ * Builds the shared Next config for an app: the standalone build, app-kit transpiled, no
35
+ * `X-Powered-By`, the security headers on every page, and optionally the content images rewrite.
36
+ * `extra` is merged over it, with `transpilePackages` and `images` combined.
126
37
  */
127
38
  export function defineAppConfig(o: AppConfigOptions, extra: NextConfig = {}): NextConfig {
128
39
  const pageHeaders = securityHeaders(o);
@@ -5,32 +5,34 @@ import Link from "next/link";
5
5
  import type { ReactNode } from "react";
6
6
  import { cx } from "../ui/cx";
7
7
 
8
+ /** The outcome of a payment, as `PaymentReturn` shows it. */
8
9
  export type PaymentReturnState = "success" | "pending" | "failed";
9
10
 
10
- /** The text and the way on for one state. */
11
+ /** The text and the next step for one state. */
11
12
  export interface PaymentReturnContent {
12
13
  title: string;
13
14
  body: string;
14
- /** Where the button goes. */
15
+ /** The button's link target. */
15
16
  href: string;
16
17
  /** The button's text. */
17
18
  cta: string;
18
19
  /** A smaller line under the body. */
19
20
  note?: string | undefined;
20
- /** Anything more under the body, such as a receipt link. */
21
+ /** Any more content under the body, such as a receipt link. */
21
22
  extra?: ReactNode;
22
23
  }
23
24
 
25
+ /** Props for `PaymentReturn`. */
24
26
  export interface PaymentReturnProps {
25
27
  state: PaymentReturnState;
26
28
  success: PaymentReturnContent;
27
29
  pending: PaymentReturnContent;
28
30
  failed: PaymentReturnContent;
29
- /** Checks the status again. Shows a button for it while pending. */
31
+ /** A function that checks the status again. While the state is pending, a button runs it. */
30
32
  onRefresh?: (() => void) | undefined;
31
33
  /** The refresh button's text. Defaults to `Check again`. */
32
34
  refreshLabel?: string | undefined;
33
- /** Classes for placing it, on the outer element. */
35
+ /** The classes for placing the component, on its outer element. */
34
36
  className?: string | undefined;
35
37
  }
36
38
 
@@ -42,8 +44,8 @@ const refresh = "px-4 py-2.5 rounded-payment-button bg-button-primary-bg/80 text
42
44
  const secondary = "py-2.5 rounded-payment-button bg-payment-secondary-bg hover:bg-payment-secondary-hover-bg text-payment-secondary-text text-sm transition-colors";
43
45
 
44
46
  /**
45
- * What a user sees on coming back from a payment provider: confirmed, still pending, or failed.
46
- * The app decides the state, such as with `usePollUntilFinal`.
47
+ * Renders what a user sees on coming back from a payment provider: confirmed, still pending, or
48
+ * failed. The app decides the state, such as with `usePollUntilFinal`.
47
49
  */
48
50
  export function PaymentReturn({ state, success, pending, failed, onRefresh, refreshLabel = "Check again", className }: PaymentReturnProps) {
49
51
  const content = { success, pending, failed }[state];
@@ -1,4 +1,4 @@
1
- /** An amount in øre as Norwegian kroner, such as `NOK 373,75`. */
1
+ /** Formats an amount in øre as Norwegian kroner, such as `NOK 373,75`. */
2
2
  export function formatNok(ore: number): string {
3
3
  return `NOK ${(ore / 100).toFixed(2).replace(".", ",")}`;
4
4
  }
@@ -2,26 +2,28 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
 
5
+ /** What `usePollUntilFinal` returns. */
5
6
  export interface PollResult<T> {
6
7
  /** The latest value, or null before the first answer. */
7
8
  data: T | null;
8
- /** True until a final value, the last retry or a failed fetch. */
9
+ /** True until a final value, the last retry, or a failed fetch. */
9
10
  loading: boolean;
10
11
  /** Polls again from the start, with the spinner showing. */
11
12
  refresh: () => void;
12
13
  }
13
14
 
15
+ /** Options for `usePollUntilFinal`. */
14
16
  export interface PollOptions {
15
- /** How many more times to fetch after the first. Defaults to 8. */
17
+ /** The number of fetches after the first. Defaults to `8`. */
16
18
  retries?: number | undefined;
17
- /** Milliseconds between fetches. Defaults to 4000. */
19
+ /** The milliseconds between fetches. Defaults to `4000`. */
18
20
  delayMs?: number | undefined;
19
21
  }
20
22
 
21
23
  /**
22
24
  * Fetches until the value is final, such as an order a payment provider has not confirmed yet when
23
- * the user comes back from it. Stops after the retries, on a failed fetch and on unmount. Each fetch
24
- * uses the latest `fetcher` and `isFinal` given.
25
+ * the user comes back from it. Stops after the retries, on a failed fetch, and on unmount. Each
26
+ * fetch uses the latest `fetcher` and `isFinal` given.
25
27
  */
26
28
  export function usePollUntilFinal<T>(
27
29
  fetcher: () => Promise<T | null>,
@@ -1,6 +1,6 @@
1
1
  import { serializeGraph, type SchemaNode } from "./schema";
2
2
 
3
- /** One JSON-LD block. Search engines merge every block on a page into one graph. */
3
+ /** Renders one JSON-LD block. Search engines merge every block on a page into one graph. */
4
4
  export function JsonLd({ nodes }: { nodes: SchemaNode[] }) {
5
5
  if (nodes.length === 0) return null;
6
6