@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/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
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;
|
package/src/seo/site.ts
CHANGED
|
@@ -9,12 +9,13 @@ export const SHARE_IMAGE_SIZE = { width: 1200, height: 630 } as const;
|
|
|
9
9
|
/** The robots directive for pages that exist for signed-in users only. */
|
|
10
10
|
export const NOINDEX: Metadata["robots"] = { index: false, follow: false };
|
|
11
11
|
|
|
12
|
+
/** Options for `defineSite`. */
|
|
12
13
|
export interface SiteConfig<L extends string> {
|
|
13
|
-
/** The site's name, as in titles, Open Graph and structured data. */
|
|
14
|
+
/** The site's name, as in titles, Open Graph, and structured data. */
|
|
14
15
|
name: string;
|
|
15
16
|
/** The canonical origin, such as `https://www.example.no`, without a trailing slash. */
|
|
16
17
|
url: string;
|
|
17
|
-
/**
|
|
18
|
+
/** The text between a page title and the site name, such as `" | "`. */
|
|
18
19
|
titleSeparator: string;
|
|
19
20
|
/** The locale segments every page is under, such as `["no", "en"]`. A site without them omits this. */
|
|
20
21
|
locales?: readonly L[] | undefined;
|
|
@@ -28,6 +29,7 @@ export interface SiteConfig<L extends string> {
|
|
|
28
29
|
shareImagePath?: string | undefined;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** Options for `pageMetadata`. */
|
|
31
33
|
export interface PageMetadataInput<L extends string> {
|
|
32
34
|
locale: L;
|
|
33
35
|
/** The path below the locale segment, such as `/builds/my-pc`. Omit for the front page. */
|
|
@@ -35,10 +37,11 @@ export interface PageMetadataInput<L extends string> {
|
|
|
35
37
|
/** The page title, without the site name. The root layout's template adds it. */
|
|
36
38
|
title?: string | undefined;
|
|
37
39
|
description?: string | undefined;
|
|
38
|
-
/**
|
|
40
|
+
/** The image URLs for the share preview. Defaults to the share image for the locale. */
|
|
39
41
|
images?: string[] | undefined;
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/** Options for `siteMetadata`. */
|
|
42
45
|
export interface SiteMetadataInput<L extends string> {
|
|
43
46
|
locale: L;
|
|
44
47
|
description?: string | undefined;
|
|
@@ -46,54 +49,60 @@ export interface SiteMetadataInput<L extends string> {
|
|
|
46
49
|
manifest?: string | undefined;
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/** One step in a breadcrumb trail. */
|
|
49
53
|
export interface Crumb {
|
|
50
54
|
name: string;
|
|
51
55
|
/** A site-relative path, such as `/no/builds`. The last crumb is the current page and has none. */
|
|
52
56
|
path?: string | undefined;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
/** Options for `localeEntries`. */
|
|
55
60
|
export interface LocaleEntryOptions<L extends string> {
|
|
56
61
|
/** The locales the page exists in. Defaults to all of them. A site without locales ignores it. */
|
|
57
62
|
locales?: readonly L[] | undefined;
|
|
58
|
-
/**
|
|
63
|
+
/** A function that returns when the page last changed in a locale. An unknown date is left out. A site without locales calls it once, with no locale. */
|
|
59
64
|
lastModified?: ((locale: L) => Date | undefined) | undefined;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/** The legal pages every site has, as `createLegalService` names them. */
|
|
63
68
|
export const LEGAL_PAGE_KEYS = ["terms", "privacy", "cookies"] as const;
|
|
64
69
|
|
|
70
|
+
/** Options for `legalEntries`. */
|
|
65
71
|
export interface LegalEntriesInput<K extends string, L extends string> {
|
|
66
72
|
/** The legal pages, each at `/<key>` below the locale. Defaults to `LEGAL_PAGE_KEYS`. */
|
|
67
73
|
keys?: readonly K[] | undefined;
|
|
68
|
-
/**
|
|
74
|
+
/** A function that returns when a page last changed, as the API's timestamp. A site without locales passes no locale. */
|
|
69
75
|
updatedAt: (key: K, locale: L | undefined) => Promise<string | null | undefined>;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
/** Options for `robots`. */
|
|
72
79
|
export interface RobotsInput {
|
|
73
|
-
/**
|
|
80
|
+
/** The paths below each locale segment that must not be crawled, such as `/admin`. */
|
|
74
81
|
privatePaths?: readonly string[] | undefined;
|
|
75
|
-
/**
|
|
82
|
+
/** The paths outside the locale segments that must not be crawled, such as `/api/`. */
|
|
76
83
|
privateRootPaths?: readonly string[] | undefined;
|
|
77
84
|
/** Whether search engines may index this deployment. Defaults to `isIndexableEnvironment()`. */
|
|
78
85
|
indexable?: boolean | undefined;
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
/** Options for `organizationNode`. */
|
|
81
89
|
export interface OrganizationOptions {
|
|
82
90
|
/** The schema.org type, such as `ComputerStore`, or several, such as `["Organization", "LocalBusiness"]`. Defaults to `Organization`. */
|
|
83
91
|
type?: string | readonly string[] | undefined;
|
|
84
|
-
/**
|
|
92
|
+
/** The pages about the same business elsewhere, such as its entry in Brønnøysundregistrene. */
|
|
85
93
|
sameAs?: readonly string[] | undefined;
|
|
86
94
|
/** The ISO country code for the address and the area served. Defaults to `NO`. */
|
|
87
95
|
country?: string | undefined;
|
|
88
|
-
/**
|
|
96
|
+
/** The site-relative path of the square image. Defaults to `/icon.png`. */
|
|
89
97
|
image?: string | undefined;
|
|
98
|
+
/** The site-relative path of the logo. Defaults to `/logo.png`. */
|
|
90
99
|
logo?: string | undefined;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
/**
|
|
94
|
-
*
|
|
95
|
-
* breaks are removed, as a URL parser removes them, and leading slashes
|
|
96
|
-
* to one, so a path always stays on the site's own host.
|
|
103
|
+
* Normalizes a site path to one leading slash and no trailing slash. `""` and `"/"` both mean the
|
|
104
|
+
* front page. Tabs and line breaks are removed, as a URL parser removes them, and leading slashes
|
|
105
|
+
* and backslashes collapse to one, so a path always stays on the site's own host.
|
|
97
106
|
*/
|
|
98
107
|
export function normalizePath(path: string): string {
|
|
99
108
|
const clean = path.replace(/[\t\n\r]/g, "");
|
|
@@ -103,8 +112,8 @@ export function normalizePath(path: string): string {
|
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
/**
|
|
106
|
-
*
|
|
107
|
-
* canonical origin and locales.
|
|
115
|
+
* Creates the URL, metadata, sitemap, robots, and structured data helpers for one site, bound to its
|
|
116
|
+
* name, canonical origin, and locales.
|
|
108
117
|
*/
|
|
109
118
|
export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
110
119
|
const locales = config.locales ?? [];
|
|
@@ -113,11 +122,11 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
113
122
|
const shareImagePath = config.shareImagePath ?? "/og";
|
|
114
123
|
const origin = new URL(config.url).origin;
|
|
115
124
|
|
|
116
|
-
/**
|
|
125
|
+
/** Builds a site-relative URL for a page in one locale, such as `/no/builds`. A site without locales has no segment. */
|
|
117
126
|
const localePath = (locale: L | undefined, path = ""): string =>
|
|
118
127
|
locale === undefined || locales.length === 0 ? normalizePath(path) || "/" : `/${locale}${normalizePath(path)}`;
|
|
119
128
|
|
|
120
|
-
/**
|
|
129
|
+
/** Builds an absolute URL on the canonical origin, without a trailing slash. */
|
|
121
130
|
const absoluteUrl = (path = ""): string => {
|
|
122
131
|
const url = new URL(normalizePath(path) || "/", config.url);
|
|
123
132
|
// A path that would leave the site's origin gives the front page instead.
|
|
@@ -127,7 +136,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
127
136
|
|
|
128
137
|
const absoluteLocaleUrl = (locale: L, path = ""): string => absoluteUrl(localePath(locale, path));
|
|
129
138
|
|
|
130
|
-
/**
|
|
139
|
+
/** Builds the language alternates by BCP 47 tag. `x-default` points at the default locale. */
|
|
131
140
|
const languageAlternates = (path: string): Record<string, string> => ({
|
|
132
141
|
...Object.fromEntries(locales.map(l => [tag(l), localePath(l, path)])),
|
|
133
142
|
...(defaultLocale !== undefined ? { "x-default": localePath(defaultLocale, path) } : {}),
|
|
@@ -145,7 +154,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
145
154
|
alt: fullTitle(locale),
|
|
146
155
|
});
|
|
147
156
|
|
|
148
|
-
/**
|
|
157
|
+
/** Builds the canonical URL, hreflang set, and Open Graph for one public page. */
|
|
149
158
|
const pageMetadata = ({ locale, path = "", title, description, images }: PageMetadataInput<L>): Metadata => {
|
|
150
159
|
const canonical = localePath(locale, path);
|
|
151
160
|
return {
|
|
@@ -167,7 +176,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
167
176
|
};
|
|
168
177
|
};
|
|
169
178
|
|
|
170
|
-
/**
|
|
179
|
+
/** Builds the metadata for the locale root layout: the title template, the index directive, and the front page's canonical. */
|
|
171
180
|
const siteMetadata = ({ locale, description, manifest }: SiteMetadataInput<L>): Metadata => {
|
|
172
181
|
const title = fullTitle(locale);
|
|
173
182
|
return {
|
|
@@ -180,12 +189,13 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
180
189
|
};
|
|
181
190
|
|
|
182
191
|
/**
|
|
183
|
-
*
|
|
184
|
-
* without locales has one URL per page, so it gets one entry with no alternates.
|
|
192
|
+
* Builds one sitemap entry per locale a page is served in, each listing the others as alternates.
|
|
193
|
+
* A site without locales has one URL per page, so it gets one entry with no alternates.
|
|
185
194
|
*/
|
|
186
195
|
const localeEntries = (path: string, options: LocaleEntryOptions<L> = {}): MetadataRoute.Sitemap => {
|
|
187
196
|
if (locales.length === 0) {
|
|
188
|
-
// There is no locale to pass
|
|
197
|
+
// There is no locale to pass. The callback's type cannot say so without making every caller
|
|
198
|
+
// handle undefined.
|
|
189
199
|
const lastModified = options.lastModified?.(undefined as never);
|
|
190
200
|
return [{ url: absoluteUrl(path), ...(lastModified ? { lastModified } : {}) }];
|
|
191
201
|
}
|
|
@@ -202,14 +212,14 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
202
212
|
});
|
|
203
213
|
};
|
|
204
214
|
|
|
205
|
-
/** Runs `load` for every locale
|
|
215
|
+
/** Runs `load` for every locale in parallel and keys the results by locale. A site without locales gets `{}`. */
|
|
206
216
|
const forEachLocale = async <T>(load: (locale: L) => Promise<T>): Promise<Partial<Record<L, T>>> =>
|
|
207
217
|
Object.fromEntries(await Promise.all(locales.map(async locale => [locale, await load(locale)] as const))) as Partial<Record<L, T>>;
|
|
208
218
|
|
|
209
219
|
/**
|
|
210
|
-
*
|
|
211
|
-
* timestamps are fetched
|
|
212
|
-
* served rather than one without dates.
|
|
220
|
+
* Builds the legal pages' sitemap entries, each dated by when it last changed in that locale. The
|
|
221
|
+
* timestamps are fetched in parallel. An error from `updatedAt` fails the sitemap, so a stale one
|
|
222
|
+
* is served rather than one without dates.
|
|
213
223
|
*/
|
|
214
224
|
const legalEntries = async <K extends string = (typeof LEGAL_PAGE_KEYS)[number]>({
|
|
215
225
|
keys,
|
|
@@ -224,8 +234,9 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
224
234
|
};
|
|
225
235
|
|
|
226
236
|
/**
|
|
227
|
-
* robots.txt. A deployment that is not production disallows everything. Otherwise the
|
|
228
|
-
* paths are disallowed under every locale segment, since a bare `/admin` matches nothing
|
|
237
|
+
* Builds robots.txt. A deployment that is not production disallows everything. Otherwise the
|
|
238
|
+
* private paths are disallowed under every locale segment, since a bare `/admin` matches nothing
|
|
239
|
+
* there.
|
|
229
240
|
*/
|
|
230
241
|
const robots = ({ privatePaths = [], privateRootPaths = [], indexable = isIndexableEnvironment() }: RobotsInput = {}): MetadataRoute.Robots => {
|
|
231
242
|
if (!indexable) return { rules: { userAgent: "*", disallow: "/" } };
|
|
@@ -238,11 +249,11 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
238
249
|
};
|
|
239
250
|
};
|
|
240
251
|
|
|
241
|
-
/**
|
|
252
|
+
/** Builds an `@id` for an entity that is the same on every page. Page-scoped nodes use the page URL. */
|
|
242
253
|
const siteId = (fragment: string): string => `${config.url}/#${fragment}`;
|
|
243
254
|
const ids = { organization: siteId("organization"), website: siteId("website") } as const;
|
|
244
255
|
|
|
245
|
-
/**
|
|
256
|
+
/** Builds the business node, under a stable `@id` that page-scoped nodes point at. */
|
|
246
257
|
const organizationNode = (company: OrganizationInfo, options: OrganizationOptions = {}): SchemaNode => {
|
|
247
258
|
const country = options.country ?? "NO";
|
|
248
259
|
const type = typeof options.type === "string" ? options.type || undefined : options.type?.length ? [...options.type] : undefined;
|
|
@@ -273,7 +284,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
273
284
|
};
|
|
274
285
|
};
|
|
275
286
|
|
|
276
|
-
/**
|
|
287
|
+
/** Builds the site as an entity, so search engines attribute its pages to it and to the business. */
|
|
277
288
|
const webSiteNode = (locale: L): SchemaNode => ({
|
|
278
289
|
"@type": "WebSite",
|
|
279
290
|
"@id": ids.website,
|
|
@@ -283,7 +294,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
283
294
|
publisher: ref(ids.organization),
|
|
284
295
|
});
|
|
285
296
|
|
|
286
|
-
/**
|
|
297
|
+
/** Builds the trail that search results show in place of the raw URL. The last crumb has no `item`. */
|
|
287
298
|
const breadcrumbNode = (crumbs: Crumb[], pageUrl: string): SchemaNode => ({
|
|
288
299
|
"@type": "BreadcrumbList",
|
|
289
300
|
"@id": `${pageUrl}#breadcrumb`,
|
|
@@ -326,4 +337,5 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
326
337
|
};
|
|
327
338
|
}
|
|
328
339
|
|
|
340
|
+
/** The helpers `defineSite` returns. */
|
|
329
341
|
export type Site<L extends string = string> = ReturnType<typeof defineSite<L>>;
|
package/src/seo/sitemap.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Gets the latest of a set of timestamps, or undefined when there are none. */
|
|
2
2
|
export function latest(dates: readonly (Date | undefined)[]): Date | undefined {
|
|
3
3
|
const known = dates.filter((date): date is Date => date != null);
|
|
4
4
|
if (known.length === 0) return undefined;
|
|
5
5
|
return known.reduce((newest, date) => (date > newest ? date : newest));
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
/** Parses an API timestamp.
|
|
8
|
+
/** Parses an API timestamp. Returns undefined for a missing or malformed value. */
|
|
9
9
|
export function parseTimestamp(value: string | null | undefined): Date | undefined {
|
|
10
10
|
if (!value) return undefined;
|
|
11
11
|
const date = new Date(value);
|
package/src/server/publicApi.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
+
/** A response from `publicGetWithMeta`, with its `Last-Modified` date. */
|
|
1
2
|
export interface PublicResponse<T> {
|
|
2
3
|
data: T;
|
|
3
|
-
/**
|
|
4
|
+
/** The date from the `Last-Modified` header, when the endpoint reports one. */
|
|
4
5
|
lastModified: Date | null;
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
/** Options for one public GET. */
|
|
7
9
|
export interface PublicGetOptions {
|
|
8
|
-
/**
|
|
10
|
+
/** The cache tags, so a revalidate route can purge this fetch by tag. */
|
|
9
11
|
tags?: string[] | undefined;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
/** Options for `createPublicApi`. */
|
|
12
15
|
export interface PublicApiOptions {
|
|
13
|
-
/**
|
|
16
|
+
/** The seconds a cached response stays fresh. Defaults to `60`. */
|
|
14
17
|
revalidate?: number | undefined;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
/**
|
|
20
|
+
/** Thrown when the API could not be reached, or answered with no usable content. */
|
|
18
21
|
export class PublicApiError extends Error {
|
|
19
22
|
readonly path: string;
|
|
20
23
|
readonly status: number | null;
|
|
@@ -36,9 +39,10 @@ function lastModifiedOf(response: Response): Date | null {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
|
-
*
|
|
42
|
+
* Creates cached GETs against the API's anonymous endpoints, for server components and route
|
|
43
|
+
* handlers.
|
|
40
44
|
*
|
|
41
|
-
* A 404
|
|
45
|
+
* A 404 returns null. An unreachable or failing API throws `PublicApiError`, so a failed fetch is
|
|
42
46
|
* never cached as an empty page in place of a working one.
|
|
43
47
|
*/
|
|
44
48
|
export function createPublicApi(baseUrl: string, options: PublicApiOptions = {}) {
|
|
@@ -51,7 +55,7 @@ export function createPublicApi(baseUrl: string, options: PublicApiOptions = {})
|
|
|
51
55
|
|
|
52
56
|
// The path is joined onto the base URL. A path that does not start with a single "/" is a bug in
|
|
53
57
|
// the caller, so it throws. A path with a dot segment, often a slug from the URL, names no resource,
|
|
54
|
-
// so it gives null, the same as a 404.
|
|
58
|
+
// so it gives null, the same as a 404. Pass a slug through encodeURIComponent first.
|
|
55
59
|
function urlFor(path: string): string | null {
|
|
56
60
|
if (!path.startsWith("/") || path.startsWith("//")) {
|
|
57
61
|
throw new Error(`Public API path must start with a single "/" under the base URL: ${path}`);
|
|
@@ -85,12 +89,12 @@ export function createPublicApi(baseUrl: string, options: PublicApiOptions = {})
|
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
/**
|
|
92
|
+
/** Gets the data alone. Returns null when the resource does not exist. */
|
|
89
93
|
async function publicGet<T>(path: string, opts: PublicGetOptions = {}): Promise<T | null> {
|
|
90
94
|
return (await publicGetWithMeta<T>(path, opts))?.data ?? null;
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
/**
|
|
97
|
+
/** Gets data a page can do without. Returns null when the API fails, so the page still renders. */
|
|
94
98
|
async function publicGetOptional<T>(path: string, opts: PublicGetOptions = {}): Promise<T | null> {
|
|
95
99
|
try {
|
|
96
100
|
return await publicGet<T>(path, opts);
|
|
@@ -4,11 +4,12 @@ import jwt from "jsonwebtoken";
|
|
|
4
4
|
import { getToken, type JWT } from "next-auth/jwt";
|
|
5
5
|
import { ABSOLUTE_SESSION_MAX_AGE, CLOCK_TOLERANCE_S, isTerminalSessionError } from "../session/expiry";
|
|
6
6
|
|
|
7
|
+
/** Options for `createRevalidateRoute`. */
|
|
7
8
|
export interface RevalidateRouteOptions {
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
* such as `/builds/[slug]`, purges every page of that route. The
|
|
11
|
-
* cache tag, so tag fetches with it.
|
|
10
|
+
* The targets the client may send, each with the page paths it purges. A path with a dynamic
|
|
11
|
+
* segment, such as `/builds/[slug]`, purges every page of that route. The route also purges the
|
|
12
|
+
* target name as a cache tag, so tag fetches with it.
|
|
12
13
|
*/
|
|
13
14
|
targets: Record<string, readonly string[]>;
|
|
14
15
|
/** The role allowed to purge, as it appears in the session user's roles. */
|
|
@@ -19,10 +20,10 @@ type Token = JWT | null;
|
|
|
19
20
|
|
|
20
21
|
const rolesOf = (token: Token): string[] => (token?.user as { roles?: string[] } | undefined)?.roles ?? [];
|
|
21
22
|
|
|
22
|
-
// getToken only decrypts the cookie, without the jwt callback. So the checks that
|
|
23
|
-
//
|
|
24
|
-
// roles must also come from an access token that has not expired, so a copied cookie of a
|
|
25
|
-
// lost the role stops working within the token's lifetime.
|
|
23
|
+
// getToken only decrypts the cookie, without the jwt callback. So this file repeats the checks that
|
|
24
|
+
// callback makes: a refused refresh still carries its roles, and the cookie outlives the seven
|
|
25
|
+
// days. The roles must also come from an access token that has not expired, so a copied cookie of a
|
|
26
|
+
// user who lost the role stops working within the token's lifetime.
|
|
26
27
|
function isLive(token: Token): boolean {
|
|
27
28
|
if (!token || isTerminalSessionError(token.error as string | undefined)) return false;
|
|
28
29
|
const loginAt = token.loginAt;
|
|
@@ -40,7 +41,8 @@ function accessTokenValid(accessToken: unknown): boolean {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
*
|
|
44
|
+
* Creates a `POST` route handler that purges cached pages after an edit, for
|
|
45
|
+
* `app/api/revalidate/route.ts`.
|
|
44
46
|
* It answers 403 unless the caller has a live session with `role`, 415 unless the body is JSON, and
|
|
45
47
|
* 400 for a target it does not know.
|
|
46
48
|
*/
|
package/src/session/expiry.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
/** How long a session lasts after sign-in, however often it is refreshed. Seven days. */
|
|
1
|
+
/** How long a session lasts after sign-in, in milliseconds, however often it is refreshed. Seven days. */
|
|
2
2
|
export const ABSOLUTE_SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
|
|
3
3
|
|
|
4
|
-
/** How far this server's clock may differ from the API's when a token's times are checked. */
|
|
4
|
+
/** How far this server's clock may differ from the API's, in seconds, when a token's times are checked. */
|
|
5
5
|
export const CLOCK_TOLERANCE_S = 60;
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* The errors the jwt callback sets when the session cannot be recovered without a new sign-in. A
|
|
9
9
|
* transient refresh failure, such as a network error or an API restart, sets no error, so the
|
|
10
10
|
* session keeps working and a later session read retries the refresh.
|
|
11
11
|
*/
|
|
@@ -31,7 +31,7 @@ export type SessionError = (typeof SESSION_ERRORS)[keyof typeof SESSION_ERRORS];
|
|
|
31
31
|
|
|
32
32
|
const TERMINAL: ReadonlySet<string> = new Set(Object.values(SESSION_ERRORS));
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/** Checks whether a session error means the user has to sign in again. */
|
|
35
35
|
export function isTerminalSessionError(error: string | undefined): boolean {
|
|
36
36
|
return error != null && TERMINAL.has(error);
|
|
37
37
|
}
|
|
@@ -58,19 +58,21 @@ function emit(): void {
|
|
|
58
58
|
for (const listener of [...state.listeners]) listener();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/** Asks the user to sign in again without leaving the page. */
|
|
62
62
|
export function openSessionPrompt(): void {
|
|
63
63
|
if (state.open) return;
|
|
64
64
|
state.open = true;
|
|
65
65
|
emit();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Closes the re-sign-in prompt. */
|
|
68
69
|
export function closeSessionPrompt(): void {
|
|
69
70
|
if (!state.open) return;
|
|
70
71
|
state.open = false;
|
|
71
72
|
emit();
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** Subscribes to the prompt opening and closing. Returns a function that unsubscribes. */
|
|
74
76
|
export function subscribeSessionPrompt(listener: () => void): () => void {
|
|
75
77
|
state.listeners.add(listener);
|
|
76
78
|
return () => {
|
|
@@ -78,6 +80,7 @@ export function subscribeSessionPrompt(listener: () => void): () => void {
|
|
|
78
80
|
};
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/** Checks whether the re-sign-in prompt is open. */
|
|
81
84
|
export function getSessionPromptOpen(): boolean {
|
|
82
85
|
return state.open;
|
|
83
86
|
}
|
|
@@ -6,14 +6,15 @@ import type { ReactNode } from "react";
|
|
|
6
6
|
/** Four minutes, shorter than the five minutes before expiry when a refresh falls due. */
|
|
7
7
|
const SESSION_POLL_SECONDS = 4 * 60;
|
|
8
8
|
|
|
9
|
+
/** Props for `AppSessionProvider`. */
|
|
9
10
|
export interface AppSessionProviderProps {
|
|
10
|
-
/**
|
|
11
|
+
/** The seconds between session reads. Defaults to four minutes. */
|
|
11
12
|
refetchInterval?: number | undefined;
|
|
12
13
|
children: ReactNode;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* next-auth's `SessionProvider
|
|
17
|
+
* Wraps next-auth's `SessionProvider` and reads the session again on a timer. Each read lets the jwt
|
|
17
18
|
* callback renew the access token ahead of expiry, so an open tab stays usable and does not find a
|
|
18
19
|
* dead session on the next save. It also keeps what `useSession` reports in this tab up to date.
|
|
19
20
|
* next-auth polls only once a session exists, so signed-out visitors cost nothing.
|
|
@@ -2,26 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
import { signIn } from "next-auth/react";
|
|
4
4
|
import { useState, type FormEvent, type ReactNode } from "react";
|
|
5
|
-
import { SIGN_IN_ERRORS } from "../
|
|
6
|
-
import { Alert } from "
|
|
7
|
-
import { PasswordInput } from "
|
|
5
|
+
import { SIGN_IN_ERRORS } from "../expiry";
|
|
6
|
+
import { Alert } from "../../ui/Alert";
|
|
7
|
+
import { PasswordInput } from "../../ui/PasswordInput";
|
|
8
8
|
import {
|
|
9
9
|
SignInRejected,
|
|
10
10
|
defaultCredentialsFormStrings,
|
|
11
11
|
type CredentialsFormStrings,
|
|
12
12
|
} from "./credentialsFormStrings";
|
|
13
|
-
import { TextInput } from "
|
|
14
|
-
import { withDefaults, type TextOverrides } from "
|
|
13
|
+
import { TextInput } from "../../ui/TextInput";
|
|
14
|
+
import { withDefaults, type TextOverrides } from "../../ui/withDefaults";
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
/** Props for `CredentialsForm`. */
|
|
17
18
|
export interface CredentialsFormProps {
|
|
18
19
|
initialEmail?: string | undefined;
|
|
19
|
-
/**
|
|
20
|
+
/** A function that runs after next-auth accepts the credentials. Throw `SignInRejected` from it to refuse with a reason. */
|
|
20
21
|
onSignedIn: () => void | Promise<void>;
|
|
21
|
-
/**
|
|
22
|
+
/** The extra buttons next to the submit button, such as a close button. */
|
|
22
23
|
children?: ReactNode;
|
|
23
24
|
strings?: TextOverrides<CredentialsFormStrings> | undefined;
|
|
24
|
-
/**
|
|
25
|
+
/** A function that runs when a sign-in starts and when it ends, such as to stop a dialog closing mid-request. */
|
|
25
26
|
onPendingChange?: ((pending: boolean) => void) | undefined;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -32,7 +33,7 @@ function refusedForCsrf(url: string | null | undefined): boolean {
|
|
|
32
33
|
return parsed.pathname.endsWith("/signin") && parsed.searchParams.get("csrf") === "true";
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
+
/** Renders email and password sign-in through next-auth's credentials provider. */
|
|
36
37
|
export function CredentialsForm({
|
|
37
38
|
initialEmail = "",
|
|
38
39
|
onSignedIn,
|
|
@@ -2,23 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
|
-
import { authLinkClassName, authPageClassName, authTitleClassName } from "
|
|
5
|
+
import { authLinkClassName, authPageClassName, authTitleClassName } from "../../ui/authClasses";
|
|
6
6
|
import { CredentialsForm } from "./CredentialsForm";
|
|
7
7
|
import type { CredentialsFormStrings } from "./credentialsFormStrings";
|
|
8
8
|
import { defaultLoginPageStrings, type LoginPageStrings } from "./loginPageStrings";
|
|
9
|
-
import { withDefaults, type TextOverrides } from "
|
|
9
|
+
import { withDefaults, type TextOverrides } from "../../ui/withDefaults";
|
|
10
10
|
|
|
11
|
+
/** Props for `LoginPage`. */
|
|
11
12
|
export interface LoginPageProps {
|
|
12
|
-
/**
|
|
13
|
+
/** The path a signed-in user goes to. */
|
|
13
14
|
redirectTo: string;
|
|
14
|
-
/** The password reset page. */
|
|
15
|
+
/** The password reset page's path. */
|
|
15
16
|
resetHref: string;
|
|
16
|
-
/** The sign-up page. Without it the page has no register link. */
|
|
17
|
+
/** The sign-up page's path. Without it, the page has no register link. */
|
|
17
18
|
registerHref?: string | undefined;
|
|
18
19
|
strings?: TextOverrides<LoginPageStrings> | undefined;
|
|
19
20
|
/** The form's own text, passed to `CredentialsForm`. */
|
|
20
21
|
formStrings?: TextOverrides<CredentialsFormStrings> | undefined;
|
|
21
|
-
/**
|
|
22
|
+
/** The classes that replace the page's outer spacing, such as when the page sits inside the app's own card. */
|
|
22
23
|
className?: string | undefined;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -4,10 +4,11 @@ import { useRouter } from "next/navigation";
|
|
|
4
4
|
import { useEffect, type ReactNode } from "react";
|
|
5
5
|
import { useSessionGate } from "./useSessionGate";
|
|
6
6
|
|
|
7
|
+
/** Props for `ProtectedGate`. */
|
|
7
8
|
export interface ProtectedGateProps {
|
|
8
|
-
/**
|
|
9
|
+
/** The path a signed-out visitor, or a session dead on arrival, is sent to. */
|
|
9
10
|
loginHref: string;
|
|
10
|
-
/**
|
|
11
|
+
/** The content shown while the page may not render yet. */
|
|
11
12
|
fallback?: ReactNode | undefined;
|
|
12
13
|
children: ReactNode;
|
|
13
14
|
}
|
|
@@ -7,23 +7,24 @@ import { useSessionGate } from "./useSessionGate";
|
|
|
7
7
|
interface RoleGateBase {
|
|
8
8
|
/** The role the page needs, as it appears in `session.user.roles`. */
|
|
9
9
|
role: string;
|
|
10
|
-
/**
|
|
10
|
+
/** The content shown while the session or its roles are not known yet. */
|
|
11
11
|
fallback?: ReactNode | undefined;
|
|
12
12
|
children: ReactNode;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** The props that show `denied` to a signed-in user without the role. */
|
|
16
16
|
interface RoleGateWithDenied extends RoleGateBase {
|
|
17
17
|
denied: ReactNode;
|
|
18
18
|
deniedHref?: never;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/** The props that send a signed-in user without the role to `deniedHref`. */
|
|
22
22
|
interface RoleGateWithDeniedHref extends RoleGateBase {
|
|
23
23
|
deniedHref: string;
|
|
24
24
|
denied?: never;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Props for `RoleGate`. Pass either `denied` or `deniedHref`. */
|
|
27
28
|
export type RoleGateProps = RoleGateWithDenied | RoleGateWithDeniedHref;
|
|
28
29
|
|
|
29
30
|
/**
|