@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.
Files changed (78) hide show
  1. package/README.md +315 -253
  2. package/package.json +13 -1
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +9 -3
  5. package/src/admin/CompanySettingsForm.tsx +123 -0
  6. package/src/admin/LegalPagesEditor.tsx +132 -0
  7. package/src/admin/LocaleTabs.tsx +36 -0
  8. package/src/admin/StatHistoryChart.tsx +100 -0
  9. package/src/admin/adminUsersPanelStrings.ts +3 -3
  10. package/src/admin/companySettingsFormStrings.ts +50 -0
  11. package/src/admin/index.ts +14 -0
  12. package/src/admin/legalPagesEditorStrings.ts +32 -0
  13. package/src/admin/statHistoryChartStrings.ts +17 -0
  14. package/src/api/apiFieldErrors.ts +4 -4
  15. package/src/api/company.ts +47 -0
  16. package/src/api/createApiClient.ts +3 -2
  17. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  18. package/src/api/index.ts +6 -1
  19. package/src/api/request.ts +2 -2
  20. package/src/api/revalidateTargets.ts +42 -0
  21. package/src/api/services.ts +14 -13
  22. package/src/auth/createAuthOptions.ts +7 -5
  23. package/src/branding/BrandingManager.tsx +10 -5
  24. package/src/branding/BrandingProvider.tsx +5 -3
  25. package/src/branding/index.ts +1 -2
  26. package/src/branding/server.ts +10 -9
  27. package/src/branding/types.ts +3 -3
  28. package/src/config.ts +10 -10
  29. package/src/forms/useFormDraft.ts +9 -7
  30. package/src/index.ts +1 -3
  31. package/src/legal/LegalPageView.tsx +62 -0
  32. package/src/legal/index.ts +1 -0
  33. package/src/markdown/Markdown.tsx +12 -10
  34. package/src/markdown/index.ts +1 -1
  35. package/src/next-auth.d.ts +11 -9
  36. package/src/next-config/headers.ts +96 -0
  37. package/src/next-config/index.ts +18 -107
  38. package/src/payments/PaymentReturn.tsx +9 -7
  39. package/src/payments/formatNok.ts +1 -1
  40. package/src/payments/usePollUntilFinal.ts +7 -5
  41. package/src/seo/JsonLd.tsx +1 -1
  42. package/src/seo/environment.ts +3 -3
  43. package/src/seo/index.ts +2 -1
  44. package/src/seo/og/index.tsx +5 -4
  45. package/src/seo/schema.ts +6 -2
  46. package/src/seo/site.ts +90 -30
  47. package/src/seo/sitemap.ts +2 -2
  48. package/src/server/publicApi.ts +13 -9
  49. package/src/server/revalidateRoute.ts +10 -8
  50. package/src/session/expiry.ts +8 -5
  51. package/src/session/react/AppSessionProvider.tsx +3 -2
  52. package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
  53. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  54. package/src/session/react/ProtectedGate.tsx +3 -2
  55. package/src/session/react/RoleGate.tsx +4 -3
  56. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  57. package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
  58. package/src/session/react/index.ts +8 -2
  59. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  60. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  61. package/src/session/react/useSessionGate.ts +3 -3
  62. package/src/styles.css +2 -0
  63. package/src/testing/index.ts +113 -0
  64. package/src/ui/Alert.tsx +6 -4
  65. package/src/ui/ContentImage.tsx +12 -11
  66. package/src/ui/ErrorState.tsx +5 -3
  67. package/src/ui/NotFoundState.tsx +9 -7
  68. package/src/ui/PasswordInput.tsx +6 -5
  69. package/src/ui/ResetPasswordFlow.tsx +10 -8
  70. package/src/ui/TextArea.tsx +6 -4
  71. package/src/ui/TextInput.tsx +4 -2
  72. package/src/ui/Toggle.tsx +3 -3
  73. package/src/ui/field.tsx +5 -3
  74. package/src/ui/index.ts +2 -10
  75. package/src/ui/resetPasswordFlowStrings.ts +7 -7
  76. package/src/validation/fieldErrors.ts +4 -3
  77. package/src/validation/password.ts +5 -5
  78. 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
- /** Between a page title and the site name, such as `" | "`. */
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
- /** Image URLs for the share preview. Defaults to the share image for the locale. */
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
- /** When the page last changed, per locale. Left out when unknown. */
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
- /** Paths below each locale segment that must not be crawled, such as `/admin`. */
80
+ /** The paths below each locale segment that must not be crawled, such as `/admin`. */
63
81
  privatePaths?: readonly string[] | undefined;
64
- /** Paths outside the locale segments that must not be crawled, such as `/api/`. */
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 `Organization` or `ComputerStore`. Defaults to `Organization`. */
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
- /** Site-relative paths of the square image and the logo. Default to `/icon.png` and `/logo.png`. */
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
- * One leading slash, no trailing slash. `""` and `"/"` both mean the front page. Tabs and line
82
- * breaks are removed, as a URL parser removes them, and leading slashes and backslashes collapse
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
- * The URL, metadata, sitemap, robots and structured data helpers for one site, bound to its name,
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
- /** A site-relative URL for a page in one locale, such as `/no/builds`. A site without locales has no segment. */
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
- /** An absolute URL on the canonical origin, without a trailing slash. */
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
- /** Language alternates by BCP 47 tag. `x-default` points at the default locale. */
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
- /** The canonical URL, hreflang set and Open Graph for one public page. */
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
- /** The metadata for the locale root layout: the title template, the index directive and the front page's canonical. */
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
- /** One sitemap entry per locale a page is served in, each listing the others as alternates. */
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 private
186
- * paths are disallowed under every locale segment, since a bare `/admin` matches nothing there.
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
- /** An `@id` for an entity that is the same on every page. Page-scoped nodes use the page URL. */
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
- /** The business, under a stable `@id` that page-scoped nodes point at. */
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": options.type ?? "Organization",
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: company.phone,
215
- email: company.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
- /** The site as an entity, so search engines attribute its pages to it and to the business. */
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
- /** The trail search results show in place of the raw URL. The last crumb has no `item`. */
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>>;
@@ -1,11 +1,11 @@
1
- /** The latest of a set of timestamps, or undefined when there are none. */
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. A missing or malformed value gives undefined. */
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);
@@ -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
- /** From the `Last-Modified` header, when the endpoint reports one. */
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
- /** Cache tags, so a revalidate route can purge this fetch by tag. */
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
- /** Seconds a cached response stays fresh. Defaults to 60. */
16
+ /** The seconds a cached response stays fresh. Defaults to `60`. */
14
17
  revalidate?: number | undefined;
15
18
  }
16
19
 
17
- /** The API could not be reached, or answered with no usable content. */
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
- * Cached GETs against the API's anonymous endpoints, for server components and route handlers.
42
+ * Creates cached GETs against the API's anonymous endpoints, for server components and route
43
+ * handlers.
40
44
  *
41
- * A 404 gives null. An unreachable or failing API throws `PublicApiError`, so a failed fetch is
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. A slug goes through encodeURIComponent first.
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
- /** The data alone. Null when the resource does not exist. */
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
- /** For data a page can do without. Null on any failure, so the page still renders. */
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
- * Each target the client may send, with the page paths it purges. A path with a dynamic segment,
10
- * such as `/builds/[slug]`, purges every page of that route. The target name is also purged as a
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 callback makes are
23
- // made here: a refused refresh still carries its roles, and the cookie outlives the seven days. The
24
- // roles must also come from an access token that has not expired, so a copied cookie of a user who
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
- * A `POST` route handler that purges cached pages after an edit, for `app/api/revalidate/route.ts`.
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
  */
@@ -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
- * Errors the jwt callback sets when the session cannot be recovered without a new sign-in. A
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
- /** Whether a session error means the user has to sign in again. */
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
- /** Ask the user to sign in again without leaving the page. */
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
- /** Seconds between session reads. Defaults to four minutes. */
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`, reading the session again on a timer. Each read lets the jwt
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 "../session/expiry";
6
- import { Alert } from "./Alert";
7
- import { PasswordInput } from "./PasswordInput";
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 "./TextInput";
14
- import { withDefaults, type TextOverrides } from "./withDefaults";
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
- /** Runs after next-auth accepts the credentials. Throw `SignInRejected` to refuse with a reason. */
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
- /** Extra buttons next to the submit button, such as a close button. */
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
- /** Runs when a sign-in starts and when it ends, such as to stop a dialog closing mid-request. */
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
- /** Email and password sign-in through next-auth's credentials provider. */
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 "./authClasses";
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 "./withDefaults";
9
+ import { withDefaults, type TextOverrides } from "../../ui/withDefaults";
10
10
 
11
+ /** Props for `LoginPage`. */
11
12
  export interface LoginPageProps {
12
- /** Where a signed-in user goes. */
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
- /** Replaces the page's outer spacing, such as when the page sits inside the app's own card. */
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
- /** Where a signed-out visitor, or a session dead on arrival, is sent. */
9
+ /** The path a signed-out visitor, or a session dead on arrival, is sent to. */
9
10
  loginHref: string;
10
- /** What shows while the page may not render yet. */
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
- /** What shows while the session or its roles are not known yet. */
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
- /** Shows `denied` to a signed-in user without the role. */
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
- /** Sends a signed-in user without the role to `deniedHref`. */
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 "../../ui/CredentialsForm";
7
- import { SignInRejected, type CredentialsFormStrings } from "../../ui/credentialsFormStrings";
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
- /** Where a sign-in as a different user is sent after it is signed out. */
39
+ /** The path a sign-in as a different user is sent to after it is signed out. */
39
40
  loginHref: string;
40
- /** Runs after the prompt signs the user back in, such as to show a toast. */
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. Its message is shown in the form. */
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. */