@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
@@ -0,0 +1,47 @@
1
+ /** The business as SjolystInnovation.AppKit.SiteSettings's public `/company` endpoint describes it. */
2
+ export interface CompanyInfo {
3
+ name: string;
4
+ legalName: string;
5
+ orgNumber: string;
6
+ vatRegistered: boolean;
7
+ /** The address on one line, for display. The API derives it from the parts below. */
8
+ address: string;
9
+ streetAddress: string;
10
+ postalCode: string;
11
+ addressLocality: string;
12
+ addressRegion: string;
13
+ email: string;
14
+ phone: string;
15
+ }
16
+
17
+ /** What the app knows about the business without the API. Only the name is required. */
18
+ export type CompanyFallback = Partial<CompanyInfo> & { name: string };
19
+
20
+ /**
21
+ * Builds the company details for public pages from the API's answer, with the app's own values for
22
+ * any field it left blank or could not give. The legal name falls back to the name, and VAT to not
23
+ * registered.
24
+ */
25
+ export function mergeCompanyInfo(data: Partial<CompanyInfo> | null | undefined, fallback: CompanyFallback): CompanyInfo {
26
+ const name = data?.name || fallback.name;
27
+ const pick = (key: Exclude<keyof CompanyInfo, "name" | "legalName" | "vatRegistered">) => data?.[key] || fallback[key] || "";
28
+ return {
29
+ name,
30
+ legalName: data?.legalName || fallback.legalName || name,
31
+ orgNumber: pick("orgNumber"),
32
+ vatRegistered: data?.vatRegistered ?? fallback.vatRegistered ?? false,
33
+ address: pick("address"),
34
+ streetAddress: pick("streetAddress"),
35
+ postalCode: pick("postalCode"),
36
+ addressLocality: pick("addressLocality"),
37
+ addressRegion: pick("addressRegion"),
38
+ email: pick("email"),
39
+ phone: pick("phone"),
40
+ };
41
+ }
42
+
43
+ /** Formats the org number with the Norwegian ` MVA` suffix once the business is VAT registered. Returns `""` without a number. */
44
+ export function formatOrgNumber({ orgNumber, vatRegistered }: { orgNumber?: string | null | undefined; vatRegistered?: boolean | null | undefined }): string {
45
+ if (!orgNumber) return "";
46
+ return vatRegistered ? `${orgNumber} MVA` : orgNumber;
47
+ }
@@ -3,8 +3,9 @@ import { getSession } from "next-auth/react";
3
3
  import { isTerminalSessionError, openSessionPrompt } from "../session/expiry";
4
4
 
5
5
  /**
6
- * An axios instance for the signed-in API, for browser code. Each request carries the session's
7
- * access token. A 401 on a dead session opens the re-sign-in prompt, so the page keeps its state.
6
+ * Creates an axios instance for the signed-in API, for browser code. Each request carries the
7
+ * session's access token. A 401 on a dead session opens the re-sign-in prompt, so the page keeps
8
+ * its state.
8
9
  */
9
10
  export function createApiClient(baseURL: string): AxiosInstance {
10
11
  if (!baseURL) {
@@ -1,14 +1,14 @@
1
1
  import axios, { type AxiosResponse } from "axios";
2
2
 
3
3
  /**
4
- * Messages to show for a status code. On a 4xx they are used when the response carries no specific
5
- * message of its own. On a 5xx they win over the body. Key 0 covers a request that got no response,
6
- * because of a dropped connection or a timeout.
4
+ * The messages to show for a status code. On a 4xx, `formatApiError` uses them when the response
5
+ * carries no specific message of its own. On a 5xx, they win over the body. Key 0 covers a request
6
+ * that got no response, because of a dropped connection or a timeout.
7
7
  */
8
8
  export type StatusMessages = Partial<Record<number, string>>;
9
9
 
10
10
  /**
11
- * Axios codes for a request that never got a response from the network. A browser reports every
11
+ * The axios codes for a request that never got a response from the network. A browser reports every
12
12
  * network failure as ERR_NETWORK. On the server, Node reports the specific cause.
13
13
  */
14
14
  const NO_RESPONSE_CODES = new Set([
@@ -36,8 +36,8 @@ const NO_RESPONSE_CODES = new Set([
36
36
  * as "Bad Gateway". A problem details `title` is never used. ASP.NET Core fills it with generic text
37
37
  * such as "Bad Request", which says less than `fallback`.
38
38
  *
39
- * Only single line text counts as a message. Axios's own message, such as "Request failed with
40
- * status code 500", is never shown. A cancelled request gives `fallback`. Any other `Error` gives
39
+ * Only single-line text counts as a message. Axios's own message, such as "Request failed with
40
+ * status code 500", is never shown. A canceled request gives `fallback`. Any other `Error` gives
41
41
  * its message.
42
42
  */
43
43
  export function formatApiError(
@@ -60,18 +60,18 @@ export function formatApiError(
60
60
  return fallback;
61
61
  }
62
62
 
63
- /** The message the body carries for this failure, if it carries one. */
63
+ /** Gets the message the body carries for this failure, if it carries one. */
64
64
  function readBody(response: AxiosResponse): string | undefined {
65
65
  const data = parseIfJson(response.data);
66
66
  if (typeof data === "string") return singleLine(data);
67
- // Only a parsed JSON object has fields to read. A Document, Blob or ArrayBuffer does not.
67
+ // Only a parsed JSON object has fields to read. A Document, Blob, or ArrayBuffer does not.
68
68
  if (!isPlainObject(data)) return undefined;
69
69
  return firstText(data.message) ?? firstValidationError(data.errors) ?? singleLine(data.detail);
70
70
  }
71
71
 
72
72
  /**
73
- * Axios leaves a JSON body as a string when the request asked for text, so parse it here. Plain text
74
- * such as "Invalid email or password." is not valid JSON and stays as it is.
73
+ * Parses a JSON body that axios left as a string because the request asked for text. Plain text such
74
+ * as "Invalid email or password." is not valid JSON and stays as it is.
75
75
  */
76
76
  function parseIfJson(data: unknown): unknown {
77
77
  if (typeof data !== "string") return data;
@@ -98,12 +98,12 @@ function firstValidationError(errors: unknown): string | undefined {
98
98
  return undefined;
99
99
  }
100
100
 
101
- /** A single line of text, or the first entry when the value is a list of them. */
101
+ /** Gets a single line of text, or the first entry when the value is a list of them. */
102
102
  function firstText(value: unknown): string | undefined {
103
103
  return singleLine(Array.isArray(value) ? value[0] : value);
104
104
  }
105
105
 
106
- /** The trimmed text, when it is one line of text and not HTML. */
106
+ /** Gets the trimmed text, when it is one line of text and not HTML. */
107
107
  function singleLine(value: unknown): string | undefined {
108
108
  if (typeof value !== "string") return undefined;
109
109
  const text = value.trim();
package/src/api/index.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  export { apiFieldErrors } from "./apiFieldErrors";
2
+ export { formatOrgNumber, mergeCompanyInfo } from "./company";
3
+ export type { CompanyFallback, CompanyInfo } from "./company";
2
4
  export { createApiClient } from "./createApiClient";
5
+ export { formatApiError } from "./formatApiError";
6
+ export type { StatusMessages } from "./formatApiError";
3
7
  export { request } from "./request";
4
- export { requestRevalidate } from "./requestRevalidate";
8
+ export { defineRevalidateTargets } from "./revalidateTargets";
9
+ export type { RevalidateTargetPaths, RevalidateTargetsConfig } from "./revalidateTargets";
5
10
  export {
6
11
  createPublicClient,
7
12
  createUserService,
@@ -1,9 +1,9 @@
1
1
  import axios from "axios";
2
- import { formatApiError, type StatusMessages } from "../errors";
2
+ import { formatApiError, type StatusMessages } from "./formatApiError";
3
3
 
4
4
  /**
5
5
  * Runs an API call and returns its data. A failure throws an `Error` with a message for the user,
6
- * from `formatApiError`. A cancelled call rethrows the cancel as it is, so the caller can ignore it.
6
+ * from `formatApiError`. A canceled call rethrows the cancel as it is, so the caller can ignore it.
7
7
  */
8
8
  export async function request<T>(
9
9
  call: () => Promise<{ data: T }>,
@@ -0,0 +1,42 @@
1
+ import { normalizePath } from "../seo/site";
2
+ import { requestRevalidate } from "./requestRevalidate";
3
+
4
+ /** The pages one admin edit changes. */
5
+ export interface RevalidateTargetPaths {
6
+ /** The paths below each locale segment, such as `/builds/[slug]`. `""` is the locale's front page. */
7
+ pages?: readonly string[] | undefined;
8
+ /** The paths outside the locale segments, such as `/sitemap.xml`. */
9
+ root?: readonly string[] | undefined;
10
+ }
11
+
12
+ /** Options for `defineRevalidateTargets`. */
13
+ export interface RevalidateTargetsConfig<T extends string> {
14
+ /** The locale segments every page is under. A site without them omits this. */
15
+ locales?: readonly string[] | undefined;
16
+ targets: Record<T, RevalidateTargetPaths>;
17
+ }
18
+
19
+ /**
20
+ * Defines the cache targets an app's admin edits purge, in one place for the client and the server.
21
+ * `targets` names them for fetch tags, `paths` lists what `createRevalidateRoute` purges for each,
22
+ * and `revalidate` asks the route to purge one after an edit. A target with no paths is purged by
23
+ * its tag alone.
24
+ */
25
+ export function defineRevalidateTargets<T extends string>({ locales = [], targets }: RevalidateTargetsConfig<T>) {
26
+ const names = Object.keys(targets) as T[];
27
+
28
+ return {
29
+ targets: Object.fromEntries(names.map(name => [name, name])) as { readonly [K in T]: K },
30
+ paths: Object.fromEntries(
31
+ names.map(name => {
32
+ const { pages = [], root = [] } = targets[name];
33
+ const localized =
34
+ locales.length > 0
35
+ ? locales.flatMap(locale => pages.map(page => `/${locale}${normalizePath(page)}`))
36
+ : pages.map(page => normalizePath(page) || "/");
37
+ return [name, [...localized, ...root]];
38
+ }),
39
+ ) as Record<T, string[]>,
40
+ revalidate: (target: T): Promise<void> => requestRevalidate(target),
41
+ };
42
+ }
@@ -4,7 +4,7 @@ import type { Branding } from "../branding/types";
4
4
  import { request } from "./request";
5
5
 
6
6
  /**
7
- * An axios instance for the API's anonymous endpoints, without the session's token. Like
7
+ * Creates an axios instance for the API's anonymous endpoints, without the session's token. Like
8
8
  * `createApiClient`, it only sends requests to `baseURL`.
9
9
  */
10
10
  export function createPublicClient(baseURL: string): AxiosInstance {
@@ -14,7 +14,7 @@ export function createPublicClient(baseURL: string): AxiosInstance {
14
14
  return axios.create({ baseURL, allowAbsoluteUrls: false });
15
15
  }
16
16
 
17
- /** Runs after an admin change saves, such as to purge the pages that show it. */
17
+ /** A callback that runs after an admin change saves, such as to purge the pages that show it. */
18
18
  export type OnSaved = () => Promise<void> | void;
19
19
 
20
20
  async function currentUserId(): Promise<string> {
@@ -35,8 +35,8 @@ export interface UserProfile {
35
35
  }
36
36
 
37
37
  /**
38
- * Password reset and the signed-in user's account, on the auth and account endpoints of
39
- * SjolystInnovation.AppKit.Auth. `api` carries the session, `publicApi` does not.
38
+ * Creates the calls for password reset and the signed-in user's account, on the auth and account
39
+ * endpoints of SjolystInnovation.AppKit.Auth. `api` carries the session, and `publicApi` does not.
40
40
  */
41
41
  export function createUserService(api: AxiosInstance, publicApi: AxiosInstance) {
42
42
  return {
@@ -88,7 +88,7 @@ export interface SiteSettings {
88
88
  publicPhone: string;
89
89
  }
90
90
 
91
- /** The admin's company settings on SjolystInnovation.AppKit.SiteSettings. */
91
+ /** Creates the admin calls for the company settings on SjolystInnovation.AppKit.SiteSettings. */
92
92
  export function createSettingsService(api: AxiosInstance, options: { onSaved?: OnSaved } = {}) {
93
93
  return {
94
94
  get: () => request(() => api.get<SiteSettings>("/settings"), "Failed to load settings"),
@@ -102,8 +102,8 @@ export function createSettingsService(api: AxiosInstance, options: { onSaved?: O
102
102
  }
103
103
 
104
104
  /**
105
- * The logo and favicon on SjolystInnovation.AppKit.SiteSettings. Reading is anonymous and answers
106
- * no branding when the API cannot be reached, so a page still renders.
105
+ * Creates the calls for the logo and favicon on SjolystInnovation.AppKit.SiteSettings. Reading is
106
+ * anonymous and returns no branding when the API cannot be reached, so a page still renders.
107
107
  */
108
108
  export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstance, options: { onSaved?: OnSaved } = {}) {
109
109
  return {
@@ -115,7 +115,7 @@ export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstan
115
115
  }
116
116
  },
117
117
 
118
- /** Sends the form `BrandingManager` builds: `logo`, `icon`, `removeLogo` and `removeIcon`. */
118
+ /** Sends the form `BrandingManager` builds: `logo`, `icon`, `removeLogo`, and `removeIcon`. */
119
119
  async update(form: FormData): Promise<Branding> {
120
120
  const saved = await request(() => api.put<Branding>("/branding", form), "Failed to save branding");
121
121
  await options.onSaved?.();
@@ -134,8 +134,9 @@ export interface LegalPage<Key extends string = string> {
134
134
  }
135
135
 
136
136
  /**
137
- * The terms, privacy and cookie pages on SjolystInnovation.AppKit.SiteSettings. Reading is anonymous
138
- * and answers null for a page that is missing or cannot be read, so a page shows its fallback.
137
+ * Creates the calls for the terms, privacy, and cookie pages on
138
+ * SjolystInnovation.AppKit.SiteSettings. Reading is anonymous and returns null for a page that is
139
+ * missing or cannot be read, so a page shows its fallback.
139
140
  */
140
141
  export function createLegalService<Key extends string = "terms" | "privacy" | "cookies">(
141
142
  api: AxiosInstance,
@@ -153,7 +154,7 @@ export function createLegalService<Key extends string = "terms" | "privacy" | "c
153
154
 
154
155
  listAll: () => request(() => api.get<LegalPage<Key>[]>("/content/legal"), "Failed to load legal pages"),
155
156
 
156
- async save(key: Key, locale: string, title: string, bodyMarkdown: string) {
157
+ async save({ key, locale, title, bodyMarkdown }: { key: Key; locale: string; title: string; bodyMarkdown: string }) {
157
158
  const page = await request(
158
159
  () => api.put<LegalPage<Key>>(`/content/legal/${encodeURIComponent(key)}/${encodeURIComponent(locale)}`, { title, bodyMarkdown }),
159
160
  "Failed to save legal page",
@@ -176,7 +177,7 @@ export interface AdminUser {
176
177
  roles: string[];
177
178
  }
178
179
 
179
- /** User administration on SjolystInnovation.AppKit.Auth: the list, roles, invites and deletion. */
180
+ /** Creates the user administration calls on SjolystInnovation.AppKit.Auth: the list, roles, invites, and deletion. */
180
181
  export function createAdminUsersService(api: AxiosInstance) {
181
182
  return {
182
183
  getUsers: (includeDeleted = false) =>
@@ -191,7 +192,7 @@ export function createAdminUsersService(api: AxiosInstance) {
191
192
  deleteUser: (userId: string) =>
192
193
  request(() => api.delete(`/users/${encodeURIComponent(userId)}/account`), "Failed to delete user"),
193
194
 
194
- invite: (email: string, firstName: string, lastName: string, role: string) =>
195
+ invite: ({ email, firstName, lastName, role }: { email: string; firstName: string; lastName: string; role: string }) =>
195
196
  request(() => api.post("/users/invite", { email, firstName, lastName, role }), "Failed to send invitation"),
196
197
  };
197
198
  }
@@ -44,12 +44,13 @@ interface AuthorizedUser extends User {
44
44
  refreshToken: string;
45
45
  }
46
46
 
47
+ /** Settings for `createAuthOptions`. Pass either `apiUrl` or `http`. */
47
48
  export type AuthOptionsSettings = {
48
49
  /** The server environment, usually `process.env`. It holds the API JWT secret. */
49
50
  env: Record<string, string | undefined>;
50
51
  } & (
51
52
  | {
52
- /** Base URL of the API, such as `https://api.example.com/api`. */
53
+ /** The base URL of the API, such as `https://api.example.com/api`. */
53
54
  apiUrl: string;
54
55
  http?: never;
55
56
  }
@@ -60,7 +61,8 @@ export type AuthOptionsSettings = {
60
61
  }
61
62
  );
62
63
 
63
- // Refresh ahead of expiry, so the token is still fresh when the user saves.
64
+ // How far ahead of expiry a refresh falls due, at most, so the token is still fresh when the user
65
+ // saves.
64
66
  const MAX_REFRESH_SKEW_MS = 5 * 60 * 1000;
65
67
 
66
68
  // The soonest a refresh can be due. The floor stops a short-lived token from refreshing on every
@@ -111,9 +113,9 @@ function failureShape(error: unknown) {
111
113
  }
112
114
 
113
115
  /**
114
- * next-auth options for email and password sign-in against the API. The API issues a short-lived
115
- * access token and a refresh token that changes on every refresh. The session refreshes ahead of
116
- * expiry, rides out temporary API failures, and ends seven days after sign-in.
116
+ * Creates the next-auth options for email and password sign-in against the API. The API issues a
117
+ * short-lived access token and a refresh token that changes on every refresh. The session refreshes
118
+ * ahead of expiry, rides out temporary API failures, and ends seven days after sign-in.
117
119
  */
118
120
  export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSettings): NextAuthOptions {
119
121
  const { env } = settings;
@@ -7,6 +7,7 @@ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
7
7
  import { useBranding } from "./BrandingProvider";
8
8
  import { toDataUrl } from "./types";
9
9
 
10
+ /** The branding form's text. Pass the app's own language. The defaults are English. */
10
11
  export interface BrandingManagerStrings {
11
12
  title: string;
12
13
  logo: string;
@@ -22,12 +23,14 @@ export interface BrandingManagerStrings {
22
23
  saveFailed: string;
23
24
  }
24
25
 
26
+ const IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif,image/x-icon,.ico";
27
+
25
28
  export const defaultBrandingManagerStrings: BrandingManagerStrings = {
26
29
  title: "Branding",
27
30
  logo: "Logo",
28
- logoHint: "Shown in the navbar and footer. PNG/SVG, max 2 MB.",
31
+ logoHint: "Shown in the navbar and footer. PNG, JPEG, WebP, or GIF, max 2 MB.",
29
32
  icon: "Icon (favicon)",
30
- iconHint: "Browser tab icon. Square PNG/ICO, max 2 MB.",
33
+ iconHint: "Browser tab icon. Square PNG or ICO, max 2 MB.",
31
34
  none: "none",
32
35
  choose: "Choose",
33
36
  remove: "Remove",
@@ -37,10 +40,11 @@ export const defaultBrandingManagerStrings: BrandingManagerStrings = {
37
40
  saveFailed: "Failed to save branding",
38
41
  };
39
42
 
43
+ /** Props for `BrandingManager`. */
40
44
  export interface BrandingManagerProps {
41
45
  /**
42
- * Sends the form: `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw
43
- * to show the error's message, such as one from `formatApiError`.
46
+ * A function that sends the form: `logo` and `icon` files, and `removeLogo` and `removeIcon` set
47
+ * to `true`. Throw from it to show the error's message, such as one from `formatApiError`.
44
48
  */
45
49
  save: (form: FormData) => Promise<unknown>;
46
50
  strings?: TextOverrides<BrandingManagerStrings> | undefined;
@@ -93,7 +97,8 @@ function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProp
93
97
  <input
94
98
  ref={inputRef}
95
99
  type="file"
96
- accept="image/*"
100
+ // The types the API accepts, so the picker does not offer an SVG it would refuse.
101
+ accept={IMAGE_TYPES}
97
102
  aria-label={label}
98
103
  className="hidden"
99
104
  onChange={e => onPick(e.target.files?.[0] ?? null)}
@@ -3,6 +3,7 @@
3
3
  import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
4
4
  import { toDataUrl, type Branding } from "./types";
5
5
 
6
+ /** What `useBranding` returns. */
6
7
  export interface BrandingContextValue {
7
8
  branding: Branding;
8
9
  /** The logo as a `data:` URL, or null when none is set. */
@@ -13,21 +14,22 @@ export interface BrandingContextValue {
13
14
 
14
15
  const BrandingContext = createContext<BrandingContextValue>({ branding: {}, logoUrl: null, refresh: () => {} });
15
16
 
16
- /** The branding from the nearest `BrandingProvider`. Empty outside one. */
17
+ /** Gets the branding from the nearest `BrandingProvider`. Returns empty branding outside one. */
17
18
  export const useBranding = () => useContext(BrandingContext);
18
19
 
19
20
  /** How often the provider loads the branding again, so an open tab picks up a change. */
20
21
  export const BRANDING_REFRESH_MS = 5 * 60 * 1000;
21
22
 
23
+ /** Props for `BrandingProvider`. */
22
24
  export interface BrandingProviderProps {
23
25
  children: ReactNode;
24
- /** Loads the branding, such as a GET of `/branding`. A failure keeps the branding it has. */
26
+ /** A function that loads the branding, such as a GET of `/branding`. On a failure the provider keeps the branding it has. */
25
27
  load: () => Promise<Branding>;
26
28
  /** The branding the server rendered with. Without it, the provider loads it on mount. */
27
29
  initial?: Branding | undefined;
28
30
  }
29
31
 
30
- /** Holds the logo and favicon for the components below it, and loads them again every five minutes. */
32
+ /** Provides the logo and favicon to the components below it, and loads them again every five minutes. */
31
33
  export function BrandingProvider({ children, load, initial }: BrandingProviderProps) {
32
34
  const [branding, setBranding] = useState<Branding>(initial ?? {});
33
35
  // The latest load, so an inline function does not restart the refresh timer on every render.
@@ -5,10 +5,9 @@ export {
5
5
  type BrandingManagerStrings,
6
6
  } from "./BrandingManager";
7
7
  export {
8
- BRANDING_REFRESH_MS,
9
8
  BrandingProvider,
10
9
  useBranding,
11
10
  type BrandingContextValue,
12
11
  type BrandingProviderProps,
13
12
  } from "./BrandingProvider";
14
- export { toDataUrl, type Branding } from "./types";
13
+ export type { Branding } from "./types";
@@ -2,22 +2,23 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import type { Branding } from "./types";
4
4
 
5
+ /** Options for `brandingIcon`. */
5
6
  export interface BrandingIconOptions {
6
- /** Loads the branding, such as a cached GET of `/branding`. Throws when the API cannot be reached. */
7
+ /** A function that loads the branding, such as a cached GET of `/branding`. It throws when the API cannot be reached. */
7
8
  load: () => Promise<Branding | null | undefined>;
8
9
  /** The bundled icon for when none is set, such as `public/icon.png`. */
9
10
  fallbackPath: string;
10
- /** Its content type, such as `image/png` or `image/svg+xml`. */
11
+ /** The bundled icon's content type, such as `image/png` or `image/svg+xml`. */
11
12
  fallbackType: string;
12
13
  }
13
14
 
14
15
  /**
15
- * The response for an `app/icon.ts` route: the favicon set in admin settings when it is a PNG,
16
- * JPEG, WebP, GIF or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox CSP
17
- * that a CSP header from the app's `next.config` replaces.
18
- * When the branding cannot be loaded it answers 503 with `no-store`, not the
19
- * bundled icon, since a browser would cache that in place of the one set in settings. The route
20
- * needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment.
16
+ * Builds the response for an `app/icon.ts` route: the favicon set in admin settings when it is a
17
+ * PNG, JPEG, WebP, GIF, or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox
18
+ * CSP that a CSP header from the app's `next.config` replaces. When the branding cannot be loaded,
19
+ * it answers 503 with `no-store` instead of the bundled icon, since a browser would cache that in
20
+ * place of the one set in settings. The route needs `dynamic = "force-dynamic"`, or the build bakes
21
+ * in the icon of the moment.
21
22
  */
22
23
  export async function brandingIcon({ load, fallbackPath, fallbackType }: BrandingIconOptions): Promise<Response> {
23
24
  let branding: Branding | null | undefined;
@@ -47,7 +48,7 @@ const RASTER_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gi
47
48
  const HARDENING = { "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; sandbox" };
48
49
 
49
50
  /**
50
- * A short digest of icon bytes, for a URL that changes when the icon does, such as
51
+ * Computes a short digest of icon bytes, for a URL that changes when the icon does, such as
51
52
  * `/icon?v=<digest>` in the web app manifest. Android refreshes a home screen icon only when the
52
53
  * manifest changes.
53
54
  */
@@ -1,13 +1,13 @@
1
1
  /** The logo and favicon set in admin settings, as the API's `/branding` endpoint returns them. */
2
2
  export interface Branding {
3
- /** Base64 image bytes. */
3
+ /** The logo's image bytes, in base64. */
4
4
  logoData?: string | null | undefined;
5
5
  logoContentType?: string | null | undefined;
6
- /** Base64 image bytes. */
6
+ /** The favicon's image bytes, in base64. */
7
7
  iconData?: string | null | undefined;
8
8
  iconContentType?: string | null | undefined;
9
9
  }
10
10
 
11
- /** A `data:` URL for base64 image bytes, or null when either part is missing. */
11
+ /** Builds a `data:` URL for base64 image bytes. Returns null when either part is missing. */
12
12
  export const toDataUrl = (data?: string | null, contentType?: string | null): string | null =>
13
13
  data && contentType ? `data:${contentType};base64,${data}` : null;
package/src/config.ts CHANGED
@@ -4,18 +4,18 @@
4
4
  */
5
5
  export interface SessionConfig {
6
6
  /**
7
- * Name of the environment variable holding the API JWT secret, such as API_JWT_SECRET. Apps that
8
- * talk to more than one API, or that deploy several apps side by side, need distinct names.
7
+ * The name of the environment variable holding the API JWT secret, such as `API_JWT_SECRET`. Apps
8
+ * that talk to more than one API, or that deploy several apps side by side, need distinct names.
9
9
  */
10
10
  jwtSecretEnvVar: string;
11
11
  /**
12
- * Where an expired or rejected session sends the browser. An app with a locale prefix uses
13
- * something like "/en/login". An app without one uses "/login".
12
+ * The path an expired or rejected session sends the browser to. An app with a locale prefix uses
13
+ * something like `"/en/login"`. An app without one uses `"/login"`.
14
14
  */
15
15
  loginRoute: string;
16
16
  /**
17
- * Prefix for the localStorage keys holding form drafts, usually the app's name, such as
18
- * "example". Drafts are stored under `<prefix>:draft:<owner>:<scope>`, so the prefix itself
17
+ * The prefix for the localStorage keys holding form drafts, usually the app's name, such as
18
+ * `"example"`. Drafts are stored under `<prefix>:draft:<owner>:<scope>`, so the prefix itself
19
19
  * has no trailing colon.
20
20
  */
21
21
  draftStoragePrefix: string;
@@ -31,8 +31,8 @@ export function defineSessionConfig(config: SessionConfig): SessionConfig {
31
31
  }
32
32
 
33
33
  /**
34
- * The key prefix for form drafts. Throws on a colon or an empty name. Those build keys such as
35
- * "app:draft::draft:", which match no draft saved under "app:draft:".
34
+ * Gets the key prefix for form drafts. Throws on a colon or an empty name. Those build keys such as
35
+ * `"app:draft::draft:"`, which match no draft saved under `"app:draft:"`.
36
36
  */
37
37
  export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">): string {
38
38
  const name = config.draftStoragePrefix;
@@ -57,8 +57,8 @@ export function requireEnv(name: string, value: string | undefined): string {
57
57
  * Reads the API JWT secret named by the config. Throws when it is missing, with a message that names
58
58
  * the variable.
59
59
  *
60
- * The caller passes the environment, usually `process.env`, so this entry point needs no Node
61
- * types and stays safe to import in browser code.
60
+ * The caller passes the environment, usually `process.env`, so this module needs no Node types and
61
+ * stays safe to import in browser code. `createAuthOptions` uses it. It is not part of the public API.
62
62
  */
63
63
  export function resolveJwtSecret(
64
64
  config: SessionConfig,
@@ -32,6 +32,7 @@ function read<T>(storageKey: string): T | null {
32
32
  }
33
33
  }
34
34
 
35
+ /** Options for `useFormDraft`. */
35
36
  export interface FormDraftOptions<T> {
36
37
  /**
37
38
  * The signed-in user's id. A browser profile can be shared, so drafts are stored per user. That
@@ -40,27 +41,28 @@ export interface FormDraftOptions<T> {
40
41
  * known.
41
42
  */
42
43
  owner: string | undefined;
43
- /** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
44
+ /** The key for the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
44
45
  scope: string;
45
46
  /** The form's current values. They must survive `JSON.stringify`. */
46
47
  value: T;
47
48
  }
48
49
 
50
+ /** What `useFormDraft` returns. */
49
51
  export interface FormDraft<T> {
50
52
  /**
51
- * The stored draft, while it is still waiting to be restored or dismissed. Read once the owner is
52
- * known and again whenever the owner or scope changes, so it can appear after mount.
53
+ * The stored draft, while it is still waiting to be restored or dismissed. The hook reads it once
54
+ * the owner is known and again whenever the owner or scope changes, so it can appear after mount.
53
55
  */
54
56
  pending: T | null;
55
- /** Stop offering the draft. The caller applies the values it wants. */
57
+ /** Stops offering the draft. The caller applies the values it wants. */
56
58
  dismiss: () => void;
57
- /** Drop the stored draft after a successful save. */
59
+ /** Drops the stored draft. Call it after a successful save. */
58
60
  clear: () => void;
59
61
  }
60
62
 
61
63
  /**
62
- * Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
63
- * Values are written half a second after the last change, so only that last half second is at risk.
64
+ * Keeps a form's values in localStorage so a sign-out, reload, or closed tab does not lose them. The
65
+ * hook writes them half a second after the last change, so only that last half second is at risk.
64
66
  *
65
67
  * A draft found at mount is offered for restore, not applied. So an edit form never silently
66
68
  * overwrites what the API returned. Saving starts at once, whether or not the offer is answered.
package/src/index.ts CHANGED
@@ -1,4 +1,2 @@
1
- export { defineSessionConfig, requireEnv, resolveJwtSecret } from "./config";
1
+ export { defineSessionConfig, requireEnv } from "./config";
2
2
  export type { SessionConfig } from "./config";
3
- export { formatApiError } from "./errors";
4
- export type { StatusMessages } from "./errors";