@sjolystinnovation/app-kit 0.22.0 → 1.0.1

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 (52) hide show
  1. package/README.md +1280 -772
  2. package/package.json +21 -4
  3. package/src/admin/AdminUsersPanel.tsx +85 -35
  4. package/src/admin/CompanySettingsForm.tsx +33 -13
  5. package/src/admin/EmailStatsPanel.tsx +76 -0
  6. package/src/admin/LegalPagesEditor.tsx +41 -14
  7. package/src/admin/adminUsersPanelStrings.ts +67 -0
  8. package/src/admin/companySettingsFormStrings.ts +26 -0
  9. package/src/admin/emailStatsPanelStrings.ts +29 -0
  10. package/src/admin/index.ts +14 -5
  11. package/src/admin/legalPagesEditorStrings.ts +25 -0
  12. package/src/api/apiErrorCode.ts +28 -0
  13. package/src/api/apiFieldErrors.ts +82 -11
  14. package/src/api/failureBody.ts +10 -0
  15. package/src/api/fieldErrorText.ts +32 -0
  16. package/src/api/index.ts +5 -1
  17. package/src/api/legalPage.ts +10 -0
  18. package/src/api/request.ts +13 -3
  19. package/src/api/services.ts +42 -12
  20. package/src/auth/createAuthOptions.ts +9 -3
  21. package/src/branding/BrandingManager.tsx +31 -36
  22. package/src/branding/brandingManagerStrings.ts +49 -0
  23. package/src/branding/index.ts +3 -3
  24. package/src/legal/LegalPageView.tsx +2 -17
  25. package/src/legal/index.ts +4 -1
  26. package/src/legal/keys.ts +10 -0
  27. package/src/legal/legalPageViewStrings.ts +17 -0
  28. package/src/locales/nb.ts +330 -0
  29. package/src/sections/SectionRenderer.tsx +275 -0
  30. package/src/sections/SectionsEditor.tsx +151 -0
  31. package/src/sections/SmartLink.tsx +42 -0
  32. package/src/sections/editors.tsx +311 -0
  33. package/src/sections/factory.ts +26 -0
  34. package/src/sections/index.ts +69 -0
  35. package/src/sections/sectionsEditorStrings.ts +108 -0
  36. package/src/sections/types.ts +107 -0
  37. package/src/seo/index.ts +1 -0
  38. package/src/seo/site.ts +5 -5
  39. package/src/session/expiry.ts +3 -1
  40. package/src/session/index.ts +0 -1
  41. package/src/session/react/CredentialsForm.tsx +8 -2
  42. package/src/session/react/credentialsFormStrings.ts +4 -1
  43. package/src/session/react/index.ts +1 -1
  44. package/src/styles.css +2 -0
  45. package/src/testing/index.ts +1 -9
  46. package/src/ui/ErrorState.tsx +1 -15
  47. package/src/ui/NotFoundState.tsx +1 -13
  48. package/src/ui/ResetPasswordFlow.tsx +55 -12
  49. package/src/ui/errorStateStrings.ts +16 -0
  50. package/src/ui/index.ts +10 -6
  51. package/src/ui/notFoundStateStrings.ts +14 -0
  52. package/src/ui/resetPasswordFlowStrings.ts +53 -4
@@ -1,5 +1,47 @@
1
1
  // Plain values, with no "use client", so a server component can import them.
2
2
 
3
+ /**
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
+
3
45
  /**
4
46
  * The users panel's text. Pass the app's own language. The defaults are English. The panel fills in
5
47
  * `{name}` and `{role}` where a text names them.
@@ -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 {
@@ -15,6 +34,8 @@ export interface LegalPagesEditorStrings {
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
  };
@@ -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
+
3
+ /**
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
+ }
2
14
 
3
15
  /**
4
16
  * Gets the per-field messages of an ASP.NET validation problem, keyed by the field's name in
5
17
  * 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.
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
+ }
@@ -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
+ }
package/src/api/index.ts CHANGED
@@ -1,3 +1,4 @@
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";
@@ -14,5 +15,8 @@ export {
14
15
  createBrandingService,
15
16
  createLegalService,
16
17
  createAdminUsersService,
18
+ createEmailStatsService,
17
19
  } from "./services";
18
- 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 { apiErrorCode } from "./apiErrorCode";
3
+ import { apiFieldErrorDetails, apiFieldErrors } from "./apiFieldErrors";
2
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 canceled 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
  }
@@ -1,6 +1,8 @@
1
1
  import axios, { type AxiosInstance } from "axios";
2
2
  import { getSession } from "next-auth/react";
3
3
  import type { Branding } from "../branding/types";
4
+ import type { LegalPage } from "./legalPage";
5
+ import { LEGAL_PAGE_KEYS, type DefaultLegalPageKey } from "../legal/keys";
4
6
  import { request } from "./request";
5
7
 
6
8
  /**
@@ -124,26 +126,30 @@ export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstan
124
126
  };
125
127
  }
126
128
 
127
- /** A legal page, as SjolystInnovation.AppKit.SiteSettings's legal endpoints return it. */
128
- export interface LegalPage<Key extends string = string> {
129
- key: Key;
130
- locale: string;
131
- title: string;
132
- bodyMarkdown: string;
133
- updatedAt: string;
129
+ /** Options for `createLegalService`. */
130
+ export interface LegalServiceOptions<Key extends string> {
131
+ /**
132
+ * The app's legal pages, the same keys as the API's `MapAppKitLegalPages` `Keys` option. Defaults
133
+ * to `LEGAL_PAGE_KEYS`. The service's key type comes from them.
134
+ */
135
+ keys?: readonly Key[] | undefined;
136
+ onSaved?: OnSaved | undefined;
134
137
  }
135
138
 
136
139
  /**
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.
140
+ * Creates the calls for the legal pages on SjolystInnovation.AppKit.SiteSettings, such as terms,
141
+ * privacy, and cookies. Reading is anonymous and returns null for a page that is missing or cannot be
142
+ * read, so a page shows its fallback. `keys` on the result holds the app's pages, for the editor and
143
+ * the sitemap.
140
144
  */
141
- export function createLegalService<Key extends string = "terms" | "privacy" | "cookies">(
145
+ export function createLegalService<const Key extends string = DefaultLegalPageKey>(
142
146
  api: AxiosInstance,
143
147
  publicApi: AxiosInstance,
144
- options: { onSaved?: OnSaved } = {},
148
+ options: LegalServiceOptions<Key> = {},
145
149
  ) {
146
150
  return {
151
+ keys: options.keys ?? (LEGAL_PAGE_KEYS as readonly string[] as readonly Key[]),
152
+
147
153
  async get(key: Key, locale: string): Promise<LegalPage<Key> | null> {
148
154
  try {
149
155
  return (await publicApi.get<LegalPage<Key>>(`/content/legal/${encodeURIComponent(key)}`, { params: { locale } })).data;
@@ -165,6 +171,27 @@ export function createLegalService<Key extends string = "terms" | "privacy" | "c
165
171
  };
166
172
  }
167
173
 
174
+ /** The transactional email of the last days, as SjolystInnovation.AppKit.Email's stats endpoint returns it. */
175
+ export interface EmailStats {
176
+ days: number;
177
+ requests: number;
178
+ delivered: number;
179
+ hardBounces: number;
180
+ softBounces: number;
181
+ spamReports: number;
182
+ blocked: number;
183
+ invalid: number;
184
+ }
185
+
186
+ /** Creates the admin call for the email stats on SjolystInnovation.AppKit.Email's `MapAppKitEmailStats`. */
187
+ export function createEmailStatsService(api: AxiosInstance) {
188
+ return {
189
+ /** The stats for the last `days` days, 30 by default. */
190
+ get: (days = 30) =>
191
+ request(() => api.get<EmailStats>("/admin/email-stats", { params: { days } }), "Failed to load email stats"),
192
+ };
193
+ }
194
+
168
195
  /** A user in the admin list, as SjolystInnovation.AppKit.Auth's user admin endpoints return it. */
169
196
  export interface AdminUser {
170
197
  id: string;
@@ -183,6 +210,9 @@ export function createAdminUsersService(api: AxiosInstance) {
183
210
  getUsers: (includeDeleted = false) =>
184
211
  request(() => api.get<AdminUser[]>("/users", { params: { includeDeleted } }), "Failed to load users"),
185
212
 
213
+ /** The roles an admin may give, in the order the API configures them. */
214
+ roles: () => request(() => api.get<string[]>("/users/roles"), "Failed to load roles"),
215
+
186
216
  addRole: (userId: string, role: string) =>
187
217
  request(() => api.post(`/users/${encodeURIComponent(userId)}/roles`, { role }), "Failed to assign role"),
188
218
 
@@ -3,6 +3,7 @@ import jwt from "jsonwebtoken";
3
3
  import type { NextAuthOptions, Session, User } from "next-auth";
4
4
  import CredentialsProvider from "next-auth/providers/credentials";
5
5
  import type { JWT } from "next-auth/jwt";
6
+ import { apiErrorCode } from "../api/apiErrorCode";
6
7
  import { resolveJwtSecret, type SessionConfig } from "../config";
7
8
  import { ABSOLUTE_SESSION_MAX_AGE, CLOCK_TOLERANCE_S, SESSION_ERRORS, SIGN_IN_ERRORS, isTerminalSessionError } from "../session/expiry";
8
9
 
@@ -102,6 +103,13 @@ function apiUnavailable(error: unknown): boolean {
102
103
  return status === undefined || status === 502 || status === 503 || status === 504;
103
104
  }
104
105
 
106
+ // The sign-in error for a failed login. Only the API's lockout code is told apart from wrong
107
+ // credentials, because the API gives it whether the password was right or not.
108
+ function signInError(error: unknown): string {
109
+ if (apiUnavailable(error)) return SIGN_IN_ERRORS.unavailable;
110
+ return apiErrorCode(error) === "lockedOut" ? SIGN_IN_ERRORS.lockedOut : SIGN_IN_ERRORS.invalidCredentials;
111
+ }
112
+
105
113
  // What a log line may contain. The error itself carries the request, which holds the password or
106
114
  // tokens.
107
115
  function failureShape(error: unknown) {
@@ -242,9 +250,7 @@ export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSe
242
250
  console.error("Sign-in failed", failureShape(error));
243
251
  // No cause: next-auth logs this error, and the cause carries the password.
244
252
  // eslint-disable-next-line preserve-caught-error
245
- throw new Error(
246
- apiUnavailable(error) ? SIGN_IN_ERRORS.unavailable : SIGN_IN_ERRORS.invalidCredentials,
247
- );
253
+ throw new Error(signInError(error));
248
254
  }
249
255
  },
250
256
  }),