@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
package/src/seo/site.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Metadata, MetadataRoute } from "next";
|
|
2
2
|
import { isIndexableEnvironment } from "./environment";
|
|
3
3
|
import { ref, type OrganizationInfo, type SchemaNode } from "./schema";
|
|
4
|
+
import { parseTimestamp } from "./sitemap";
|
|
4
5
|
|
|
5
6
|
/** The size of the share image. */
|
|
6
7
|
export const SHARE_IMAGE_SIZE = { width: 1200, height: 630 } as const;
|
|
@@ -8,12 +9,13 @@ export const SHARE_IMAGE_SIZE = { width: 1200, height: 630 } as const;
|
|
|
8
9
|
/** The robots directive for pages that exist for signed-in users only. */
|
|
9
10
|
export const NOINDEX: Metadata["robots"] = { index: false, follow: false };
|
|
10
11
|
|
|
12
|
+
/** Options for `defineSite`. */
|
|
11
13
|
export interface SiteConfig<L extends string> {
|
|
12
|
-
/** 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. */
|
|
13
15
|
name: string;
|
|
14
16
|
/** The canonical origin, such as `https://www.example.no`, without a trailing slash. */
|
|
15
17
|
url: string;
|
|
16
|
-
/**
|
|
18
|
+
/** The text between a page title and the site name, such as `" | "`. */
|
|
17
19
|
titleSeparator: string;
|
|
18
20
|
/** The locale segments every page is under, such as `["no", "en"]`. A site without them omits this. */
|
|
19
21
|
locales?: readonly L[] | undefined;
|
|
@@ -27,6 +29,7 @@ export interface SiteConfig<L extends string> {
|
|
|
27
29
|
shareImagePath?: string | undefined;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** Options for `pageMetadata`. */
|
|
30
33
|
export interface PageMetadataInput<L extends string> {
|
|
31
34
|
locale: L;
|
|
32
35
|
/** The path below the locale segment, such as `/builds/my-pc`. Omit for the front page. */
|
|
@@ -34,10 +37,11 @@ export interface PageMetadataInput<L extends string> {
|
|
|
34
37
|
/** The page title, without the site name. The root layout's template adds it. */
|
|
35
38
|
title?: string | undefined;
|
|
36
39
|
description?: string | undefined;
|
|
37
|
-
/**
|
|
40
|
+
/** The image URLs for the share preview. Defaults to the share image for the locale. */
|
|
38
41
|
images?: string[] | undefined;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/** Options for `siteMetadata`. */
|
|
41
45
|
export interface SiteMetadataInput<L extends string> {
|
|
42
46
|
locale: L;
|
|
43
47
|
description?: string | undefined;
|
|
@@ -45,42 +49,60 @@ export interface SiteMetadataInput<L extends string> {
|
|
|
45
49
|
manifest?: string | undefined;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
/** One step in a breadcrumb trail. */
|
|
48
53
|
export interface Crumb {
|
|
49
54
|
name: string;
|
|
50
55
|
/** A site-relative path, such as `/no/builds`. The last crumb is the current page and has none. */
|
|
51
56
|
path?: string | undefined;
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/** Options for `localeEntries`. */
|
|
54
60
|
export interface LocaleEntryOptions<L extends string> {
|
|
55
|
-
/** The locales the page exists in. Defaults to all of them. */
|
|
61
|
+
/** The locales the page exists in. Defaults to all of them. A site without locales ignores it. */
|
|
56
62
|
locales?: readonly L[] | undefined;
|
|
57
|
-
/**
|
|
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. */
|
|
58
64
|
lastModified?: ((locale: L) => Date | undefined) | undefined;
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
/** The legal pages every site has, as `createLegalService` names them. */
|
|
68
|
+
export const LEGAL_PAGE_KEYS = ["terms", "privacy", "cookies"] as const;
|
|
69
|
+
|
|
70
|
+
/** Options for `legalEntries`. */
|
|
71
|
+
export interface LegalEntriesInput<K extends string, L extends string> {
|
|
72
|
+
/** The legal pages, each at `/<key>` below the locale. Defaults to `LEGAL_PAGE_KEYS`. */
|
|
73
|
+
keys?: readonly K[] | undefined;
|
|
74
|
+
/** A function that returns when a page last changed, as the API's timestamp. A site without locales passes no locale. */
|
|
75
|
+
updatedAt: (key: K, locale: L | undefined) => Promise<string | null | undefined>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Options for `robots`. */
|
|
61
79
|
export interface RobotsInput {
|
|
62
|
-
/**
|
|
80
|
+
/** The paths below each locale segment that must not be crawled, such as `/admin`. */
|
|
63
81
|
privatePaths?: readonly string[] | undefined;
|
|
64
|
-
/**
|
|
82
|
+
/** The paths outside the locale segments that must not be crawled, such as `/api/`. */
|
|
65
83
|
privateRootPaths?: readonly string[] | undefined;
|
|
66
84
|
/** Whether search engines may index this deployment. Defaults to `isIndexableEnvironment()`. */
|
|
67
85
|
indexable?: boolean | undefined;
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
/** Options for `organizationNode`. */
|
|
70
89
|
export interface OrganizationOptions {
|
|
71
|
-
/** The schema.org type, such as `
|
|
72
|
-
type?: string | undefined;
|
|
90
|
+
/** The schema.org type, such as `ComputerStore`, or several, such as `["Organization", "LocalBusiness"]`. Defaults to `Organization`. */
|
|
91
|
+
type?: string | readonly string[] | undefined;
|
|
92
|
+
/** The pages about the same business elsewhere, such as its entry in Brønnøysundregistrene. */
|
|
93
|
+
sameAs?: readonly string[] | undefined;
|
|
73
94
|
/** The ISO country code for the address and the area served. Defaults to `NO`. */
|
|
74
95
|
country?: string | undefined;
|
|
75
|
-
/**
|
|
96
|
+
/** The site-relative path of the square image. Defaults to `/icon.png`. */
|
|
76
97
|
image?: string | undefined;
|
|
98
|
+
/** The site-relative path of the logo. Defaults to `/logo.png`. */
|
|
77
99
|
logo?: string | undefined;
|
|
78
100
|
}
|
|
79
101
|
|
|
80
102
|
/**
|
|
81
|
-
*
|
|
82
|
-
* breaks are removed, as a URL parser removes them, and leading slashes
|
|
83
|
-
* 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.
|
|
84
106
|
*/
|
|
85
107
|
export function normalizePath(path: string): string {
|
|
86
108
|
const clean = path.replace(/[\t\n\r]/g, "");
|
|
@@ -90,8 +112,8 @@ export function normalizePath(path: string): string {
|
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
/**
|
|
93
|
-
*
|
|
94
|
-
* 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.
|
|
95
117
|
*/
|
|
96
118
|
export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
97
119
|
const locales = config.locales ?? [];
|
|
@@ -100,11 +122,11 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
100
122
|
const shareImagePath = config.shareImagePath ?? "/og";
|
|
101
123
|
const origin = new URL(config.url).origin;
|
|
102
124
|
|
|
103
|
-
/**
|
|
125
|
+
/** Builds a site-relative URL for a page in one locale, such as `/no/builds`. A site without locales has no segment. */
|
|
104
126
|
const localePath = (locale: L | undefined, path = ""): string =>
|
|
105
127
|
locale === undefined || locales.length === 0 ? normalizePath(path) || "/" : `/${locale}${normalizePath(path)}`;
|
|
106
128
|
|
|
107
|
-
/**
|
|
129
|
+
/** Builds an absolute URL on the canonical origin, without a trailing slash. */
|
|
108
130
|
const absoluteUrl = (path = ""): string => {
|
|
109
131
|
const url = new URL(normalizePath(path) || "/", config.url);
|
|
110
132
|
// A path that would leave the site's origin gives the front page instead.
|
|
@@ -114,7 +136,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
114
136
|
|
|
115
137
|
const absoluteLocaleUrl = (locale: L, path = ""): string => absoluteUrl(localePath(locale, path));
|
|
116
138
|
|
|
117
|
-
/**
|
|
139
|
+
/** Builds the language alternates by BCP 47 tag. `x-default` points at the default locale. */
|
|
118
140
|
const languageAlternates = (path: string): Record<string, string> => ({
|
|
119
141
|
...Object.fromEntries(locales.map(l => [tag(l), localePath(l, path)])),
|
|
120
142
|
...(defaultLocale !== undefined ? { "x-default": localePath(defaultLocale, path) } : {}),
|
|
@@ -132,7 +154,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
132
154
|
alt: fullTitle(locale),
|
|
133
155
|
});
|
|
134
156
|
|
|
135
|
-
/**
|
|
157
|
+
/** Builds the canonical URL, hreflang set, and Open Graph for one public page. */
|
|
136
158
|
const pageMetadata = ({ locale, path = "", title, description, images }: PageMetadataInput<L>): Metadata => {
|
|
137
159
|
const canonical = localePath(locale, path);
|
|
138
160
|
return {
|
|
@@ -154,7 +176,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
154
176
|
};
|
|
155
177
|
};
|
|
156
178
|
|
|
157
|
-
/**
|
|
179
|
+
/** Builds the metadata for the locale root layout: the title template, the index directive, and the front page's canonical. */
|
|
158
180
|
const siteMetadata = ({ locale, description, manifest }: SiteMetadataInput<L>): Metadata => {
|
|
159
181
|
const title = fullTitle(locale);
|
|
160
182
|
return {
|
|
@@ -166,8 +188,17 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
166
188
|
};
|
|
167
189
|
};
|
|
168
190
|
|
|
169
|
-
/**
|
|
191
|
+
/**
|
|
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.
|
|
194
|
+
*/
|
|
170
195
|
const localeEntries = (path: string, options: LocaleEntryOptions<L> = {}): MetadataRoute.Sitemap => {
|
|
196
|
+
if (locales.length === 0) {
|
|
197
|
+
// There is no locale to pass. The callback's type cannot say so without making every caller
|
|
198
|
+
// handle undefined.
|
|
199
|
+
const lastModified = options.lastModified?.(undefined as never);
|
|
200
|
+
return [{ url: absoluteUrl(path), ...(lastModified ? { lastModified } : {}) }];
|
|
201
|
+
}
|
|
171
202
|
const served = options.locales ?? locales;
|
|
172
203
|
|
|
173
204
|
const languages: Record<string, string> = Object.fromEntries(served.map(l => [tag(l), absoluteLocaleUrl(l, path)]));
|
|
@@ -181,9 +212,31 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
181
212
|
});
|
|
182
213
|
};
|
|
183
214
|
|
|
215
|
+
/** Runs `load` for every locale in parallel and keys the results by locale. A site without locales gets `{}`. */
|
|
216
|
+
const forEachLocale = async <T>(load: (locale: L) => Promise<T>): Promise<Partial<Record<L, T>>> =>
|
|
217
|
+
Object.fromEntries(await Promise.all(locales.map(async locale => [locale, await load(locale)] as const))) as Partial<Record<L, T>>;
|
|
218
|
+
|
|
219
|
+
/**
|
|
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.
|
|
223
|
+
*/
|
|
224
|
+
const legalEntries = async <K extends string = (typeof LEGAL_PAGE_KEYS)[number]>({
|
|
225
|
+
keys,
|
|
226
|
+
updatedAt,
|
|
227
|
+
}: LegalEntriesInput<K, L>): Promise<MetadataRoute.Sitemap> => {
|
|
228
|
+
const pages = keys ?? (LEGAL_PAGE_KEYS as unknown as readonly K[]);
|
|
229
|
+
const served: (L | undefined)[] = locales.length > 0 ? [...locales] : [undefined];
|
|
230
|
+
const stamps = await Promise.all(
|
|
231
|
+
pages.map(key => Promise.all(served.map(async locale => parseTimestamp(await updatedAt(key, locale))))),
|
|
232
|
+
);
|
|
233
|
+
return pages.flatMap((key, k) => localeEntries(`/${key}`, { lastModified: locale => stamps[k]?.[served.indexOf(locale)] }));
|
|
234
|
+
};
|
|
235
|
+
|
|
184
236
|
/**
|
|
185
|
-
* robots.txt. A deployment that is not production disallows everything. Otherwise the
|
|
186
|
-
* 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.
|
|
187
240
|
*/
|
|
188
241
|
const robots = ({ privatePaths = [], privateRootPaths = [], indexable = isIndexableEnvironment() }: RobotsInput = {}): MetadataRoute.Robots => {
|
|
189
242
|
if (!indexable) return { rules: { userAgent: "*", disallow: "/" } };
|
|
@@ -196,23 +249,26 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
196
249
|
};
|
|
197
250
|
};
|
|
198
251
|
|
|
199
|
-
/**
|
|
252
|
+
/** Builds an `@id` for an entity that is the same on every page. Page-scoped nodes use the page URL. */
|
|
200
253
|
const siteId = (fragment: string): string => `${config.url}/#${fragment}`;
|
|
201
254
|
const ids = { organization: siteId("organization"), website: siteId("website") } as const;
|
|
202
255
|
|
|
203
|
-
/**
|
|
256
|
+
/** Builds the business node, under a stable `@id` that page-scoped nodes point at. */
|
|
204
257
|
const organizationNode = (company: OrganizationInfo, options: OrganizationOptions = {}): SchemaNode => {
|
|
205
258
|
const country = options.country ?? "NO";
|
|
259
|
+
const type = typeof options.type === "string" ? options.type || undefined : options.type?.length ? [...options.type] : undefined;
|
|
260
|
+
const phone = company.phone?.trim();
|
|
261
|
+
const email = company.email?.trim();
|
|
206
262
|
return {
|
|
207
|
-
"@type":
|
|
263
|
+
"@type": type ?? "Organization",
|
|
208
264
|
"@id": ids.organization,
|
|
209
265
|
name: company.name,
|
|
210
266
|
...(company.legalName && company.legalName !== company.name ? { legalName: company.legalName } : {}),
|
|
211
267
|
url: absoluteUrl(),
|
|
212
268
|
image: absoluteUrl(options.image ?? "/icon.png"),
|
|
213
269
|
logo: absoluteUrl(options.logo ?? "/logo.png"),
|
|
214
|
-
telephone:
|
|
215
|
-
email:
|
|
270
|
+
...(phone ? { telephone: phone } : {}),
|
|
271
|
+
...(email ? { email } : {}),
|
|
216
272
|
address: {
|
|
217
273
|
"@type": "PostalAddress",
|
|
218
274
|
streetAddress: company.streetAddress || company.address,
|
|
@@ -224,10 +280,11 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
224
280
|
areaServed: country,
|
|
225
281
|
...(company.vatRegistered && company.orgNumber ? { vatID: `NO${company.orgNumber.replace(/\s/g, "")}MVA` } : {}),
|
|
226
282
|
...(company.orgNumber ? { taxID: company.orgNumber } : {}),
|
|
283
|
+
...(options.sameAs?.length ? { sameAs: [...options.sameAs] } : {}),
|
|
227
284
|
};
|
|
228
285
|
};
|
|
229
286
|
|
|
230
|
-
/**
|
|
287
|
+
/** Builds the site as an entity, so search engines attribute its pages to it and to the business. */
|
|
231
288
|
const webSiteNode = (locale: L): SchemaNode => ({
|
|
232
289
|
"@type": "WebSite",
|
|
233
290
|
"@id": ids.website,
|
|
@@ -237,7 +294,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
237
294
|
publisher: ref(ids.organization),
|
|
238
295
|
});
|
|
239
296
|
|
|
240
|
-
/**
|
|
297
|
+
/** Builds the trail that search results show in place of the raw URL. The last crumb has no `item`. */
|
|
241
298
|
const breadcrumbNode = (crumbs: Crumb[], pageUrl: string): SchemaNode => ({
|
|
242
299
|
"@type": "BreadcrumbList",
|
|
243
300
|
"@id": `${pageUrl}#breadcrumb`,
|
|
@@ -268,6 +325,8 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
268
325
|
pageMetadata,
|
|
269
326
|
siteMetadata,
|
|
270
327
|
localeEntries,
|
|
328
|
+
forEachLocale,
|
|
329
|
+
legalEntries,
|
|
271
330
|
robots,
|
|
272
331
|
siteId,
|
|
273
332
|
ids,
|
|
@@ -278,4 +337,5 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
|
278
337
|
};
|
|
279
338
|
}
|
|
280
339
|
|
|
340
|
+
/** The helpers `defineSite` returns. */
|
|
281
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
|
/**
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { getSession, signOut, useSession } from "next-auth/react";
|
|
4
4
|
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
|
5
5
|
import { Alert } from "../../ui/Alert";
|
|
6
|
-
import { CredentialsForm } from "
|
|
7
|
-
import { SignInRejected, type CredentialsFormStrings } from "
|
|
6
|
+
import { CredentialsForm } from "./CredentialsForm";
|
|
7
|
+
import { SignInRejected, type CredentialsFormStrings } from "./credentialsFormStrings";
|
|
8
8
|
import { withDefaults, type TextOverrides } from "../../ui/withDefaults";
|
|
9
9
|
import {
|
|
10
10
|
closeSessionPrompt,
|
|
@@ -34,10 +34,11 @@ function msUntilNextChange(remaining: number): number | null {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
/** Props for `SessionExpiryGuard`. */
|
|
37
38
|
export interface SessionExpiryGuardProps {
|
|
38
|
-
/**
|
|
39
|
+
/** The path a sign-in as a different user is sent to after it is signed out. */
|
|
39
40
|
loginHref: string;
|
|
40
|
-
/**
|
|
41
|
+
/** A function that runs after the prompt signs the user back in, such as to show a toast. */
|
|
41
42
|
onRestored?: (() => void) | undefined;
|
|
42
43
|
strings?: TextOverrides<SessionExpiryGuardStrings> | undefined;
|
|
43
44
|
formStrings?: TextOverrides<CredentialsFormStrings> | undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Plain values, with no "use client", so a server component can import them.
|
|
2
2
|
|
|
3
|
-
/** Thrown from `onSignedIn` to refuse a sign-in.
|
|
3
|
+
/** Thrown from `onSignedIn` to refuse a sign-in. The form shows its message. */
|
|
4
4
|
export class SignInRejected extends Error {}
|
|
5
5
|
|
|
6
6
|
/** The form's text. Pass the app's own language. The defaults are English. */
|