@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.
Files changed (74) hide show
  1. package/README.md +184 -265
  2. package/package.json +1 -1
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +9 -3
  5. package/src/admin/CompanySettingsForm.tsx +2 -1
  6. package/src/admin/LegalPagesEditor.tsx +5 -4
  7. package/src/admin/LocaleTabs.tsx +4 -3
  8. package/src/admin/StatHistoryChart.tsx +4 -3
  9. package/src/admin/adminUsersPanelStrings.ts +3 -3
  10. package/src/admin/legalPagesEditorStrings.ts +1 -1
  11. package/src/admin/statHistoryChartStrings.ts +3 -2
  12. package/src/api/apiFieldErrors.ts +4 -4
  13. package/src/api/company.ts +4 -3
  14. package/src/api/createApiClient.ts +3 -2
  15. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  16. package/src/api/index.ts +2 -1
  17. package/src/api/request.ts +2 -2
  18. package/src/api/revalidateTargets.ts +7 -5
  19. package/src/api/services.ts +14 -13
  20. package/src/auth/createAuthOptions.ts +7 -5
  21. package/src/branding/BrandingManager.tsx +10 -5
  22. package/src/branding/BrandingProvider.tsx +5 -3
  23. package/src/branding/index.ts +1 -2
  24. package/src/branding/server.ts +10 -9
  25. package/src/branding/types.ts +3 -3
  26. package/src/config.ts +10 -10
  27. package/src/forms/useFormDraft.ts +9 -7
  28. package/src/index.ts +1 -3
  29. package/src/legal/LegalPageView.tsx +6 -5
  30. package/src/markdown/Markdown.tsx +12 -10
  31. package/src/markdown/index.ts +1 -1
  32. package/src/next-auth.d.ts +11 -9
  33. package/src/next-config/headers.ts +96 -0
  34. package/src/next-config/index.ts +18 -107
  35. package/src/payments/PaymentReturn.tsx +9 -7
  36. package/src/payments/formatNok.ts +1 -1
  37. package/src/payments/usePollUntilFinal.ts +7 -5
  38. package/src/seo/JsonLd.tsx +1 -1
  39. package/src/seo/environment.ts +3 -3
  40. package/src/seo/index.ts +0 -1
  41. package/src/seo/og/index.tsx +5 -4
  42. package/src/seo/schema.ts +6 -2
  43. package/src/seo/site.ts +44 -32
  44. package/src/seo/sitemap.ts +2 -2
  45. package/src/server/publicApi.ts +13 -9
  46. package/src/server/revalidateRoute.ts +10 -8
  47. package/src/session/expiry.ts +8 -5
  48. package/src/session/react/AppSessionProvider.tsx +3 -2
  49. package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
  50. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  51. package/src/session/react/ProtectedGate.tsx +3 -2
  52. package/src/session/react/RoleGate.tsx +4 -3
  53. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  54. package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
  55. package/src/session/react/index.ts +8 -2
  56. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  57. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  58. package/src/session/react/useSessionGate.ts +3 -3
  59. package/src/testing/index.ts +5 -5
  60. package/src/ui/Alert.tsx +6 -4
  61. package/src/ui/ContentImage.tsx +12 -11
  62. package/src/ui/ErrorState.tsx +5 -3
  63. package/src/ui/NotFoundState.tsx +9 -7
  64. package/src/ui/PasswordInput.tsx +6 -5
  65. package/src/ui/ResetPasswordFlow.tsx +10 -8
  66. package/src/ui/TextArea.tsx +6 -4
  67. package/src/ui/TextInput.tsx +4 -2
  68. package/src/ui/Toggle.tsx +3 -3
  69. package/src/ui/field.tsx +5 -3
  70. package/src/ui/index.ts +2 -10
  71. package/src/ui/resetPasswordFlowStrings.ts +7 -7
  72. package/src/validation/fieldErrors.ts +4 -3
  73. package/src/validation/password.ts +5 -5
  74. package/src/validation/passwordRules.ts +2 -2
@@ -1,9 +1,9 @@
1
- /** Values of `SITE_ENV` that name the live site. `prod` is what the deploy workflows pass. */
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
- * Whether this deployment is the one search engines should index. An unset `SITE_ENV` counts as
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
@@ -4,7 +4,6 @@ export { ref, serializeGraph, type OrganizationInfo, type SchemaNode, type Schem
4
4
  export {
5
5
  defineSite,
6
6
  LEGAL_PAGE_KEYS,
7
- normalizePath,
8
7
  NOINDEX,
9
8
  SHARE_IMAGE_SIZE,
10
9
  type Crumb,
@@ -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
- * Colours as literals. The image renders outside the browser, so it cannot read CSS custom
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
- * The default share preview: the name, a bar in the brand colour and the tagline. Return it from a
18
- * route such as `[locale]/og/route.tsx`, which `pageMetadata` points at. A route, not the
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
- /** The JSON-LD document for one page. `<` is escaped, so the payload cannot close the script tag. */
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
- /** The business as the API describes it. Blank fields are left out of the structured data. */
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
- /** Between a page title and the site name, such as `" | "`. */
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
- /** 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. */
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
- /** When the page last changed, per locale. Left out when unknown. A site without locales calls it once, with no locale. */
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
- /** When a page last changed, as the API's timestamp. A site without locales gets no locale. */
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
- /** 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`. */
74
81
  privatePaths?: readonly string[] | undefined;
75
- /** 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/`. */
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
- /** Pages about the same business elsewhere, such as its entry in Brønnøysundregistrene. */
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
- /** 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`. */
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
- * One leading slash, no trailing slash. `""` and `"/"` both mean the front page. Tabs and line
95
- * breaks are removed, as a URL parser removes them, and leading slashes and backslashes collapse
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
- * The URL, metadata, sitemap, robots and structured data helpers for one site, bound to its name,
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
- /** 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. */
117
126
  const localePath = (locale: L | undefined, path = ""): string =>
118
127
  locale === undefined || locales.length === 0 ? normalizePath(path) || "/" : `/${locale}${normalizePath(path)}`;
119
128
 
120
- /** An absolute URL on the canonical origin, without a trailing slash. */
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
- /** 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. */
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
- /** 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. */
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
- /** 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. */
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
- * One sitemap entry per locale a page is served in, each listing the others as alternates. A site
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, and the callback's type cannot say so without making every caller's simpler.
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 at once and keys the results by locale. A site without locales gets `{}`. */
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
- * The legal pages' sitemap entries, each dated by when it last changed in that locale. The
211
- * timestamps are fetched at once. An error from `updatedAt` fails the sitemap, so a stale one is
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 private
228
- * 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.
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
- /** 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. */
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
- /** 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. */
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
- /** 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. */
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
- /** 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`. */
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>>;
@@ -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
  /**