@sjolystinnovation/app-kit 0.20.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 +315 -253
- package/package.json +13 -1
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +9 -3
- package/src/admin/CompanySettingsForm.tsx +123 -0
- package/src/admin/LegalPagesEditor.tsx +132 -0
- package/src/admin/LocaleTabs.tsx +36 -0
- package/src/admin/StatHistoryChart.tsx +100 -0
- package/src/admin/adminUsersPanelStrings.ts +3 -3
- package/src/admin/companySettingsFormStrings.ts +50 -0
- package/src/admin/index.ts +14 -0
- package/src/admin/legalPagesEditorStrings.ts +32 -0
- package/src/admin/statHistoryChartStrings.ts +17 -0
- package/src/api/apiFieldErrors.ts +4 -4
- package/src/api/company.ts +47 -0
- package/src/api/createApiClient.ts +3 -2
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +6 -1
- package/src/api/request.ts +2 -2
- package/src/api/revalidateTargets.ts +42 -0
- 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 +62 -0
- package/src/legal/index.ts +1 -0
- 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 +2 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +90 -30
- 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/styles.css +2 -0
- package/src/testing/index.ts +113 -0
- 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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { LegalPage } from "../api/services";
|
|
3
|
+
import { Markdown } from "../markdown";
|
|
4
|
+
import { parseTimestamp } from "../seo/sitemap";
|
|
5
|
+
import { withDefaults, type TextOverrides } from "../ui/withDefaults";
|
|
6
|
+
|
|
7
|
+
/** The legal page's text around the content. Pass the app's own language. The defaults are English. */
|
|
8
|
+
export interface LegalPageViewStrings {
|
|
9
|
+
/** The text before the date the page last changed. */
|
|
10
|
+
updated: string;
|
|
11
|
+
/** The text shown in place of the content when nobody has written the page yet. */
|
|
12
|
+
missing: string;
|
|
13
|
+
/** The text of the link back to the front page. */
|
|
14
|
+
toFrontPage: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const defaultLegalPageViewStrings: LegalPageViewStrings = {
|
|
18
|
+
updated: "Last updated",
|
|
19
|
+
missing: "This content has not been written yet.",
|
|
20
|
+
toFrontPage: "Back to the front page",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Props for `LegalPageView`. */
|
|
24
|
+
export interface LegalPageViewProps {
|
|
25
|
+
/** The page from the API, or null when nobody has written it yet. */
|
|
26
|
+
page: LegalPage | null;
|
|
27
|
+
/** The heading while there is no page. */
|
|
28
|
+
title: string;
|
|
29
|
+
/** The front page's path, for the link at the bottom. */
|
|
30
|
+
homeHref: string;
|
|
31
|
+
/** The BCP 47 tag the date is written in, such as `nb-NO`. */
|
|
32
|
+
dateLocale: string;
|
|
33
|
+
strings?: TextOverrides<LegalPageViewStrings> | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Renders a terms, privacy, or cookies page as an admin wrote it: the title, when it last changed, and the Markdown body. */
|
|
37
|
+
export function LegalPageView({ page, title, homeHref, dateLocale, strings }: LegalPageViewProps) {
|
|
38
|
+
const text = withDefaults(defaultLegalPageViewStrings, strings);
|
|
39
|
+
// A malformed timestamp leaves the date out rather than failing the page.
|
|
40
|
+
const updatedAt = parseTimestamp(page?.updatedAt);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12 space-y-6">
|
|
44
|
+
<h1 className="text-3xl font-black text-gray-900">{page?.title ?? title}</h1>
|
|
45
|
+
{updatedAt && (
|
|
46
|
+
<p className="text-xs text-gray-500">
|
|
47
|
+
{text.updated}: {new Intl.DateTimeFormat(dateLocale, { dateStyle: "long" }).format(updatedAt)}
|
|
48
|
+
</p>
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
<div className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm space-y-4 text-sm text-gray-600">
|
|
52
|
+
{page ? <Markdown>{page.bodyMarkdown}</Markdown> : <p>{text.missing}</p>}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<p className="text-xs text-gray-500">
|
|
56
|
+
<Link href={homeHref} className="hover:text-gray-900 transition-colors">
|
|
57
|
+
← {text.toFrontPage}
|
|
58
|
+
</Link>
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LegalPageView, defaultLegalPageViewStrings, type LegalPageViewProps, type LegalPageViewStrings } from "./LegalPageView";
|
|
@@ -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
|
|
package/src/seo/environment.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** The values of `SITE_ENV` that name the live site. The deploy workflows pass `prod`. */
|
|
2
2
|
const PRODUCTION = new Set(["production", "prod", ""]);
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* production, so a missing variable is never what deindexes the live site.
|
|
5
|
+
* Checks whether this deployment is the one search engines should index. An unset `SITE_ENV` counts
|
|
6
|
+
* as production, so a missing variable is never what deindexes the live site.
|
|
7
7
|
*/
|
|
8
8
|
export function isIndexableEnvironment(siteEnv: string | undefined = process.env.SITE_ENV): boolean {
|
|
9
9
|
return PRODUCTION.has(siteEnv?.trim().toLowerCase() ?? "");
|
package/src/seo/index.ts
CHANGED
|
@@ -3,10 +3,11 @@ export { JsonLd } from "./JsonLd";
|
|
|
3
3
|
export { ref, serializeGraph, type OrganizationInfo, type SchemaNode, type SchemaRef } from "./schema";
|
|
4
4
|
export {
|
|
5
5
|
defineSite,
|
|
6
|
-
|
|
6
|
+
LEGAL_PAGE_KEYS,
|
|
7
7
|
NOINDEX,
|
|
8
8
|
SHARE_IMAGE_SIZE,
|
|
9
9
|
type Crumb,
|
|
10
|
+
type LegalEntriesInput,
|
|
10
11
|
type LocaleEntryOptions,
|
|
11
12
|
type OrganizationOptions,
|
|
12
13
|
type PageMetadataInput,
|
package/src/seo/og/index.tsx
CHANGED
|
@@ -3,20 +3,21 @@ import { SHARE_IMAGE_SIZE } from "../site";
|
|
|
3
3
|
|
|
4
4
|
export { SHARE_IMAGE_SIZE };
|
|
5
5
|
|
|
6
|
+
/** Options for `shareImage`. */
|
|
6
7
|
export interface ShareImageInput {
|
|
7
8
|
name: string;
|
|
8
9
|
tagline: string;
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
+
* The colors as literals. The image renders outside the browser, so it cannot read CSS custom
|
|
11
12
|
* properties. `primary` is the bar above the name.
|
|
12
13
|
*/
|
|
13
14
|
colors: { background: string; foreground: string; primary: string };
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
* route such as `[locale]/og/route.tsx`, which `pageMetadata` points at.
|
|
19
|
-
* `opengraph-image` file convention, since a page's own `openGraph` block replaces that image.
|
|
18
|
+
* Renders the default share preview: the name, a bar in the brand color, and the tagline. Return it
|
|
19
|
+
* from a route such as `[locale]/og/route.tsx`, which `pageMetadata` points at. It is a route, not
|
|
20
|
+
* the `opengraph-image` file convention, since a page's own `openGraph` block replaces that image.
|
|
20
21
|
*/
|
|
21
22
|
export function shareImage({ name, tagline, colors }: ShareImageInput): ImageResponse {
|
|
22
23
|
return new ImageResponse(
|
package/src/seo/schema.ts
CHANGED
|
@@ -10,9 +10,10 @@ export interface SchemaRef {
|
|
|
10
10
|
"@id": string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Creates a reference to the node with this `@id`. */
|
|
13
14
|
export const ref = (id: string): SchemaRef => ({ "@id": id });
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
+
/** Serializes the JSON-LD document for one page. `<` is escaped, so the payload cannot close the script tag. */
|
|
16
17
|
export function serializeGraph(nodes: SchemaNode[]): string {
|
|
17
18
|
return JSON.stringify({
|
|
18
19
|
"@context": "https://schema.org",
|
|
@@ -20,7 +21,10 @@ export function serializeGraph(nodes: SchemaNode[]): string {
|
|
|
20
21
|
}).replace(/</g, "\\u003c");
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The business as the API describes it. The structured data leaves out blank fields, except the
|
|
26
|
+
* street address, which falls back to the one-line address.
|
|
27
|
+
*/
|
|
24
28
|
export interface OrganizationInfo {
|
|
25
29
|
name: string;
|
|
26
30
|
legalName?: string | undefined;
|