@sjolystinnovation/app-kit 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -265
- package/package.json +1 -1
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +9 -3
- package/src/admin/CompanySettingsForm.tsx +2 -1
- package/src/admin/LegalPagesEditor.tsx +5 -4
- package/src/admin/LocaleTabs.tsx +4 -3
- package/src/admin/StatHistoryChart.tsx +4 -3
- package/src/admin/adminUsersPanelStrings.ts +3 -3
- package/src/admin/legalPagesEditorStrings.ts +1 -1
- package/src/admin/statHistoryChartStrings.ts +3 -2
- package/src/api/apiFieldErrors.ts +4 -4
- package/src/api/company.ts +4 -3
- package/src/api/createApiClient.ts +3 -2
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +2 -1
- package/src/api/request.ts +2 -2
- package/src/api/revalidateTargets.ts +7 -5
- package/src/api/services.ts +14 -13
- package/src/auth/createAuthOptions.ts +7 -5
- package/src/branding/BrandingManager.tsx +10 -5
- package/src/branding/BrandingProvider.tsx +5 -3
- package/src/branding/index.ts +1 -2
- 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 +6 -5
- 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/seo/JsonLd.tsx +1 -1
- package/src/seo/environment.ts +3 -3
- package/src/seo/index.ts +0 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +44 -32
- 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 +8 -5
- package/src/session/react/AppSessionProvider.tsx +3 -2
- package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
- 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 +1 -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/testing/index.ts +5 -5
- package/src/ui/Alert.tsx +6 -4
- package/src/ui/ContentImage.tsx +12 -11
- package/src/ui/ErrorState.tsx +5 -3
- package/src/ui/NotFoundState.tsx +9 -7
- package/src/ui/PasswordInput.tsx +6 -5
- package/src/ui/ResetPasswordFlow.tsx +10 -8
- package/src/ui/TextArea.tsx +6 -4
- package/src/ui/TextInput.tsx +4 -2
- package/src/ui/Toggle.tsx +3 -3
- package/src/ui/field.tsx +5 -3
- package/src/ui/index.ts +2 -10
- package/src/ui/resetPasswordFlowStrings.ts +7 -7
- package/src/validation/fieldErrors.ts +4 -3
- package/src/validation/password.ts +5 -5
- package/src/validation/passwordRules.ts +2 -2
package/package.json
CHANGED
package/src/admin/AdminShell.tsx
CHANGED
|
@@ -13,6 +13,7 @@ export interface AdminTab {
|
|
|
13
13
|
|
|
14
14
|
type GateProps = RoleGateProps extends infer P ? (P extends unknown ? Omit<P, "children"> : never) : never;
|
|
15
15
|
|
|
16
|
+
/** Props for `AdminShell`. The gate props are `RoleGate`'s, without `children`. */
|
|
16
17
|
export type AdminShellProps = GateProps & {
|
|
17
18
|
title: ReactNode;
|
|
18
19
|
tabs: readonly AdminTab[];
|
|
@@ -48,6 +49,6 @@ export function AdminShell({ title, tabs, children, ...gate }: AdminShellProps)
|
|
|
48
49
|
{children}
|
|
49
50
|
</div>
|
|
50
51
|
);
|
|
51
|
-
// The gate props
|
|
52
|
+
// The gate props arrive as one of RoleGate's two shapes, so they go back as that shape.
|
|
52
53
|
return <RoleGate {...({ ...gate, children: page } as RoleGateProps)} />;
|
|
53
54
|
}
|
|
@@ -9,12 +9,13 @@ import { Toggle } from "../ui/Toggle";
|
|
|
9
9
|
import { withDefaults, type TextOverrides } from "../ui/withDefaults";
|
|
10
10
|
import { defaultAdminUsersPanelStrings, type AdminUsersPanelStrings } from "./adminUsersPanelStrings";
|
|
11
11
|
|
|
12
|
+
/** Props for `AdminUsersPanel`. */
|
|
12
13
|
export interface AdminUsersPanelProps {
|
|
13
14
|
/** The user admin calls, such as `createAdminUsersService(api)`. */
|
|
14
15
|
service: ReturnType<typeof createAdminUsersService>;
|
|
15
16
|
/** The roles an admin may give, from the app's own endpoint. */
|
|
16
17
|
getRoles: () => Promise<string[]>;
|
|
17
|
-
/** The role a new invite starts with. `Admin
|
|
18
|
+
/** The role a new invite starts with. Defaults to `Admin`. */
|
|
18
19
|
defaultInviteRole?: string | undefined;
|
|
19
20
|
strings?: TextOverrides<AdminUsersPanelStrings> | undefined;
|
|
20
21
|
}
|
|
@@ -39,7 +40,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
|
|
|
39
40
|
const [roleLoading, setRoleLoading] = useState(false);
|
|
40
41
|
const [invite, setInvite] = useState({ email: "", firstName: "", lastName: "", role: defaultInviteRole });
|
|
41
42
|
const [inviting, setInviting] = useState(false);
|
|
42
|
-
//
|
|
43
|
+
// Bumping it fetches the list again after a change, through the same effect as every other load.
|
|
43
44
|
const [reloads, setReloads] = useState(0);
|
|
44
45
|
// The latest service, so an inline one does not fetch the list again on every render. The roles
|
|
45
46
|
// load once, so the first getRoles is enough.
|
|
@@ -134,7 +135,12 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
|
|
|
134
135
|
}
|
|
135
136
|
setInviting(true);
|
|
136
137
|
try {
|
|
137
|
-
await serviceRef.current.invite(
|
|
138
|
+
await serviceRef.current.invite({
|
|
139
|
+
email: invite.email.trim(),
|
|
140
|
+
firstName: invite.firstName.trim(),
|
|
141
|
+
lastName: invite.lastName.trim(),
|
|
142
|
+
role: invite.role,
|
|
143
|
+
});
|
|
138
144
|
toast.success(text.inviteSent);
|
|
139
145
|
setInvite({ email: "", firstName: "", lastName: "", role: invite.role });
|
|
140
146
|
reload();
|
|
@@ -8,11 +8,12 @@ import { Toggle } from "../ui/Toggle";
|
|
|
8
8
|
import { withDefaults, type TextOverrides } from "../ui/withDefaults";
|
|
9
9
|
import { defaultCompanySettingsFormStrings, type CompanySettingsFormStrings } from "./companySettingsFormStrings";
|
|
10
10
|
|
|
11
|
+
/** Props for `CompanySettingsForm`. */
|
|
11
12
|
export interface CompanySettingsFormProps {
|
|
12
13
|
/** The settings calls, such as `createSettingsService(api)`. */
|
|
13
14
|
service: { get: () => Promise<SiteSettings>; update: (settings: SiteSettings) => Promise<SiteSettings> };
|
|
14
15
|
strings?: TextOverrides<CompanySettingsFormStrings> | undefined;
|
|
15
|
-
/**
|
|
16
|
+
/** The content shown below the form once the settings have loaded, such as the `BrandingManager`. */
|
|
16
17
|
children?: ReactNode;
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -8,17 +8,18 @@ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
|
|
|
8
8
|
import { defaultLegalPagesEditorStrings, type LegalPagesEditorStrings } from "./legalPagesEditorStrings";
|
|
9
9
|
import { LocaleTabs } from "./LocaleTabs";
|
|
10
10
|
|
|
11
|
+
/** Props for `LegalPagesEditor`. */
|
|
11
12
|
export interface LegalPagesEditorProps<K extends string, L extends string> {
|
|
12
13
|
/** The legal page calls, such as `createLegalService(api, publicApi)`. */
|
|
13
14
|
service: {
|
|
14
15
|
listAll: () => Promise<LegalPage<K>[]>;
|
|
15
|
-
save: (key: K
|
|
16
|
+
save: (page: { key: K; locale: L; title: string; bodyMarkdown: string }) => Promise<unknown>;
|
|
16
17
|
};
|
|
17
|
-
/** The pages, in tab order. The
|
|
18
|
+
/** The pages, in tab order. The editor opens on the first. */
|
|
18
19
|
keys: readonly K[];
|
|
19
20
|
/** Each page's tab label, such as `Terms`. */
|
|
20
21
|
keyLabels: Record<K, string>;
|
|
21
|
-
/** The locales each page is written in. The
|
|
22
|
+
/** The locales each page is written in. The editor opens on the first. */
|
|
22
23
|
locales: readonly L[];
|
|
23
24
|
/** Each locale's tab label, such as `Norsk`. */
|
|
24
25
|
localeLabels: Record<L, string>;
|
|
@@ -70,7 +71,7 @@ export function LegalPagesEditor<K extends string, L extends string>({ service,
|
|
|
70
71
|
}
|
|
71
72
|
setSaving(true);
|
|
72
73
|
try {
|
|
73
|
-
await serviceRef.current.save(activeKey, activeLocale, draft.title.trim(), draft.bodyMarkdown);
|
|
74
|
+
await serviceRef.current.save({ key: activeKey, locale: activeLocale, title: draft.title.trim(), bodyMarkdown: draft.bodyMarkdown });
|
|
74
75
|
toast.success(text.saved);
|
|
75
76
|
} catch (err) {
|
|
76
77
|
toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
|
package/src/admin/LocaleTabs.tsx
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
/** Props for `LocaleTabs`. */
|
|
3
4
|
export interface LocaleTabsProps<L extends string> {
|
|
4
5
|
locales: readonly L[];
|
|
5
6
|
/** Each locale's tab label, such as `Norsk`. */
|
|
6
7
|
labels: Record<L, string>;
|
|
7
8
|
active: L;
|
|
8
9
|
onChange: (locale: L) => void;
|
|
9
|
-
/**
|
|
10
|
+
/** The locales that have content. A locale set to `false` gets a dot titled `notFilled`. */
|
|
10
11
|
filled?: Partial<Record<L, boolean>> | undefined;
|
|
11
|
-
/** The dot's title
|
|
12
|
+
/** The dot's title. Defaults to "Not filled in". */
|
|
12
13
|
notFilled?: string | undefined;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
+
/** Renders tabs for editing one translation at a time. */
|
|
16
17
|
export function LocaleTabs<L extends string>({ locales, labels, active, onChange, filled, notFilled = "Not filled in" }: LocaleTabsProps<L>) {
|
|
17
18
|
return (
|
|
18
19
|
<div className="flex items-center gap-1 border-b border-gray-200">
|
|
@@ -9,10 +9,11 @@ export interface StatSeries<T> {
|
|
|
9
9
|
/** The field of each day's row the line plots. A missing or non-numeric value counts as zero. */
|
|
10
10
|
key: keyof T & string;
|
|
11
11
|
label: string;
|
|
12
|
-
/** The line's
|
|
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,8 @@
|
|
|
1
1
|
// Plain values, with no "use client", so a server component can import them.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The users panel's text. Pass the app's own language. The defaults are English.
|
|
5
|
-
*
|
|
4
|
+
* The users panel's text. Pass the app's own language. The defaults are English. The panel fills in
|
|
5
|
+
* `{name}` and `{role}` where a text names them.
|
|
6
6
|
*/
|
|
7
7
|
export interface AdminUsersPanelStrings {
|
|
8
8
|
users: string;
|
|
@@ -19,7 +19,7 @@ export interface AdminUsersPanelStrings {
|
|
|
19
19
|
/** The tag after a deleted user's name. */
|
|
20
20
|
deleted: string;
|
|
21
21
|
delete: string;
|
|
22
|
-
/**
|
|
22
|
+
/** The text before the role name on the button that takes a role away. */
|
|
23
23
|
remove: string;
|
|
24
24
|
noRoles: string;
|
|
25
25
|
addRole: string;
|
|
@@ -11,7 +11,7 @@ export interface LegalPagesEditorStrings {
|
|
|
11
11
|
save: string;
|
|
12
12
|
saving: string;
|
|
13
13
|
loadFailed: string;
|
|
14
|
-
/**
|
|
14
|
+
/** The message shown when the title or the content is blank. */
|
|
15
15
|
required: string;
|
|
16
16
|
saved: string;
|
|
17
17
|
saveFailed: string;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Plain values, with no "use client", so a server component can import them.
|
|
2
2
|
|
|
3
|
+
/** The chart's text. Pass the app's own language. The defaults are English. */
|
|
3
4
|
export interface StatHistoryChartStrings {
|
|
4
|
-
/**
|
|
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;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
* they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
|
|
6
|
-
* `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body.
|
|
7
|
-
* are no field errors, so the caller falls back to one message for the whole form.
|
|
4
|
+
* Gets the per-field messages of an ASP.NET validation problem, keyed by the field's name in
|
|
5
|
+
* camelCase so they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
|
|
6
|
+
* `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body. Returns null when
|
|
7
|
+
* there are no field errors, so the caller falls back to one message for the whole form.
|
|
8
8
|
*/
|
|
9
9
|
export function apiFieldErrors(error: unknown): Record<string, string[]> | null {
|
|
10
10
|
const data = axios.isAxiosError(error) ? error.response?.data : error;
|
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) {
|
|
@@ -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
|
@@ -2,8 +2,9 @@ export { apiFieldErrors } from "./apiFieldErrors";
|
|
|
2
2
|
export { formatOrgNumber, mergeCompanyInfo } from "./company";
|
|
3
3
|
export type { CompanyFallback, CompanyInfo } from "./company";
|
|
4
4
|
export { createApiClient } from "./createApiClient";
|
|
5
|
+
export { formatApiError } from "./formatApiError";
|
|
6
|
+
export type { StatusMessages } from "./formatApiError";
|
|
5
7
|
export { request } from "./request";
|
|
6
|
-
export { requestRevalidate } from "./requestRevalidate";
|
|
7
8
|
export { defineRevalidateTargets } from "./revalidateTargets";
|
|
8
9
|
export type { RevalidateTargetPaths, RevalidateTargetsConfig } from "./revalidateTargets";
|
|
9
10
|
export {
|
package/src/api/request.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
|
-
import { formatApiError, type StatusMessages } from "
|
|
2
|
+
import { formatApiError, type StatusMessages } from "./formatApiError";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Runs an API call and returns its data. A failure throws an `Error` with a message for the user,
|
|
6
|
-
* from `formatApiError`. A
|
|
6
|
+
* from `formatApiError`. A canceled call rethrows the cancel as it is, so the caller can ignore it.
|
|
7
7
|
*/
|
|
8
8
|
export async function request<T>(
|
|
9
9
|
call: () => Promise<{ data: T }>,
|
|
@@ -3,12 +3,13 @@ import { requestRevalidate } from "./requestRevalidate";
|
|
|
3
3
|
|
|
4
4
|
/** The pages one admin edit changes. */
|
|
5
5
|
export interface RevalidateTargetPaths {
|
|
6
|
-
/**
|
|
6
|
+
/** The paths below each locale segment, such as `/builds/[slug]`. `""` is the locale's front page. */
|
|
7
7
|
pages?: readonly string[] | undefined;
|
|
8
|
-
/**
|
|
8
|
+
/** The paths outside the locale segments, such as `/sitemap.xml`. */
|
|
9
9
|
root?: readonly string[] | undefined;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Options for `defineRevalidateTargets`. */
|
|
12
13
|
export interface RevalidateTargetsConfig<T extends string> {
|
|
13
14
|
/** The locale segments every page is under. A site without them omits this. */
|
|
14
15
|
locales?: readonly string[] | undefined;
|
|
@@ -16,9 +17,10 @@ export interface RevalidateTargetsConfig<T extends string> {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
* for fetch tags, `paths`
|
|
21
|
-
* route to purge one after an edit. A target with no paths is purged by
|
|
20
|
+
* Defines the cache targets an app's admin edits purge, in one place for the client and the server.
|
|
21
|
+
* `targets` names them for fetch tags, `paths` lists what `createRevalidateRoute` purges for each,
|
|
22
|
+
* and `revalidate` asks the route to purge one after an edit. A target with no paths is purged by
|
|
23
|
+
* its tag alone.
|
|
22
24
|
*/
|
|
23
25
|
export function defineRevalidateTargets<T extends string>({ locales = [], targets }: RevalidateTargetsConfig<T>) {
|
|
24
26
|
const names = Object.keys(targets) as T[];
|
package/src/api/services.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Branding } from "../branding/types";
|
|
|
4
4
|
import { request } from "./request";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Creates an axios instance for the API's anonymous endpoints, without the session's token. Like
|
|
8
8
|
* `createApiClient`, it only sends requests to `baseURL`.
|
|
9
9
|
*/
|
|
10
10
|
export function createPublicClient(baseURL: string): AxiosInstance {
|
|
@@ -14,7 +14,7 @@ export function createPublicClient(baseURL: string): AxiosInstance {
|
|
|
14
14
|
return axios.create({ baseURL, allowAbsoluteUrls: false });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/** A callback that runs after an admin change saves, such as to purge the pages that show it. */
|
|
18
18
|
export type OnSaved = () => Promise<void> | void;
|
|
19
19
|
|
|
20
20
|
async function currentUserId(): Promise<string> {
|
|
@@ -35,8 +35,8 @@ export interface UserProfile {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
* SjolystInnovation.AppKit.Auth. `api` carries the session, `publicApi` does not.
|
|
38
|
+
* Creates the calls for password reset and the signed-in user's account, on the auth and account
|
|
39
|
+
* endpoints of SjolystInnovation.AppKit.Auth. `api` carries the session, and `publicApi` does not.
|
|
40
40
|
*/
|
|
41
41
|
export function createUserService(api: AxiosInstance, publicApi: AxiosInstance) {
|
|
42
42
|
return {
|
|
@@ -88,7 +88,7 @@ export interface SiteSettings {
|
|
|
88
88
|
publicPhone: string;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/**
|
|
91
|
+
/** Creates the admin calls for the company settings on SjolystInnovation.AppKit.SiteSettings. */
|
|
92
92
|
export function createSettingsService(api: AxiosInstance, options: { onSaved?: OnSaved } = {}) {
|
|
93
93
|
return {
|
|
94
94
|
get: () => request(() => api.get<SiteSettings>("/settings"), "Failed to load settings"),
|
|
@@ -102,8 +102,8 @@ export function createSettingsService(api: AxiosInstance, options: { onSaved?: O
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
106
|
-
* no branding when the API cannot be reached, so a page still renders.
|
|
105
|
+
* Creates the calls for the logo and favicon on SjolystInnovation.AppKit.SiteSettings. Reading is
|
|
106
|
+
* anonymous and returns no branding when the API cannot be reached, so a page still renders.
|
|
107
107
|
*/
|
|
108
108
|
export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstance, options: { onSaved?: OnSaved } = {}) {
|
|
109
109
|
return {
|
|
@@ -115,7 +115,7 @@ export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstan
|
|
|
115
115
|
}
|
|
116
116
|
},
|
|
117
117
|
|
|
118
|
-
/** Sends the form `BrandingManager` builds: `logo`, `icon`, `removeLogo
|
|
118
|
+
/** Sends the form `BrandingManager` builds: `logo`, `icon`, `removeLogo`, and `removeIcon`. */
|
|
119
119
|
async update(form: FormData): Promise<Branding> {
|
|
120
120
|
const saved = await request(() => api.put<Branding>("/branding", form), "Failed to save branding");
|
|
121
121
|
await options.onSaved?.();
|
|
@@ -134,8 +134,9 @@ export interface LegalPage<Key extends string = string> {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
138
|
-
* and
|
|
137
|
+
* Creates the calls for the terms, privacy, and cookie pages on
|
|
138
|
+
* SjolystInnovation.AppKit.SiteSettings. Reading is anonymous and returns null for a page that is
|
|
139
|
+
* missing or cannot be read, so a page shows its fallback.
|
|
139
140
|
*/
|
|
140
141
|
export function createLegalService<Key extends string = "terms" | "privacy" | "cookies">(
|
|
141
142
|
api: AxiosInstance,
|
|
@@ -153,7 +154,7 @@ export function createLegalService<Key extends string = "terms" | "privacy" | "c
|
|
|
153
154
|
|
|
154
155
|
listAll: () => request(() => api.get<LegalPage<Key>[]>("/content/legal"), "Failed to load legal pages"),
|
|
155
156
|
|
|
156
|
-
async save(key: Key
|
|
157
|
+
async save({ key, locale, title, bodyMarkdown }: { key: Key; locale: string; title: string; bodyMarkdown: string }) {
|
|
157
158
|
const page = await request(
|
|
158
159
|
() => api.put<LegalPage<Key>>(`/content/legal/${encodeURIComponent(key)}/${encodeURIComponent(locale)}`, { title, bodyMarkdown }),
|
|
159
160
|
"Failed to save legal page",
|
|
@@ -176,7 +177,7 @@ export interface AdminUser {
|
|
|
176
177
|
roles: string[];
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
/**
|
|
180
|
+
/** Creates the user administration calls on SjolystInnovation.AppKit.Auth: the list, roles, invites, and deletion. */
|
|
180
181
|
export function createAdminUsersService(api: AxiosInstance) {
|
|
181
182
|
return {
|
|
182
183
|
getUsers: (includeDeleted = false) =>
|
|
@@ -191,7 +192,7 @@ export function createAdminUsersService(api: AxiosInstance) {
|
|
|
191
192
|
deleteUser: (userId: string) =>
|
|
192
193
|
request(() => api.delete(`/users/${encodeURIComponent(userId)}/account`), "Failed to delete user"),
|
|
193
194
|
|
|
194
|
-
invite: (email: string
|
|
195
|
+
invite: ({ email, firstName, lastName, role }: { email: string; firstName: string; lastName: string; role: string }) =>
|
|
195
196
|
request(() => api.post("/users/invite", { email, firstName, lastName, role }), "Failed to send invitation"),
|
|
196
197
|
};
|
|
197
198
|
}
|
|
@@ -44,12 +44,13 @@ interface AuthorizedUser extends User {
|
|
|
44
44
|
refreshToken: string;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Settings for `createAuthOptions`. Pass either `apiUrl` or `http`. */
|
|
47
48
|
export type AuthOptionsSettings = {
|
|
48
49
|
/** The server environment, usually `process.env`. It holds the API JWT secret. */
|
|
49
50
|
env: Record<string, string | undefined>;
|
|
50
51
|
} & (
|
|
51
52
|
| {
|
|
52
|
-
/**
|
|
53
|
+
/** The base URL of the API, such as `https://api.example.com/api`. */
|
|
53
54
|
apiUrl: string;
|
|
54
55
|
http?: never;
|
|
55
56
|
}
|
|
@@ -60,7 +61,8 @@ export type AuthOptionsSettings = {
|
|
|
60
61
|
}
|
|
61
62
|
);
|
|
62
63
|
|
|
63
|
-
//
|
|
64
|
+
// How far ahead of expiry a refresh falls due, at most, so the token is still fresh when the user
|
|
65
|
+
// saves.
|
|
64
66
|
const MAX_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
65
67
|
|
|
66
68
|
// The soonest a refresh can be due. The floor stops a short-lived token from refreshing on every
|
|
@@ -111,9 +113,9 @@ function failureShape(error: unknown) {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/**
|
|
114
|
-
* next-auth options for email and password sign-in against the API. The API issues a
|
|
115
|
-
* access token and a refresh token that changes on every refresh. The session refreshes
|
|
116
|
-
* expiry, rides out temporary API failures, and ends seven days after sign-in.
|
|
116
|
+
* Creates the next-auth options for email and password sign-in against the API. The API issues a
|
|
117
|
+
* short-lived access token and a refresh token that changes on every refresh. The session refreshes
|
|
118
|
+
* ahead of expiry, rides out temporary API failures, and ends seven days after sign-in.
|
|
117
119
|
*/
|
|
118
120
|
export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSettings): NextAuthOptions {
|
|
119
121
|
const { env } = settings;
|
|
@@ -7,6 +7,7 @@ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
|
|
|
7
7
|
import { useBranding } from "./BrandingProvider";
|
|
8
8
|
import { toDataUrl } from "./types";
|
|
9
9
|
|
|
10
|
+
/** The branding form's text. Pass the app's own language. The defaults are English. */
|
|
10
11
|
export interface BrandingManagerStrings {
|
|
11
12
|
title: string;
|
|
12
13
|
logo: string;
|
|
@@ -22,12 +23,14 @@ export interface BrandingManagerStrings {
|
|
|
22
23
|
saveFailed: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif,image/x-icon,.ico";
|
|
27
|
+
|
|
25
28
|
export const defaultBrandingManagerStrings: BrandingManagerStrings = {
|
|
26
29
|
title: "Branding",
|
|
27
30
|
logo: "Logo",
|
|
28
|
-
logoHint: "Shown in the navbar and footer. PNG
|
|
31
|
+
logoHint: "Shown in the navbar and footer. PNG, JPEG, WebP, or GIF, max 2 MB.",
|
|
29
32
|
icon: "Icon (favicon)",
|
|
30
|
-
iconHint: "Browser tab icon. Square PNG
|
|
33
|
+
iconHint: "Browser tab icon. Square PNG or ICO, max 2 MB.",
|
|
31
34
|
none: "none",
|
|
32
35
|
choose: "Choose",
|
|
33
36
|
remove: "Remove",
|
|
@@ -37,10 +40,11 @@ export const defaultBrandingManagerStrings: BrandingManagerStrings = {
|
|
|
37
40
|
saveFailed: "Failed to save branding",
|
|
38
41
|
};
|
|
39
42
|
|
|
43
|
+
/** Props for `BrandingManager`. */
|
|
40
44
|
export interface BrandingManagerProps {
|
|
41
45
|
/**
|
|
42
|
-
*
|
|
43
|
-
* to show the error's message, such as one from `formatApiError`.
|
|
46
|
+
* A function that sends the form: `logo` and `icon` files, and `removeLogo` and `removeIcon` set
|
|
47
|
+
* to `true`. Throw from it to show the error's message, such as one from `formatApiError`.
|
|
44
48
|
*/
|
|
45
49
|
save: (form: FormData) => Promise<unknown>;
|
|
46
50
|
strings?: TextOverrides<BrandingManagerStrings> | undefined;
|
|
@@ -93,7 +97,8 @@ function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProp
|
|
|
93
97
|
<input
|
|
94
98
|
ref={inputRef}
|
|
95
99
|
type="file"
|
|
96
|
-
|
|
100
|
+
// The types the API accepts, so the picker does not offer an SVG it would refuse.
|
|
101
|
+
accept={IMAGE_TYPES}
|
|
97
102
|
aria-label={label}
|
|
98
103
|
className="hidden"
|
|
99
104
|
onChange={e => onPick(e.target.files?.[0] ?? null)}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import { toDataUrl, type Branding } from "./types";
|
|
5
5
|
|
|
6
|
+
/** What `useBranding` returns. */
|
|
6
7
|
export interface BrandingContextValue {
|
|
7
8
|
branding: Branding;
|
|
8
9
|
/** The logo as a `data:` URL, or null when none is set. */
|
|
@@ -13,21 +14,22 @@ export interface BrandingContextValue {
|
|
|
13
14
|
|
|
14
15
|
const BrandingContext = createContext<BrandingContextValue>({ branding: {}, logoUrl: null, refresh: () => {} });
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
+
/** Gets the branding from the nearest `BrandingProvider`. Returns empty branding outside one. */
|
|
17
18
|
export const useBranding = () => useContext(BrandingContext);
|
|
18
19
|
|
|
19
20
|
/** How often the provider loads the branding again, so an open tab picks up a change. */
|
|
20
21
|
export const BRANDING_REFRESH_MS = 5 * 60 * 1000;
|
|
21
22
|
|
|
23
|
+
/** Props for `BrandingProvider`. */
|
|
22
24
|
export interface BrandingProviderProps {
|
|
23
25
|
children: ReactNode;
|
|
24
|
-
/**
|
|
26
|
+
/** A function that loads the branding, such as a GET of `/branding`. On a failure the provider keeps the branding it has. */
|
|
25
27
|
load: () => Promise<Branding>;
|
|
26
28
|
/** The branding the server rendered with. Without it, the provider loads it on mount. */
|
|
27
29
|
initial?: Branding | undefined;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
/**
|
|
32
|
+
/** Provides the logo and favicon to the components below it, and loads them again every five minutes. */
|
|
31
33
|
export function BrandingProvider({ children, load, initial }: BrandingProviderProps) {
|
|
32
34
|
const [branding, setBranding] = useState<Branding>(initial ?? {});
|
|
33
35
|
// The latest load, so an inline function does not restart the refresh timer on every render.
|
package/src/branding/index.ts
CHANGED
|
@@ -5,10 +5,9 @@ export {
|
|
|
5
5
|
type BrandingManagerStrings,
|
|
6
6
|
} from "./BrandingManager";
|
|
7
7
|
export {
|
|
8
|
-
BRANDING_REFRESH_MS,
|
|
9
8
|
BrandingProvider,
|
|
10
9
|
useBranding,
|
|
11
10
|
type BrandingContextValue,
|
|
12
11
|
type BrandingProviderProps,
|
|
13
12
|
} from "./BrandingProvider";
|
|
14
|
-
export {
|
|
13
|
+
export type { Branding } from "./types";
|