@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -13,6 +13,7 @@ export interface AdminTab {
13
13
 
14
14
  type GateProps = RoleGateProps extends infer P ? (P extends unknown ? Omit<P, "children"> : never) : never;
15
15
 
16
+ /** Props for `AdminShell`. The gate props are `RoleGate`'s, without `children`. */
16
17
  export type AdminShellProps = GateProps & {
17
18
  title: ReactNode;
18
19
  tabs: readonly AdminTab[];
@@ -48,6 +49,6 @@ export function AdminShell({ title, tabs, children, ...gate }: AdminShellProps)
48
49
  {children}
49
50
  </div>
50
51
  );
51
- // The gate props came in as one of RoleGate's two shapes, so they go back as that shape.
52
+ // The gate props arrive as one of RoleGate's two shapes, so they go back as that shape.
52
53
  return <RoleGate {...({ ...gate, children: page } as RoleGateProps)} />;
53
54
  }
@@ -9,12 +9,13 @@ import { Toggle } from "../ui/Toggle";
9
9
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
10
10
  import { defaultAdminUsersPanelStrings, type AdminUsersPanelStrings } from "./adminUsersPanelStrings";
11
11
 
12
+ /** Props for `AdminUsersPanel`. */
12
13
  export interface AdminUsersPanelProps {
13
14
  /** The user admin calls, such as `createAdminUsersService(api)`. */
14
15
  service: ReturnType<typeof createAdminUsersService>;
15
16
  /** The roles an admin may give, from the app's own endpoint. */
16
17
  getRoles: () => Promise<string[]>;
17
- /** The role a new invite starts with. `Admin` by default. */
18
+ /** The role a new invite starts with. Defaults to `Admin`. */
18
19
  defaultInviteRole?: string | undefined;
19
20
  strings?: TextOverrides<AdminUsersPanelStrings> | undefined;
20
21
  }
@@ -39,7 +40,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
39
40
  const [roleLoading, setRoleLoading] = useState(false);
40
41
  const [invite, setInvite] = useState({ email: "", firstName: "", lastName: "", role: defaultInviteRole });
41
42
  const [inviting, setInviting] = useState(false);
42
- // Bumped to fetch the list again after a change, through the same effect as every other load.
43
+ // Bumping it fetches the list again after a change, through the same effect as every other load.
43
44
  const [reloads, setReloads] = useState(0);
44
45
  // The latest service, so an inline one does not fetch the list again on every render. The roles
45
46
  // load once, so the first getRoles is enough.
@@ -134,7 +135,12 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
134
135
  }
135
136
  setInviting(true);
136
137
  try {
137
- await serviceRef.current.invite(invite.email.trim(), invite.firstName.trim(), invite.lastName.trim(), invite.role);
138
+ await serviceRef.current.invite({
139
+ email: invite.email.trim(),
140
+ firstName: invite.firstName.trim(),
141
+ lastName: invite.lastName.trim(),
142
+ role: invite.role,
143
+ });
138
144
  toast.success(text.inviteSent);
139
145
  setInvite({ email: "", firstName: "", lastName: "", role: invite.role });
140
146
  reload();
@@ -8,11 +8,12 @@ import { Toggle } from "../ui/Toggle";
8
8
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
9
9
  import { defaultCompanySettingsFormStrings, type CompanySettingsFormStrings } from "./companySettingsFormStrings";
10
10
 
11
+ /** Props for `CompanySettingsForm`. */
11
12
  export interface CompanySettingsFormProps {
12
13
  /** The settings calls, such as `createSettingsService(api)`. */
13
14
  service: { get: () => Promise<SiteSettings>; update: (settings: SiteSettings) => Promise<SiteSettings> };
14
15
  strings?: TextOverrides<CompanySettingsFormStrings> | undefined;
15
- /** Shown below the form once the settings have loaded, such as the `BrandingManager`. */
16
+ /** The content shown below the form once the settings have loaded, such as the `BrandingManager`. */
16
17
  children?: ReactNode;
17
18
  }
18
19
 
@@ -8,17 +8,18 @@ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
8
8
  import { defaultLegalPagesEditorStrings, type LegalPagesEditorStrings } from "./legalPagesEditorStrings";
9
9
  import { LocaleTabs } from "./LocaleTabs";
10
10
 
11
+ /** Props for `LegalPagesEditor`. */
11
12
  export interface LegalPagesEditorProps<K extends string, L extends string> {
12
13
  /** The legal page calls, such as `createLegalService(api, publicApi)`. */
13
14
  service: {
14
15
  listAll: () => Promise<LegalPage<K>[]>;
15
- save: (key: K, locale: L, title: string, bodyMarkdown: string) => Promise<unknown>;
16
+ save: (page: { key: K; locale: L; title: string; bodyMarkdown: string }) => Promise<unknown>;
16
17
  };
17
- /** The pages, in tab order. The first opens. */
18
+ /** The pages, in tab order. The editor opens on the first. */
18
19
  keys: readonly K[];
19
20
  /** Each page's tab label, such as `Terms`. */
20
21
  keyLabels: Record<K, string>;
21
- /** The locales each page is written in. The first opens. */
22
+ /** The locales each page is written in. The editor opens on the first. */
22
23
  locales: readonly L[];
23
24
  /** Each locale's tab label, such as `Norsk`. */
24
25
  localeLabels: Record<L, string>;
@@ -70,7 +71,7 @@ export function LegalPagesEditor<K extends string, L extends string>({ service,
70
71
  }
71
72
  setSaving(true);
72
73
  try {
73
- await serviceRef.current.save(activeKey, activeLocale, draft.title.trim(), draft.bodyMarkdown);
74
+ await serviceRef.current.save({ key: activeKey, locale: activeLocale, title: draft.title.trim(), bodyMarkdown: draft.bodyMarkdown });
74
75
  toast.success(text.saved);
75
76
  } catch (err) {
76
77
  toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
@@ -1,18 +1,19 @@
1
1
  "use client";
2
2
 
3
+ /** Props for `LocaleTabs`. */
3
4
  export interface LocaleTabsProps<L extends string> {
4
5
  locales: readonly L[];
5
6
  /** Each locale's tab label, such as `Norsk`. */
6
7
  labels: Record<L, string>;
7
8
  active: L;
8
9
  onChange: (locale: L) => void;
9
- /** Which locales have content. A locale marked false gets a dot titled `notFilled`. */
10
+ /** The locales that have content. A locale set to `false` gets a dot titled `notFilled`. */
10
11
  filled?: Partial<Record<L, boolean>> | undefined;
11
- /** The dot's title, such as "Not filled in". */
12
+ /** The dot's title. Defaults to "Not filled in". */
12
13
  notFilled?: string | undefined;
13
14
  }
14
15
 
15
- /** Tabs for editing one translation at a time. */
16
+ /** Renders tabs for editing one translation at a time. */
16
17
  export function LocaleTabs<L extends string>({ locales, labels, active, onChange, filled, notFilled = "Not filled in" }: LocaleTabsProps<L>) {
17
18
  return (
18
19
  <div className="flex items-center gap-1 border-b border-gray-200">
@@ -9,10 +9,11 @@ export interface StatSeries<T> {
9
9
  /** The field of each day's row the line plots. A missing or non-numeric value counts as zero. */
10
10
  key: keyof T & string;
11
11
  label: string;
12
- /** The line's colour. Defaults to the next colour in `STAT_SERIES_COLORS`. */
12
+ /** The line's color. Defaults to the next color in `STAT_SERIES_COLORS`. */
13
13
  color?: string | undefined;
14
14
  }
15
15
 
16
+ /** Props for `StatHistoryChart`. */
16
17
  export interface StatHistoryChartProps<T extends { date: string }> {
17
18
  /** One row per day, oldest first, with `date` as `YYYY-MM-DD`. */
18
19
  data: readonly T[];
@@ -25,8 +26,8 @@ const H = 240;
25
26
  const PAD = { top: 16, right: 16, bottom: 28, left: 32 };
26
27
 
27
28
  /**
28
- * A line per series over the days, with the scale from zero to the highest shown value. The legend
29
- * buttons hide and show a line, and the scale follows the lines that are shown.
29
+ * Renders a line per series over the days, with the scale from zero to the highest shown value. The
30
+ * legend buttons hide and show a line, and the scale follows the lines that are shown.
30
31
  */
31
32
  export function StatHistoryChart<T extends { date: string }>({ data, series, strings }: StatHistoryChartProps<T>) {
32
33
  const text = withDefaults(defaultStatHistoryChartStrings, strings);
@@ -1,8 +1,8 @@
1
1
  // Plain values, with no "use client", so a server component can import them.
2
2
 
3
3
  /**
4
- * The users panel's text. Pass the app's own language. The defaults are English. `{name}` and `{role}`
5
- * are filled in where a text names them.
4
+ * The users panel's text. Pass the app's own language. The defaults are English. The panel fills in
5
+ * `{name}` and `{role}` where a text names them.
6
6
  */
7
7
  export interface AdminUsersPanelStrings {
8
8
  users: string;
@@ -19,7 +19,7 @@ export interface AdminUsersPanelStrings {
19
19
  /** The tag after a deleted user's name. */
20
20
  deleted: string;
21
21
  delete: string;
22
- /** Before the role name on the button that takes a role away. */
22
+ /** The text before the role name on the button that takes a role away. */
23
23
  remove: string;
24
24
  noRoles: string;
25
25
  addRole: string;
@@ -11,7 +11,7 @@ export interface LegalPagesEditorStrings {
11
11
  save: string;
12
12
  saving: string;
13
13
  loadFailed: string;
14
- /** When the title or the content is blank. */
14
+ /** The message shown when the title or the content is blank. */
15
15
  required: string;
16
16
  saved: string;
17
17
  saveFailed: string;
@@ -1,7 +1,8 @@
1
1
  // Plain values, with no "use client", so a server component can import them.
2
2
 
3
+ /** The chart's text. Pass the app's own language. The defaults are English. */
3
4
  export interface StatHistoryChartStrings {
4
- /** Shown instead of the chart when there are no days yet. */
5
+ /** The text shown instead of the chart when there are no days yet. */
5
6
  empty: string;
6
7
  /** The chart's accessible name. */
7
8
  chart: string;
@@ -12,5 +13,5 @@ export const defaultStatHistoryChartStrings: StatHistoryChartStrings = {
12
13
  chart: "Stats over time",
13
14
  };
14
15
 
15
- /** The default line colours, in order: blue, green, amber, teal and purple. */
16
+ /** The default line colors, in order: blue, green, amber, teal, and purple. */
16
17
  export const STAT_SERIES_COLORS = ["#2563eb", "#16a34a", "#d97706", "#0d9488", "#9333ea"] as const;
@@ -1,10 +1,10 @@
1
1
  import axios from "axios";
2
2
 
3
3
  /**
4
- * The per-field messages of an ASP.NET validation problem, keyed by the field's name in camelCase so
5
- * they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
6
- * `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body. Answers null when there
7
- * are no field errors, so the caller falls back to one message for the whole form.
4
+ * Gets the per-field messages of an ASP.NET validation problem, keyed by the field's name in
5
+ * camelCase so they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
6
+ * `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body. Returns null when
7
+ * there are no field errors, so the caller falls back to one message for the whole form.
8
8
  */
9
9
  export function apiFieldErrors(error: unknown): Record<string, string[]> | null {
10
10
  const data = axios.isAxiosError(error) ? error.response?.data : error;
@@ -18,8 +18,9 @@ export interface CompanyInfo {
18
18
  export type CompanyFallback = Partial<CompanyInfo> & { name: string };
19
19
 
20
20
  /**
21
- * The company details for public pages: the API's answer, with the app's own values for any field
22
- * it left blank or could not give. The legal name falls back to the name, and VAT to not registered.
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.
23
24
  */
24
25
  export function mergeCompanyInfo(data: Partial<CompanyInfo> | null | undefined, fallback: CompanyFallback): CompanyInfo {
25
26
  const name = data?.name || fallback.name;
@@ -39,7 +40,7 @@ export function mergeCompanyInfo(data: Partial<CompanyInfo> | null | undefined,
39
40
  };
40
41
  }
41
42
 
42
- /** The org number with the Norwegian ` MVA` suffix once the business is VAT registered. Empty without a number. */
43
+ /** Formats the org number with the Norwegian ` MVA` suffix once the business is VAT registered. Returns `""` without a number. */
43
44
  export function formatOrgNumber({ orgNumber, vatRegistered }: { orgNumber?: string | null | undefined; vatRegistered?: boolean | null | undefined }): string {
44
45
  if (!orgNumber) return "";
45
46
  return vatRegistered ? `${orgNumber} MVA` : orgNumber;
@@ -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
@@ -2,8 +2,9 @@ export { apiFieldErrors } from "./apiFieldErrors";
2
2
  export { formatOrgNumber, mergeCompanyInfo } from "./company";
3
3
  export type { CompanyFallback, CompanyInfo } from "./company";
4
4
  export { createApiClient } from "./createApiClient";
5
+ export { formatApiError } from "./formatApiError";
6
+ export type { StatusMessages } from "./formatApiError";
5
7
  export { request } from "./request";
6
- export { requestRevalidate } from "./requestRevalidate";
7
8
  export { defineRevalidateTargets } from "./revalidateTargets";
8
9
  export type { RevalidateTargetPaths, RevalidateTargetsConfig } from "./revalidateTargets";
9
10
  export {
@@ -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 }>,
@@ -3,12 +3,13 @@ import { requestRevalidate } from "./requestRevalidate";
3
3
 
4
4
  /** The pages one admin edit changes. */
5
5
  export interface RevalidateTargetPaths {
6
- /** Paths below each locale segment, such as `/builds/[slug]`. `""` is the locale's front page. */
6
+ /** The paths below each locale segment, such as `/builds/[slug]`. `""` is the locale's front page. */
7
7
  pages?: readonly string[] | undefined;
8
- /** Paths outside the locale segments, such as `/sitemap.xml`. */
8
+ /** The paths outside the locale segments, such as `/sitemap.xml`. */
9
9
  root?: readonly string[] | undefined;
10
10
  }
11
11
 
12
+ /** Options for `defineRevalidateTargets`. */
12
13
  export interface RevalidateTargetsConfig<T extends string> {
13
14
  /** The locale segments every page is under. A site without them omits this. */
14
15
  locales?: readonly string[] | undefined;
@@ -16,9 +17,10 @@ export interface RevalidateTargetsConfig<T extends string> {
16
17
  }
17
18
 
18
19
  /**
19
- * The cache targets an app's admin edits purge, in one place for both sides: `targets` names them
20
- * for fetch tags, `paths` is what `createRevalidateRoute` purges for each, and `revalidate` asks the
21
- * route to purge one after an edit. A target with no paths is purged by its tag alone.
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.
22
24
  */
23
25
  export function defineRevalidateTargets<T extends string>({ locales = [], targets }: RevalidateTargetsConfig<T>) {
24
26
  const names = Object.keys(targets) as T[];
@@ -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";