@sjolystinnovation/app-kit 0.21.0 → 1.0.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 (99) hide show
  1. package/README.md +1287 -860
  2. package/package.json +21 -4
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +93 -37
  5. package/src/admin/CompanySettingsForm.tsx +35 -14
  6. package/src/admin/EmailStatsPanel.tsx +76 -0
  7. package/src/admin/LegalPagesEditor.tsx +45 -17
  8. package/src/admin/LocaleTabs.tsx +4 -3
  9. package/src/admin/StatHistoryChart.tsx +4 -3
  10. package/src/admin/adminUsersPanelStrings.ts +70 -3
  11. package/src/admin/companySettingsFormStrings.ts +26 -0
  12. package/src/admin/emailStatsPanelStrings.ts +29 -0
  13. package/src/admin/index.ts +14 -5
  14. package/src/admin/legalPagesEditorStrings.ts +26 -1
  15. package/src/admin/statHistoryChartStrings.ts +3 -2
  16. package/src/api/apiErrorCode.ts +28 -0
  17. package/src/api/apiFieldErrors.ts +84 -13
  18. package/src/api/company.ts +4 -3
  19. package/src/api/createApiClient.ts +3 -2
  20. package/src/api/failureBody.ts +10 -0
  21. package/src/api/fieldErrorText.ts +32 -0
  22. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  23. package/src/api/index.ts +7 -2
  24. package/src/api/legalPage.ts +10 -0
  25. package/src/api/request.ts +14 -4
  26. package/src/api/revalidateTargets.ts +7 -5
  27. package/src/api/services.ts +53 -22
  28. package/src/auth/createAuthOptions.ts +16 -8
  29. package/src/branding/BrandingManager.tsx +37 -37
  30. package/src/branding/BrandingProvider.tsx +5 -3
  31. package/src/branding/brandingManagerStrings.ts +49 -0
  32. package/src/branding/index.ts +4 -5
  33. package/src/branding/server.ts +10 -9
  34. package/src/branding/types.ts +3 -3
  35. package/src/config.ts +10 -10
  36. package/src/forms/useFormDraft.ts +9 -7
  37. package/src/index.ts +1 -3
  38. package/src/legal/LegalPageView.tsx +5 -19
  39. package/src/legal/index.ts +4 -1
  40. package/src/legal/keys.ts +10 -0
  41. package/src/legal/legalPageViewStrings.ts +17 -0
  42. package/src/locales/nb.ts +330 -0
  43. package/src/markdown/Markdown.tsx +12 -10
  44. package/src/markdown/index.ts +1 -1
  45. package/src/next-auth.d.ts +11 -9
  46. package/src/next-config/headers.ts +96 -0
  47. package/src/next-config/index.ts +18 -107
  48. package/src/payments/PaymentReturn.tsx +9 -7
  49. package/src/payments/formatNok.ts +1 -1
  50. package/src/payments/usePollUntilFinal.ts +7 -5
  51. package/src/sections/SectionRenderer.tsx +275 -0
  52. package/src/sections/SectionsEditor.tsx +151 -0
  53. package/src/sections/SmartLink.tsx +42 -0
  54. package/src/sections/editors.tsx +311 -0
  55. package/src/sections/factory.ts +26 -0
  56. package/src/sections/index.ts +69 -0
  57. package/src/sections/sectionsEditorStrings.ts +108 -0
  58. package/src/sections/types.ts +107 -0
  59. package/src/seo/JsonLd.tsx +1 -1
  60. package/src/seo/environment.ts +3 -3
  61. package/src/seo/index.ts +1 -1
  62. package/src/seo/og/index.tsx +5 -4
  63. package/src/seo/schema.ts +6 -2
  64. package/src/seo/site.ts +49 -37
  65. package/src/seo/sitemap.ts +2 -2
  66. package/src/server/publicApi.ts +13 -9
  67. package/src/server/revalidateRoute.ts +10 -8
  68. package/src/session/expiry.ts +11 -6
  69. package/src/session/index.ts +0 -1
  70. package/src/session/react/AppSessionProvider.tsx +3 -2
  71. package/src/{ui → session/react}/CredentialsForm.tsx +17 -10
  72. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  73. package/src/session/react/ProtectedGate.tsx +3 -2
  74. package/src/session/react/RoleGate.tsx +4 -3
  75. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  76. package/src/{ui → session/react}/credentialsFormStrings.ts +4 -1
  77. package/src/session/react/index.ts +8 -2
  78. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  79. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  80. package/src/session/react/useSessionGate.ts +3 -3
  81. package/src/styles.css +2 -0
  82. package/src/testing/index.ts +5 -13
  83. package/src/ui/Alert.tsx +6 -4
  84. package/src/ui/ContentImage.tsx +12 -11
  85. package/src/ui/ErrorState.tsx +5 -17
  86. package/src/ui/NotFoundState.tsx +7 -17
  87. package/src/ui/PasswordInput.tsx +6 -5
  88. package/src/ui/ResetPasswordFlow.tsx +64 -19
  89. package/src/ui/TextArea.tsx +6 -4
  90. package/src/ui/TextInput.tsx +4 -2
  91. package/src/ui/Toggle.tsx +3 -3
  92. package/src/ui/errorStateStrings.ts +16 -0
  93. package/src/ui/field.tsx +5 -3
  94. package/src/ui/index.ts +10 -14
  95. package/src/ui/notFoundStateStrings.ts +14 -0
  96. package/src/ui/resetPasswordFlowStrings.ts +51 -7
  97. package/src/validation/fieldErrors.ts +4 -3
  98. package/src/validation/password.ts +5 -5
  99. package/src/validation/passwordRules.ts +2 -2
@@ -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,50 @@
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 panel's text for each field error code of SjolystInnovation.AppKit.Auth's invite endpoint,
5
+ * shown under the field. `{max}` in `tooLong` becomes the field's character limit. The panel shows
6
+ * the API's own message for a code that is not here.
7
+ */
8
+ export interface AdminUsersValidationStrings {
9
+ required: string;
10
+ tooLong: string;
11
+ invalidEmail: string;
12
+ /** The chosen role is not one the API lets an admin give. */
13
+ unknownRole: string;
14
+ /** Another account took the email while the invite was sent. */
15
+ emailTaken: string;
16
+ }
17
+
18
+ /**
19
+ * The panel's text for each code of SjolystInnovation.AppKit.Auth's role and invite endpoints that
20
+ * is not about one field. The panel shows the API's own message for a code that is not here.
21
+ */
22
+ export interface AdminUsersErrorStrings {
23
+ unknownRole: string;
24
+ /** An admin tried to take away their own admin role. */
25
+ ownAdminRole: string;
26
+ /** Taking the admin role from the last active admin. */
27
+ lastAdmin: string;
28
+ roleAssignFailed: string;
29
+ roleRemoveFailed: string;
30
+ /** The invited email already has an account. */
31
+ emailTaken: string;
32
+ }
33
+
34
+ /**
35
+ * The panel's text for each code SjolystInnovation.AppKit.Auth gives when deleting an account. The
36
+ * panel shows the API's own message for a code that is not here.
37
+ */
38
+ export interface AdminUsersDeleteErrorStrings {
39
+ /** Deleting the last active admin. */
40
+ lastAdmin: string;
41
+ /** Another account change happened at the same time. Trying again may work. */
42
+ accountChangeConflict: string;
43
+ }
44
+
45
+ /**
46
+ * The users panel's text. Pass the app's own language. The defaults are English. The panel fills in
47
+ * `{name}` and `{role}` where a text names them.
6
48
  */
7
49
  export interface AdminUsersPanelStrings {
8
50
  users: string;
@@ -19,7 +61,7 @@ export interface AdminUsersPanelStrings {
19
61
  /** The tag after a deleted user's name. */
20
62
  deleted: string;
21
63
  delete: string;
22
- /** Before the role name on the button that takes a role away. */
64
+ /** The text before the role name on the button that takes a role away. */
23
65
  remove: string;
24
66
  noRoles: string;
25
67
  addRole: string;
@@ -37,6 +79,12 @@ export interface AdminUsersPanelStrings {
37
79
  inviteIncomplete: string;
38
80
  inviteSent: string;
39
81
  inviteFailed: string;
82
+ /** The invite field error texts, by code. Any code left out keeps its default. */
83
+ validation: AdminUsersValidationStrings;
84
+ /** The role and invite refusal texts, by code. Any code left out keeps its default. */
85
+ errors: AdminUsersErrorStrings;
86
+ /** The delete refusal texts, by code. Any code left out keeps its default. */
87
+ deleteErrors: AdminUsersDeleteErrorStrings;
40
88
  }
41
89
 
42
90
  export const defaultAdminUsersPanelStrings: AdminUsersPanelStrings = {
@@ -70,4 +118,23 @@ export const defaultAdminUsersPanelStrings: AdminUsersPanelStrings = {
70
118
  inviteIncomplete: "Fill in the name and email",
71
119
  inviteSent: "Invitation sent. The user gets a code to set a password.",
72
120
  inviteFailed: "Failed to send the invitation",
121
+ validation: {
122
+ required: "Required.",
123
+ tooLong: "At most {max} characters.",
124
+ invalidEmail: "Not a valid email address.",
125
+ unknownRole: "Unknown role.",
126
+ emailTaken: "That email already has an account.",
127
+ },
128
+ errors: {
129
+ unknownRole: "Unknown role.",
130
+ ownAdminRole: "You cannot remove your own admin role.",
131
+ lastAdmin: "The last admin keeps the admin role. Give someone else the role first.",
132
+ roleAssignFailed: "Failed to assign role.",
133
+ roleRemoveFailed: "Failed to remove role.",
134
+ emailTaken: "That email already has an account.",
135
+ },
136
+ deleteErrors: {
137
+ lastAdmin: "The last admin account cannot be deleted. Give someone else the admin role first.",
138
+ accountChangeConflict: "Another change to the accounts happened at the same time. Try again.",
139
+ },
73
140
  };
@@ -1,5 +1,20 @@
1
1
  // Plain values, with no "use client", so a server component can import them.
2
2
 
3
+ /**
4
+ * The form's text for each field error code of SjolystInnovation.AppKit.SiteSettings. `{max}` in
5
+ * `tooLong` becomes the field's character limit. The form shows the API's own message for a code
6
+ * that is not here.
7
+ */
8
+ export interface CompanySettingsValidationStrings {
9
+ required: string;
10
+ tooLong: string;
11
+ invalidEmail: string;
12
+ orgNumberRequiredForVat: string;
13
+ invalidOrgNumber: string;
14
+ invalidPostalCode: string;
15
+ placeRequired: string;
16
+ }
17
+
3
18
  /** The company settings form's text. Pass the app's own language. The defaults are English. */
4
19
  export interface CompanySettingsFormStrings {
5
20
  contactRecipient: string;
@@ -23,6 +38,8 @@ export interface CompanySettingsFormStrings {
23
38
  loadFailed: string;
24
39
  saved: string;
25
40
  saveFailed: string;
41
+ /** The field error texts, by code. Any code left out keeps its default. */
42
+ validation: CompanySettingsValidationStrings;
26
43
  }
27
44
 
28
45
  export const defaultCompanySettingsFormStrings: CompanySettingsFormStrings = {
@@ -47,4 +64,13 @@ export const defaultCompanySettingsFormStrings: CompanySettingsFormStrings = {
47
64
  loadFailed: "Failed to load settings",
48
65
  saved: "Settings saved",
49
66
  saveFailed: "Failed to save",
67
+ validation: {
68
+ required: "Required.",
69
+ tooLong: "At most {max} characters.",
70
+ invalidEmail: "Not a valid email address.",
71
+ orgNumberRequiredForVat: "An organisation number is required when the business is VAT registered.",
72
+ invalidOrgNumber: "Not a valid organisation number. It has nine digits and ends in a check digit.",
73
+ invalidPostalCode: "A Norwegian postal code is four digits.",
74
+ placeRequired: "A postal code needs the place name that goes with it.",
75
+ },
50
76
  };
@@ -0,0 +1,29 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /**
4
+ * The email stats panel's text. Pass the app's own language. The defaults are English. The panel
5
+ * fills in `{days}` in `title`.
6
+ */
7
+ export interface EmailStatsPanelStrings {
8
+ title: string;
9
+ /** The emails the app asked Brevo to send. */
10
+ sent: string;
11
+ delivered: string;
12
+ /** Hard and soft bounces together. */
13
+ bounces: string;
14
+ /** Blocked emails and spam reports together. */
15
+ blocked: string;
16
+ loading: string;
17
+ /** The text shown when the stats could not load, such as when Brevo does not answer. */
18
+ unavailable: string;
19
+ }
20
+
21
+ export const defaultEmailStatsPanelStrings: EmailStatsPanelStrings = {
22
+ title: "Email (last {days} days)",
23
+ sent: "Sent",
24
+ delivered: "Delivered",
25
+ bounces: "Bounces",
26
+ blocked: "Blocked / spam",
27
+ loading: "Loading…",
28
+ unavailable: "Unavailable.",
29
+ };
@@ -1,20 +1,29 @@
1
1
  export { CompanySettingsForm } from "./CompanySettingsForm";
2
2
  export type { CompanySettingsFormProps } from "./CompanySettingsForm";
3
3
  export { defaultCompanySettingsFormStrings } from "./companySettingsFormStrings";
4
- export type { CompanySettingsFormStrings } from "./companySettingsFormStrings";
4
+ export type { CompanySettingsFormStrings, CompanySettingsValidationStrings } from "./companySettingsFormStrings";
5
5
  export { AdminShell } from "./AdminShell";
6
6
  export type { AdminShellProps, AdminTab } from "./AdminShell";
7
7
  export { StatHistoryChart } from "./StatHistoryChart";
8
- export { STAT_SERIES_COLORS, defaultStatHistoryChartStrings } from "./statHistoryChartStrings";
8
+ export { defaultStatHistoryChartStrings } from "./statHistoryChartStrings";
9
9
  export type { StatHistoryChartProps, StatSeries } from "./StatHistoryChart";
10
10
  export type { StatHistoryChartStrings } from "./statHistoryChartStrings";
11
+ export { EmailStatsPanel } from "./EmailStatsPanel";
12
+ export type { EmailStatsPanelProps } from "./EmailStatsPanel";
13
+ export { defaultEmailStatsPanelStrings } from "./emailStatsPanelStrings";
14
+ export type { EmailStatsPanelStrings } from "./emailStatsPanelStrings";
11
15
  export { AdminUsersPanel } from "./AdminUsersPanel";
12
16
  export type { AdminUsersPanelProps } from "./AdminUsersPanel";
13
17
  export { defaultAdminUsersPanelStrings } from "./adminUsersPanelStrings";
14
- export type { AdminUsersPanelStrings } from "./adminUsersPanelStrings";
18
+ export type {
19
+ AdminUsersDeleteErrorStrings,
20
+ AdminUsersErrorStrings,
21
+ AdminUsersPanelStrings,
22
+ AdminUsersValidationStrings,
23
+ } from "./adminUsersPanelStrings";
15
24
  export { LegalPagesEditor } from "./LegalPagesEditor";
16
25
  export type { LegalPagesEditorProps } from "./LegalPagesEditor";
17
- export { defaultLegalPagesEditorStrings } from "./legalPagesEditorStrings";
18
- export type { LegalPagesEditorStrings } from "./legalPagesEditorStrings";
26
+ export { defaultLegalPageKeyLabels, defaultLegalPagesEditorStrings } from "./legalPagesEditorStrings";
27
+ export type { LegalPagesEditorStrings, LegalPagesValidationStrings } from "./legalPagesEditorStrings";
19
28
  export { LocaleTabs } from "./LocaleTabs";
20
29
  export type { LocaleTabsProps } from "./LocaleTabs";
@@ -1,4 +1,23 @@
1
1
  // Plain values, with no "use client", so a server component can import them.
2
+ import type { DefaultLegalPageKey } from "../legal/keys";
3
+
4
+ /**
5
+ * The editor's text for each field error code of SjolystInnovation.AppKit.SiteSettings that saving a
6
+ * legal page can give. `{max}` in `tooLong` becomes the title's character limit. The editor shows the
7
+ * API's own message for a code that is not here.
8
+ */
9
+ export interface LegalPagesValidationStrings {
10
+ /** A blank title or content. The editor also shows it under a blank field before it calls the API. */
11
+ required: string;
12
+ tooLong: string;
13
+ }
14
+
15
+ /** The tab labels of the default legal pages, in English. */
16
+ export const defaultLegalPageKeyLabels: Record<DefaultLegalPageKey, string> = {
17
+ terms: "Terms",
18
+ privacy: "Privacy",
19
+ cookies: "Cookies",
20
+ };
2
21
 
3
22
  /** The legal pages editor's text. Pass the app's own language. The defaults are English. */
4
23
  export interface LegalPagesEditorStrings {
@@ -11,10 +30,12 @@ export interface LegalPagesEditorStrings {
11
30
  save: string;
12
31
  saving: string;
13
32
  loadFailed: string;
14
- /** When the title or the content is blank. */
33
+ /** The message shown when the title or the content is blank. */
15
34
  required: string;
16
35
  saved: string;
17
36
  saveFailed: string;
37
+ /** The field error texts, by code. Any code left out keeps its default. */
38
+ validation: LegalPagesValidationStrings;
18
39
  }
19
40
 
20
41
  export const defaultLegalPagesEditorStrings: LegalPagesEditorStrings = {
@@ -29,4 +50,8 @@ export const defaultLegalPagesEditorStrings: LegalPagesEditorStrings = {
29
50
  required: "Fill in the title and the content",
30
51
  saved: "Page saved",
31
52
  saveFailed: "Failed to save the page",
53
+ validation: {
54
+ required: "Required.",
55
+ tooLong: "At most {max} characters.",
56
+ },
32
57
  };
@@ -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;
@@ -0,0 +1,28 @@
1
+ import { failureBody } from "./failureBody";
2
+
3
+ /**
4
+ * Gets the stable code of a failure that is not about one field, such as `lockedOut` or
5
+ * `invalidResetCode` from `{ "message": "...", "code": "invalidResetCode" }`, for showing the app's
6
+ * own text. Takes the failed axios call, the response body, or the `Error` that `request` throws.
7
+ * Returns undefined when the failure has no code, so the caller shows the API's message.
8
+ */
9
+ export function apiErrorCode(error: unknown): string | undefined {
10
+ const data = failureBody(error);
11
+ // An axios error is read through its body above. Its own `code`, such as ERR_NETWORK, is not the API's.
12
+ const code = data instanceof Error ? (data as { errorCode?: unknown }).errorCode : bodyCode(data);
13
+ return typeof code === "string" && code !== "" ? code : undefined;
14
+ }
15
+
16
+ /** Reads `code` from a JSON body, also when axios left the body as text. */
17
+ function bodyCode(data: unknown): unknown {
18
+ let body = data;
19
+ if (typeof data === "string") {
20
+ try {
21
+ body = JSON.parse(data);
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ // A text, number or list has no `code` of its own, so only a JSON object gives one.
27
+ return (body as { code?: unknown } | null | undefined)?.code;
28
+ }
@@ -1,23 +1,94 @@
1
- import axios from "axios";
1
+ import { failureBody } from "./failureBody";
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
+ * One field error of an ASP.NET validation problem. `message` is the API's text. `code` is the stable
5
+ * code from the problem's `codes` extension, such as `invalidOrgNumber`, for showing the app's own
6
+ * text. `limit` is the field's number from the `limits` extension, such as the most characters for
7
+ * the code `tooLong`.
8
+ */
9
+ export interface ApiFieldError {
10
+ message: string;
11
+ code?: string;
12
+ limit?: number;
13
+ }
14
+
15
+ /**
16
+ * Gets the per-field messages of an ASP.NET validation problem, keyed by the field's name in
17
+ * camelCase so they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
18
+ * `{ orgNumber: ["..."] }`. Takes the failed axios call, the response body, or the `Error` that
19
+ * `request` throws. Returns null when there are no field errors, so the caller falls back to one
20
+ * message for the whole form.
8
21
  */
9
22
  export function apiFieldErrors(error: unknown): Record<string, string[]> | null {
10
- const data = axios.isAxiosError(error) ? error.response?.data : error;
11
- const errors = data && typeof data === "object" ? (data as { errors?: unknown }).errors : undefined;
12
- if (!errors || typeof errors !== "object" || Array.isArray(errors)) return null;
23
+ const details = apiFieldErrorDetails(error);
24
+ if (!details) return null;
25
+ return Object.fromEntries(Object.entries(details).map(([field, list]) => [field, list.map(d => d.message)]));
26
+ }
13
27
 
28
+ /**
29
+ * Gets the same field errors as `apiFieldErrors`, each with its code and limit when the problem has
30
+ * the `codes` and `limits` extensions: `{ errors: { OrgNumber: ["..."] }, codes: { OrgNumber:
31
+ * ["invalidOrgNumber"] } }` becomes `{ orgNumber: [{ message: "...", code: "invalidOrgNumber" }] }`.
32
+ * A code lines up with the message at the same place in its field's list.
33
+ */
34
+ export function apiFieldErrorDetails(error: unknown): Record<string, ApiFieldError[]> | null {
14
35
  // A Map, so a field named like an Object property such as "constructor" is still just a field.
15
- const fields = new Map<string, string[]>();
16
- for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
17
- const messages = (Array.isArray(value) ? value : [value]).filter((m): m is string => typeof m === "string" && m !== "");
18
- if (messages.length === 0) continue;
36
+ const fields = new Map<string, ApiFieldError[]>();
37
+ for (const [key, list] of problemEntries(error)) {
38
+ if (list.length === 0) continue;
19
39
  const field = key.charAt(0).toLowerCase() + key.slice(1);
20
- fields.set(field, [...(fields.get(field) ?? []), ...messages]);
40
+ fields.set(field, [...(fields.get(field) ?? []), ...list]);
21
41
  }
22
42
  return fields.size > 0 ? Object.fromEntries(fields) : null;
23
43
  }
44
+
45
+ type Entries = [string, ApiFieldError[]][];
46
+
47
+ /**
48
+ * Reads the field errors as they come, from a problem body or from the `Error` that `request` throws.
49
+ * Gives none for anything else.
50
+ */
51
+ function problemEntries(error: unknown): Entries {
52
+ const data = failureBody(error);
53
+ if (data instanceof Error) {
54
+ const kept = data as { fieldErrorDetails?: unknown; fieldErrors?: unknown };
55
+ if (isRecord(kept.fieldErrorDetails)) return keptEntries(kept.fieldErrorDetails);
56
+ return isRecord(kept.fieldErrors) ? bodyEntries(kept.fieldErrors, {}, {}) : [];
57
+ }
58
+ if (!isRecord(data) || !isRecord(data.errors)) return [];
59
+ return bodyEntries(data.errors, isRecord(data.codes) ? data.codes : {}, isRecord(data.limits) ? data.limits : {});
60
+ }
61
+
62
+ function bodyEntries(errors: Record<string, unknown>, codes: Record<string, unknown>, limits: Record<string, unknown>): Entries {
63
+ return Object.entries(errors).map(([key, value]) => {
64
+ // A key named like an Object property reads something that is not text or a number, which toError drops.
65
+ const fieldCodes = codes[key];
66
+ const codeList = Array.isArray(fieldCodes) ? fieldCodes : [fieldCodes];
67
+ const limit = limits[key];
68
+ const messages = Array.isArray(value) ? value : [value];
69
+ return [key, messages.flatMap((message, i) => toError({ message, code: codeList[i], limit }))];
70
+ });
71
+ }
72
+
73
+ function keptEntries(details: Record<string, unknown>): Entries {
74
+ return Object.entries(details).map(([key, value]) => [
75
+ key,
76
+ (Array.isArray(value) ? value : []).flatMap(item => (isRecord(item) ? toError(item) : [])),
77
+ ]);
78
+ }
79
+
80
+ /** Keeps a non-empty text message, with its code and limit only when they have the right types. */
81
+ function toError({ message, code, limit }: Record<string, unknown>): ApiFieldError[] {
82
+ if (typeof message !== "string" || message === "") return [];
83
+ return [
84
+ {
85
+ message,
86
+ ...(typeof code === "string" && code !== "" ? { code } : {}),
87
+ ...(typeof limit === "number" && Number.isFinite(limit) ? { limit } : {}),
88
+ },
89
+ ];
90
+ }
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return !!value && typeof value === "object" && !Array.isArray(value);
94
+ }
@@ -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) {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The response body of a failed axios call, or the value itself when it is not one. It checks the
3
+ * axios flag like `axios.isAxiosError` does, without importing axios, so the `ui` entry can read a
4
+ * failure's codes while axios stays an optional peer.
5
+ */
6
+ export function failureBody(error: unknown): unknown {
7
+ if (!error || typeof error !== "object") return error;
8
+ const failed = error as { isAxiosError?: unknown; response?: { data?: unknown } };
9
+ return failed.isAxiosError === true ? failed.response?.data : error;
10
+ }
@@ -0,0 +1,32 @@
1
+ import { apiErrorCode } from "./apiErrorCode";
2
+ import { apiFieldErrorDetails, type ApiFieldError } from "./apiFieldErrors";
3
+
4
+ /**
5
+ * The app's text for a field error when `validation` has text for the error's code, with `{max}`
6
+ * filled in from the error's limit. Otherwise the API's message.
7
+ */
8
+ export function fieldErrorText(error: ApiFieldError, validation: object): string {
9
+ const code = error.code ?? "";
10
+ const template = Object.hasOwn(validation, code) ? (validation as Record<string, string>)[code] : undefined;
11
+ if (template === undefined) return error.message;
12
+ if (!template.includes("{max}")) return template;
13
+ return error.limit === undefined ? error.message : template.replaceAll("{max}", String(error.limit));
14
+ }
15
+
16
+ /**
17
+ * The text for each field error of a failed call, keyed by field like `apiFieldErrors`, from
18
+ * `fieldErrorText`. Empty when the call has no field errors.
19
+ */
20
+ export function fieldErrorTexts(error: unknown, validation: object): Record<string, string[]> {
21
+ const details = apiFieldErrorDetails(error) ?? {};
22
+ return Object.fromEntries(Object.entries(details).map(([field, list]) => [field, list.map(e => fieldErrorText(e, validation))]));
23
+ }
24
+
25
+ /**
26
+ * The app's text for a failed call's code that is not about one field, such as `lockedOut`, when
27
+ * `errors` has text for it. Otherwise the failure's own message, or `fallback` when it has none.
28
+ */
29
+ export function errorText(error: unknown, errors: object, fallback: string): string {
30
+ const message = error instanceof Error && error.message ? error.message : fallback;
31
+ return fieldErrorText({ message, code: apiErrorCode(error) ?? "" }, errors);
32
+ }
@@ -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,9 +1,11 @@
1
+ export { apiErrorCode } from "./apiErrorCode";
1
2
  export { apiFieldErrors } from "./apiFieldErrors";
2
3
  export { formatOrgNumber, mergeCompanyInfo } from "./company";
3
4
  export type { CompanyFallback, CompanyInfo } from "./company";
4
5
  export { createApiClient } from "./createApiClient";
6
+ export { formatApiError } from "./formatApiError";
7
+ export type { StatusMessages } from "./formatApiError";
5
8
  export { request } from "./request";
6
- export { requestRevalidate } from "./requestRevalidate";
7
9
  export { defineRevalidateTargets } from "./revalidateTargets";
8
10
  export type { RevalidateTargetPaths, RevalidateTargetsConfig } from "./revalidateTargets";
9
11
  export {
@@ -13,5 +15,8 @@ export {
13
15
  createBrandingService,
14
16
  createLegalService,
15
17
  createAdminUsersService,
18
+ createEmailStatsService,
16
19
  } from "./services";
17
- export type { OnSaved, UserProfile, SiteSettings, LegalPage, AdminUser } from "./services";
20
+ export type { LegalServiceOptions } from "./services";
21
+ export type { OnSaved, UserProfile, SiteSettings, AdminUser, EmailStats } from "./services";
22
+ export type { LegalPage } from "./legalPage";
@@ -0,0 +1,10 @@
1
+ // Types only, with no imports, so the legal page view can use them without axios or next-auth.
2
+
3
+ /** A legal page, as SjolystInnovation.AppKit.SiteSettings's legal endpoints return it. */
4
+ export interface LegalPage<Key extends string = string> {
5
+ key: Key;
6
+ locale: string;
7
+ title: string;
8
+ bodyMarkdown: string;
9
+ updatedAt: string;
10
+ }
@@ -1,9 +1,14 @@
1
1
  import axios from "axios";
2
- import { formatApiError, type StatusMessages } from "../errors";
2
+ import { apiErrorCode } from "./apiErrorCode";
3
+ import { apiFieldErrorDetails, apiFieldErrors } from "./apiFieldErrors";
4
+ import { formatApiError, type StatusMessages } from "./formatApiError";
3
5
 
4
6
  /**
5
7
  * 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.
8
+ * from `formatApiError`. When the API answers with a validation problem, the error also carries its
9
+ * per-field messages with their codes, which `apiFieldErrors` and the forms' field errors read. When
10
+ * the API answers with a code for the whole call, the error carries it for `apiErrorCode`. A
11
+ * canceled call rethrows the cancel as it is, so the caller can ignore it.
7
12
  */
8
13
  export async function request<T>(
9
14
  call: () => Promise<{ data: T }>,
@@ -16,7 +21,12 @@ export async function request<T>(
16
21
  } catch (error: unknown) {
17
22
  if (axios.isCancel(error)) throw error;
18
23
  // No cause. Errors get logged, and the axios error carries the access token and request body.
19
- // eslint-disable-next-line preserve-caught-error
20
- throw new Error(formatApiError(error, fallback, statusMessages));
24
+ const failure = new Error(formatApiError(error, fallback, statusMessages));
25
+ // Only the field messages with their codes, never the response, for the same reason.
26
+ const fieldErrors = apiFieldErrors(error);
27
+ if (fieldErrors) Object.assign(failure, { fieldErrors, fieldErrorDetails: apiFieldErrorDetails(error) });
28
+ const errorCode = apiErrorCode(error);
29
+ if (errorCode) Object.assign(failure, { errorCode });
30
+ throw failure;
21
31
  }
22
32
  }