@sjolystinnovation/app-kit 0.20.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 +315 -253
- package/package.json +13 -1
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +9 -3
- package/src/admin/CompanySettingsForm.tsx +123 -0
- package/src/admin/LegalPagesEditor.tsx +132 -0
- package/src/admin/LocaleTabs.tsx +36 -0
- package/src/admin/StatHistoryChart.tsx +100 -0
- package/src/admin/adminUsersPanelStrings.ts +3 -3
- package/src/admin/companySettingsFormStrings.ts +50 -0
- package/src/admin/index.ts +14 -0
- package/src/admin/legalPagesEditorStrings.ts +32 -0
- package/src/admin/statHistoryChartStrings.ts +17 -0
- package/src/api/apiFieldErrors.ts +4 -4
- package/src/api/company.ts +47 -0
- package/src/api/createApiClient.ts +3 -2
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +6 -1
- package/src/api/request.ts +2 -2
- package/src/api/revalidateTargets.ts +42 -0
- 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 +62 -0
- package/src/legal/index.ts +1 -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/seo/JsonLd.tsx +1 -1
- package/src/seo/environment.ts +3 -3
- package/src/seo/index.ts +2 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +90 -30
- 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/styles.css +2 -0
- package/src/testing/index.ts +113 -0
- 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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** The business as SjolystInnovation.AppKit.SiteSettings's public `/company` endpoint describes it. */
|
|
2
|
+
export interface CompanyInfo {
|
|
3
|
+
name: string;
|
|
4
|
+
legalName: string;
|
|
5
|
+
orgNumber: string;
|
|
6
|
+
vatRegistered: boolean;
|
|
7
|
+
/** The address on one line, for display. The API derives it from the parts below. */
|
|
8
|
+
address: string;
|
|
9
|
+
streetAddress: string;
|
|
10
|
+
postalCode: string;
|
|
11
|
+
addressLocality: string;
|
|
12
|
+
addressRegion: string;
|
|
13
|
+
email: string;
|
|
14
|
+
phone: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What the app knows about the business without the API. Only the name is required. */
|
|
18
|
+
export type CompanyFallback = Partial<CompanyInfo> & { name: string };
|
|
19
|
+
|
|
20
|
+
/**
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
export function mergeCompanyInfo(data: Partial<CompanyInfo> | null | undefined, fallback: CompanyFallback): CompanyInfo {
|
|
26
|
+
const name = data?.name || fallback.name;
|
|
27
|
+
const pick = (key: Exclude<keyof CompanyInfo, "name" | "legalName" | "vatRegistered">) => data?.[key] || fallback[key] || "";
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
legalName: data?.legalName || fallback.legalName || name,
|
|
31
|
+
orgNumber: pick("orgNumber"),
|
|
32
|
+
vatRegistered: data?.vatRegistered ?? fallback.vatRegistered ?? false,
|
|
33
|
+
address: pick("address"),
|
|
34
|
+
streetAddress: pick("streetAddress"),
|
|
35
|
+
postalCode: pick("postalCode"),
|
|
36
|
+
addressLocality: pick("addressLocality"),
|
|
37
|
+
addressRegion: pick("addressRegion"),
|
|
38
|
+
email: pick("email"),
|
|
39
|
+
phone: pick("phone"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Formats the org number with the Norwegian ` MVA` suffix once the business is VAT registered. Returns `""` without a number. */
|
|
44
|
+
export function formatOrgNumber({ orgNumber, vatRegistered }: { orgNumber?: string | null | undefined; vatRegistered?: boolean | null | undefined }): string {
|
|
45
|
+
if (!orgNumber) return "";
|
|
46
|
+
return vatRegistered ? `${orgNumber} MVA` : orgNumber;
|
|
47
|
+
}
|
|
@@ -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
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export { apiFieldErrors } from "./apiFieldErrors";
|
|
2
|
+
export { formatOrgNumber, mergeCompanyInfo } from "./company";
|
|
3
|
+
export type { CompanyFallback, CompanyInfo } from "./company";
|
|
2
4
|
export { createApiClient } from "./createApiClient";
|
|
5
|
+
export { formatApiError } from "./formatApiError";
|
|
6
|
+
export type { StatusMessages } from "./formatApiError";
|
|
3
7
|
export { request } from "./request";
|
|
4
|
-
export {
|
|
8
|
+
export { defineRevalidateTargets } from "./revalidateTargets";
|
|
9
|
+
export type { RevalidateTargetPaths, RevalidateTargetsConfig } from "./revalidateTargets";
|
|
5
10
|
export {
|
|
6
11
|
createPublicClient,
|
|
7
12
|
createUserService,
|
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 }>,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { normalizePath } from "../seo/site";
|
|
2
|
+
import { requestRevalidate } from "./requestRevalidate";
|
|
3
|
+
|
|
4
|
+
/** The pages one admin edit changes. */
|
|
5
|
+
export interface RevalidateTargetPaths {
|
|
6
|
+
/** The paths below each locale segment, such as `/builds/[slug]`. `""` is the locale's front page. */
|
|
7
|
+
pages?: readonly string[] | undefined;
|
|
8
|
+
/** The paths outside the locale segments, such as `/sitemap.xml`. */
|
|
9
|
+
root?: readonly string[] | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Options for `defineRevalidateTargets`. */
|
|
13
|
+
export interface RevalidateTargetsConfig<T extends string> {
|
|
14
|
+
/** The locale segments every page is under. A site without them omits this. */
|
|
15
|
+
locales?: readonly string[] | undefined;
|
|
16
|
+
targets: Record<T, RevalidateTargetPaths>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
export function defineRevalidateTargets<T extends string>({ locales = [], targets }: RevalidateTargetsConfig<T>) {
|
|
26
|
+
const names = Object.keys(targets) as T[];
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
targets: Object.fromEntries(names.map(name => [name, name])) as { readonly [K in T]: K },
|
|
30
|
+
paths: Object.fromEntries(
|
|
31
|
+
names.map(name => {
|
|
32
|
+
const { pages = [], root = [] } = targets[name];
|
|
33
|
+
const localized =
|
|
34
|
+
locales.length > 0
|
|
35
|
+
? locales.flatMap(locale => pages.map(page => `/${locale}${normalizePath(page)}`))
|
|
36
|
+
: pages.map(page => normalizePath(page) || "/");
|
|
37
|
+
return [name, [...localized, ...root]];
|
|
38
|
+
}),
|
|
39
|
+
) as Record<T, string[]>,
|
|
40
|
+
revalidate: (target: T): Promise<void> => requestRevalidate(target),
|
|
41
|
+
};
|
|
42
|
+
}
|
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";
|
package/src/branding/server.ts
CHANGED
|
@@ -2,22 +2,23 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import type { Branding } from "./types";
|
|
4
4
|
|
|
5
|
+
/** Options for `brandingIcon`. */
|
|
5
6
|
export interface BrandingIconOptions {
|
|
6
|
-
/**
|
|
7
|
+
/** A function that loads the branding, such as a cached GET of `/branding`. It throws when the API cannot be reached. */
|
|
7
8
|
load: () => Promise<Branding | null | undefined>;
|
|
8
9
|
/** The bundled icon for when none is set, such as `public/icon.png`. */
|
|
9
10
|
fallbackPath: string;
|
|
10
|
-
/**
|
|
11
|
+
/** The bundled icon's content type, such as `image/png` or `image/svg+xml`. */
|
|
11
12
|
fallbackType: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
* JPEG, WebP, GIF or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox
|
|
17
|
-
* that a CSP header from the app's `next.config` replaces.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Builds the response for an `app/icon.ts` route: the favicon set in admin settings when it is a
|
|
17
|
+
* PNG, JPEG, WebP, GIF, or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox
|
|
18
|
+
* CSP that a CSP header from the app's `next.config` replaces. When the branding cannot be loaded,
|
|
19
|
+
* it answers 503 with `no-store` instead of the bundled icon, since a browser would cache that in
|
|
20
|
+
* place of the one set in settings. The route needs `dynamic = "force-dynamic"`, or the build bakes
|
|
21
|
+
* in the icon of the moment.
|
|
21
22
|
*/
|
|
22
23
|
export async function brandingIcon({ load, fallbackPath, fallbackType }: BrandingIconOptions): Promise<Response> {
|
|
23
24
|
let branding: Branding | null | undefined;
|
|
@@ -47,7 +48,7 @@ const RASTER_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gi
|
|
|
47
48
|
const HARDENING = { "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; sandbox" };
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
|
-
*
|
|
51
|
+
* Computes a short digest of icon bytes, for a URL that changes when the icon does, such as
|
|
51
52
|
* `/icon?v=<digest>` in the web app manifest. Android refreshes a home screen icon only when the
|
|
52
53
|
* manifest changes.
|
|
53
54
|
*/
|
package/src/branding/types.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/** The logo and favicon set in admin settings, as the API's `/branding` endpoint returns them. */
|
|
2
2
|
export interface Branding {
|
|
3
|
-
/**
|
|
3
|
+
/** The logo's image bytes, in base64. */
|
|
4
4
|
logoData?: string | null | undefined;
|
|
5
5
|
logoContentType?: string | null | undefined;
|
|
6
|
-
/**
|
|
6
|
+
/** The favicon's image bytes, in base64. */
|
|
7
7
|
iconData?: string | null | undefined;
|
|
8
8
|
iconContentType?: string | null | undefined;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/** Builds a `data:` URL for base64 image bytes. Returns null when either part is missing. */
|
|
12
12
|
export const toDataUrl = (data?: string | null, contentType?: string | null): string | null =>
|
|
13
13
|
data && contentType ? `data:${contentType};base64,${data}` : null;
|
package/src/config.ts
CHANGED
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export interface SessionConfig {
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
* talk to more than one API, or that deploy several apps side by side, need distinct names.
|
|
7
|
+
* The name of the environment variable holding the API JWT secret, such as `API_JWT_SECRET`. Apps
|
|
8
|
+
* that talk to more than one API, or that deploy several apps side by side, need distinct names.
|
|
9
9
|
*/
|
|
10
10
|
jwtSecretEnvVar: string;
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
* something like "/en/login"
|
|
12
|
+
* The path an expired or rejected session sends the browser to. An app with a locale prefix uses
|
|
13
|
+
* something like `"/en/login"`. An app without one uses `"/login"`.
|
|
14
14
|
*/
|
|
15
15
|
loginRoute: string;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
* "example"
|
|
17
|
+
* The prefix for the localStorage keys holding form drafts, usually the app's name, such as
|
|
18
|
+
* `"example"`. Drafts are stored under `<prefix>:draft:<owner>:<scope>`, so the prefix itself
|
|
19
19
|
* has no trailing colon.
|
|
20
20
|
*/
|
|
21
21
|
draftStoragePrefix: string;
|
|
@@ -31,8 +31,8 @@ export function defineSessionConfig(config: SessionConfig): SessionConfig {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
* "app:draft::draft:"
|
|
34
|
+
* Gets the key prefix for form drafts. Throws on a colon or an empty name. Those build keys such as
|
|
35
|
+
* `"app:draft::draft:"`, which match no draft saved under `"app:draft:"`.
|
|
36
36
|
*/
|
|
37
37
|
export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">): string {
|
|
38
38
|
const name = config.draftStoragePrefix;
|
|
@@ -57,8 +57,8 @@ export function requireEnv(name: string, value: string | undefined): string {
|
|
|
57
57
|
* Reads the API JWT secret named by the config. Throws when it is missing, with a message that names
|
|
58
58
|
* the variable.
|
|
59
59
|
*
|
|
60
|
-
* The caller passes the environment, usually `process.env`, so this
|
|
61
|
-
*
|
|
60
|
+
* The caller passes the environment, usually `process.env`, so this module needs no Node types and
|
|
61
|
+
* stays safe to import in browser code. `createAuthOptions` uses it. It is not part of the public API.
|
|
62
62
|
*/
|
|
63
63
|
export function resolveJwtSecret(
|
|
64
64
|
config: SessionConfig,
|
|
@@ -32,6 +32,7 @@ function read<T>(storageKey: string): T | null {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Options for `useFormDraft`. */
|
|
35
36
|
export interface FormDraftOptions<T> {
|
|
36
37
|
/**
|
|
37
38
|
* The signed-in user's id. A browser profile can be shared, so drafts are stored per user. That
|
|
@@ -40,27 +41,28 @@ export interface FormDraftOptions<T> {
|
|
|
40
41
|
* known.
|
|
41
42
|
*/
|
|
42
43
|
owner: string | undefined;
|
|
43
|
-
/**
|
|
44
|
+
/** The key for the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
|
|
44
45
|
scope: string;
|
|
45
46
|
/** The form's current values. They must survive `JSON.stringify`. */
|
|
46
47
|
value: T;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** What `useFormDraft` returns. */
|
|
49
51
|
export interface FormDraft<T> {
|
|
50
52
|
/**
|
|
51
|
-
* The stored draft, while it is still waiting to be restored or dismissed.
|
|
52
|
-
* known and again whenever the owner or scope changes, so it can appear after mount.
|
|
53
|
+
* The stored draft, while it is still waiting to be restored or dismissed. The hook reads it once
|
|
54
|
+
* the owner is known and again whenever the owner or scope changes, so it can appear after mount.
|
|
53
55
|
*/
|
|
54
56
|
pending: T | null;
|
|
55
|
-
/**
|
|
57
|
+
/** Stops offering the draft. The caller applies the values it wants. */
|
|
56
58
|
dismiss: () => void;
|
|
57
|
-
/**
|
|
59
|
+
/** Drops the stored draft. Call it after a successful save. */
|
|
58
60
|
clear: () => void;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
|
-
* Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
|
|
63
|
-
*
|
|
64
|
+
* Keeps a form's values in localStorage so a sign-out, reload, or closed tab does not lose them. The
|
|
65
|
+
* hook writes them half a second after the last change, so only that last half second is at risk.
|
|
64
66
|
*
|
|
65
67
|
* A draft found at mount is offered for restore, not applied. So an edit form never silently
|
|
66
68
|
* overwrites what the API returned. Saving starts at once, whether or not the offer is answered.
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
export { defineSessionConfig, requireEnv
|
|
1
|
+
export { defineSessionConfig, requireEnv } from "./config";
|
|
2
2
|
export type { SessionConfig } from "./config";
|
|
3
|
-
export { formatApiError } from "./errors";
|
|
4
|
-
export type { StatusMessages } from "./errors";
|