@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.
- package/README.md +1287 -860
- package/package.json +21 -4
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +93 -37
- package/src/admin/CompanySettingsForm.tsx +35 -14
- package/src/admin/EmailStatsPanel.tsx +76 -0
- package/src/admin/LegalPagesEditor.tsx +45 -17
- package/src/admin/LocaleTabs.tsx +4 -3
- package/src/admin/StatHistoryChart.tsx +4 -3
- package/src/admin/adminUsersPanelStrings.ts +70 -3
- package/src/admin/companySettingsFormStrings.ts +26 -0
- package/src/admin/emailStatsPanelStrings.ts +29 -0
- package/src/admin/index.ts +14 -5
- package/src/admin/legalPagesEditorStrings.ts +26 -1
- package/src/admin/statHistoryChartStrings.ts +3 -2
- package/src/api/apiErrorCode.ts +28 -0
- package/src/api/apiFieldErrors.ts +84 -13
- package/src/api/company.ts +4 -3
- package/src/api/createApiClient.ts +3 -2
- package/src/api/failureBody.ts +10 -0
- package/src/api/fieldErrorText.ts +32 -0
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +7 -2
- package/src/api/legalPage.ts +10 -0
- package/src/api/request.ts +14 -4
- package/src/api/revalidateTargets.ts +7 -5
- package/src/api/services.ts +53 -22
- package/src/auth/createAuthOptions.ts +16 -8
- package/src/branding/BrandingManager.tsx +37 -37
- package/src/branding/BrandingProvider.tsx +5 -3
- package/src/branding/brandingManagerStrings.ts +49 -0
- package/src/branding/index.ts +4 -5
- package/src/branding/server.ts +10 -9
- package/src/branding/types.ts +3 -3
- package/src/config.ts +10 -10
- package/src/forms/useFormDraft.ts +9 -7
- package/src/index.ts +1 -3
- package/src/legal/LegalPageView.tsx +5 -19
- package/src/legal/index.ts +4 -1
- package/src/legal/keys.ts +10 -0
- package/src/legal/legalPageViewStrings.ts +17 -0
- package/src/locales/nb.ts +330 -0
- package/src/markdown/Markdown.tsx +12 -10
- package/src/markdown/index.ts +1 -1
- package/src/next-auth.d.ts +11 -9
- package/src/next-config/headers.ts +96 -0
- package/src/next-config/index.ts +18 -107
- package/src/payments/PaymentReturn.tsx +9 -7
- package/src/payments/formatNok.ts +1 -1
- package/src/payments/usePollUntilFinal.ts +7 -5
- package/src/sections/SectionRenderer.tsx +275 -0
- package/src/sections/SectionsEditor.tsx +151 -0
- package/src/sections/SmartLink.tsx +42 -0
- package/src/sections/editors.tsx +311 -0
- package/src/sections/factory.ts +26 -0
- package/src/sections/index.ts +69 -0
- package/src/sections/sectionsEditorStrings.ts +108 -0
- package/src/sections/types.ts +107 -0
- package/src/seo/JsonLd.tsx +1 -1
- package/src/seo/environment.ts +3 -3
- package/src/seo/index.ts +1 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +49 -37
- package/src/seo/sitemap.ts +2 -2
- package/src/server/publicApi.ts +13 -9
- package/src/server/revalidateRoute.ts +10 -8
- package/src/session/expiry.ts +11 -6
- package/src/session/index.ts +0 -1
- package/src/session/react/AppSessionProvider.tsx +3 -2
- package/src/{ui → session/react}/CredentialsForm.tsx +17 -10
- package/src/{ui → session/react}/LoginPage.tsx +7 -6
- package/src/session/react/ProtectedGate.tsx +3 -2
- package/src/session/react/RoleGate.tsx +4 -3
- package/src/session/react/SessionExpiryGuard.tsx +5 -4
- package/src/{ui → session/react}/credentialsFormStrings.ts +4 -1
- package/src/session/react/index.ts +8 -2
- package/src/{ui → session/react}/loginPageStrings.ts +1 -1
- package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
- package/src/session/react/useSessionGate.ts +3 -3
- package/src/styles.css +2 -0
- package/src/testing/index.ts +5 -13
- package/src/ui/Alert.tsx +6 -4
- package/src/ui/ContentImage.tsx +12 -11
- package/src/ui/ErrorState.tsx +5 -17
- package/src/ui/NotFoundState.tsx +7 -17
- package/src/ui/PasswordInput.tsx +6 -5
- package/src/ui/ResetPasswordFlow.tsx +64 -19
- package/src/ui/TextArea.tsx +6 -4
- package/src/ui/TextInput.tsx +4 -2
- package/src/ui/Toggle.tsx +3 -3
- package/src/ui/errorStateStrings.ts +16 -0
- package/src/ui/field.tsx +5 -3
- package/src/ui/index.ts +10 -14
- package/src/ui/notFoundStateStrings.ts +14 -0
- package/src/ui/resetPasswordFlowStrings.ts +51 -7
- package/src/validation/fieldErrors.ts +4 -3
- package/src/validation/password.ts +5 -5
- 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
|
|
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
|
-
*
|
|
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
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
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
|
+
};
|
package/src/admin/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
1
|
+
import { failureBody } from "./failureBody";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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,
|
|
16
|
-
for (const [key,
|
|
17
|
-
|
|
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) ?? []), ...
|
|
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
|
+
}
|
package/src/api/company.ts
CHANGED
|
@@ -18,8 +18,9 @@ export interface CompanyInfo {
|
|
|
18
18
|
export type CompanyFallback = Partial<CompanyInfo> & { name: string };
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
* it left blank or could not give. The legal name falls back to the name, and VAT to not
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
7
|
-
* access token. A 401 on a dead session opens the re-sign-in prompt, so the page keeps
|
|
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
|
-
*
|
|
5
|
-
* message of its own. On a 5xx they win over the body. Key 0 covers a request
|
|
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
|
-
*
|
|
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
|
|
40
|
-
* status code 500", is never shown. A
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
+
}
|
package/src/api/request.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
|
-
import {
|
|
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`.
|
|
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
|
-
|
|
20
|
-
|
|
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
|
}
|