@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.
- package/README.md +184 -265
- package/package.json +1 -1
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +9 -3
- package/src/admin/CompanySettingsForm.tsx +2 -1
- package/src/admin/LegalPagesEditor.tsx +5 -4
- package/src/admin/LocaleTabs.tsx +4 -3
- package/src/admin/StatHistoryChart.tsx +4 -3
- package/src/admin/adminUsersPanelStrings.ts +3 -3
- package/src/admin/legalPagesEditorStrings.ts +1 -1
- package/src/admin/statHistoryChartStrings.ts +3 -2
- package/src/api/apiFieldErrors.ts +4 -4
- package/src/api/company.ts +4 -3
- package/src/api/createApiClient.ts +3 -2
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +2 -1
- package/src/api/request.ts +2 -2
- package/src/api/revalidateTargets.ts +7 -5
- package/src/api/services.ts +14 -13
- package/src/auth/createAuthOptions.ts +7 -5
- package/src/branding/BrandingManager.tsx +10 -5
- package/src/branding/BrandingProvider.tsx +5 -3
- package/src/branding/index.ts +1 -2
- package/src/branding/server.ts +10 -9
- package/src/branding/types.ts +3 -3
- package/src/config.ts +10 -10
- package/src/forms/useFormDraft.ts +9 -7
- package/src/index.ts +1 -3
- package/src/legal/LegalPageView.tsx +6 -5
- package/src/markdown/Markdown.tsx +12 -10
- package/src/markdown/index.ts +1 -1
- package/src/next-auth.d.ts +11 -9
- package/src/next-config/headers.ts +96 -0
- package/src/next-config/index.ts +18 -107
- package/src/payments/PaymentReturn.tsx +9 -7
- package/src/payments/formatNok.ts +1 -1
- package/src/payments/usePollUntilFinal.ts +7 -5
- package/src/seo/JsonLd.tsx +1 -1
- package/src/seo/environment.ts +3 -3
- package/src/seo/index.ts +0 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +44 -32
- package/src/seo/sitemap.ts +2 -2
- package/src/server/publicApi.ts +13 -9
- package/src/server/revalidateRoute.ts +10 -8
- package/src/session/expiry.ts +8 -5
- package/src/session/react/AppSessionProvider.tsx +3 -2
- package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
- package/src/{ui → session/react}/LoginPage.tsx +7 -6
- package/src/session/react/ProtectedGate.tsx +3 -2
- package/src/session/react/RoleGate.tsx +4 -3
- package/src/session/react/SessionExpiryGuard.tsx +5 -4
- package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
- package/src/session/react/index.ts +8 -2
- package/src/{ui → session/react}/loginPageStrings.ts +1 -1
- package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
- package/src/session/react/useSessionGate.ts +3 -3
- package/src/testing/index.ts +5 -5
- package/src/ui/Alert.tsx +6 -4
- package/src/ui/ContentImage.tsx +12 -11
- package/src/ui/ErrorState.tsx +5 -3
- package/src/ui/NotFoundState.tsx +9 -7
- package/src/ui/PasswordInput.tsx +6 -5
- package/src/ui/ResetPasswordFlow.tsx +10 -8
- package/src/ui/TextArea.tsx +6 -4
- package/src/ui/TextInput.tsx +4 -2
- package/src/ui/Toggle.tsx +3 -3
- package/src/ui/field.tsx +5 -3
- package/src/ui/index.ts +2 -10
- package/src/ui/resetPasswordFlowStrings.ts +7 -7
- package/src/validation/fieldErrors.ts +4 -3
- package/src/validation/password.ts +5 -5
- package/src/validation/passwordRules.ts +2 -2
package/src/branding/server.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
16
|
-
* JPEG, WebP, GIF or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox
|
|
17
|
-
* that a CSP header from the app's `next.config` replaces.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
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
|
*/
|
package/src/branding/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
3
|
+
/** The logo's image bytes, in base64. */
|
|
4
4
|
logoData?: string | null | undefined;
|
|
5
5
|
logoContentType?: string | null | undefined;
|
|
6
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
* something like "/en/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
|
-
*
|
|
18
|
-
* "example"
|
|
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
|
-
*
|
|
35
|
-
* "app:draft::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
|
|
61
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
/**
|
|
57
|
+
/** Stops offering the draft. The caller applies the values it wants. */
|
|
56
58
|
dismiss: () => void;
|
|
57
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
9
|
+
/** The text before the date the page last changed. */
|
|
10
10
|
updated: string;
|
|
11
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
59
|
+
/** The Markdown text. An empty string, null, or undefined renders nothing. */
|
|
59
60
|
children: string | null | undefined;
|
|
60
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
70
|
-
* rendered, and the output is sanitized, so the text cannot inject scripts, styles or event
|
|
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;
|
package/src/markdown/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { Markdown,
|
|
1
|
+
export { Markdown, type MarkdownProps } from "./Markdown";
|
package/src/next-auth.d.ts
CHANGED
|
@@ -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:
|
|
10
|
+
user: SessionUser;
|
|
9
11
|
accessToken: string;
|
|
10
|
-
error?:
|
|
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?:
|
|
22
|
-
error?:
|
|
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
|
+
}
|
package/src/next-config/index.ts
CHANGED
|
@@ -1,128 +1,39 @@
|
|
|
1
1
|
import type { NextConfig } from "next";
|
|
2
|
+
import { contentImagesRewrite, securityHeaders, type CspOptions, type Header } from "./headers";
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
18
|
+
/** A Permissions-Policy that replaces the default one. */
|
|
108
19
|
permissionsPolicy?: string | undefined;
|
|
109
20
|
/**
|
|
110
|
-
*
|
|
111
|
-
* enough for a browser to hand back an old page and fetch the current one behind it.
|
|
112
|
-
* 300
|
|
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
|
-
*
|
|
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
|
-
*
|
|
124
|
-
* the security headers on every page, and optionally the content images rewrite.
|
|
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
|
|
11
|
+
/** The text and the next step for one state. */
|
|
11
12
|
export interface PaymentReturnContent {
|
|
12
13
|
title: string;
|
|
13
14
|
body: string;
|
|
14
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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];
|
|
@@ -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
|
-
/**
|
|
17
|
+
/** The number of fetches after the first. Defaults to `8`. */
|
|
16
18
|
retries?: number | undefined;
|
|
17
|
-
/**
|
|
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
|
|
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>,
|
package/src/seo/JsonLd.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { serializeGraph, type SchemaNode } from "./schema";
|
|
2
2
|
|
|
3
|
-
/**
|
|
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
|
|