@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
|
@@ -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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import axios, { type AxiosInstance } from "axios";
|
|
2
2
|
import { getSession } from "next-auth/react";
|
|
3
3
|
import type { Branding } from "../branding/types";
|
|
4
|
+
import type { LegalPage } from "./legalPage";
|
|
5
|
+
import { LEGAL_PAGE_KEYS, type DefaultLegalPageKey } from "../legal/keys";
|
|
4
6
|
import { request } from "./request";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
9
|
+
* Creates an axios instance for the API's anonymous endpoints, without the session's token. Like
|
|
8
10
|
* `createApiClient`, it only sends requests to `baseURL`.
|
|
9
11
|
*/
|
|
10
12
|
export function createPublicClient(baseURL: string): AxiosInstance {
|
|
@@ -14,7 +16,7 @@ export function createPublicClient(baseURL: string): AxiosInstance {
|
|
|
14
16
|
return axios.create({ baseURL, allowAbsoluteUrls: false });
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
/**
|
|
19
|
+
/** A callback that runs after an admin change saves, such as to purge the pages that show it. */
|
|
18
20
|
export type OnSaved = () => Promise<void> | void;
|
|
19
21
|
|
|
20
22
|
async function currentUserId(): Promise<string> {
|
|
@@ -35,8 +37,8 @@ export interface UserProfile {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
*
|
|
39
|
-
* SjolystInnovation.AppKit.Auth. `api` carries the session, `publicApi` does not.
|
|
40
|
+
* Creates the calls for password reset and the signed-in user's account, on the auth and account
|
|
41
|
+
* endpoints of SjolystInnovation.AppKit.Auth. `api` carries the session, and `publicApi` does not.
|
|
40
42
|
*/
|
|
41
43
|
export function createUserService(api: AxiosInstance, publicApi: AxiosInstance) {
|
|
42
44
|
return {
|
|
@@ -88,7 +90,7 @@ export interface SiteSettings {
|
|
|
88
90
|
publicPhone: string;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
/**
|
|
93
|
+
/** Creates the admin calls for the company settings on SjolystInnovation.AppKit.SiteSettings. */
|
|
92
94
|
export function createSettingsService(api: AxiosInstance, options: { onSaved?: OnSaved } = {}) {
|
|
93
95
|
return {
|
|
94
96
|
get: () => request(() => api.get<SiteSettings>("/settings"), "Failed to load settings"),
|
|
@@ -102,8 +104,8 @@ export function createSettingsService(api: AxiosInstance, options: { onSaved?: O
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/**
|
|
105
|
-
*
|
|
106
|
-
* no branding when the API cannot be reached, so a page still renders.
|
|
107
|
+
* Creates the calls for the logo and favicon on SjolystInnovation.AppKit.SiteSettings. Reading is
|
|
108
|
+
* anonymous and returns no branding when the API cannot be reached, so a page still renders.
|
|
107
109
|
*/
|
|
108
110
|
export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstance, options: { onSaved?: OnSaved } = {}) {
|
|
109
111
|
return {
|
|
@@ -115,7 +117,7 @@ export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstan
|
|
|
115
117
|
}
|
|
116
118
|
},
|
|
117
119
|
|
|
118
|
-
/** Sends the form `BrandingManager` builds: `logo`, `icon`, `removeLogo
|
|
120
|
+
/** Sends the form `BrandingManager` builds: `logo`, `icon`, `removeLogo`, and `removeIcon`. */
|
|
119
121
|
async update(form: FormData): Promise<Branding> {
|
|
120
122
|
const saved = await request(() => api.put<Branding>("/branding", form), "Failed to save branding");
|
|
121
123
|
await options.onSaved?.();
|
|
@@ -124,25 +126,30 @@ export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstan
|
|
|
124
126
|
};
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
/**
|
|
128
|
-
export interface
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
/** Options for `createLegalService`. */
|
|
130
|
+
export interface LegalServiceOptions<Key extends string> {
|
|
131
|
+
/**
|
|
132
|
+
* The app's legal pages, the same keys as the API's `MapAppKitLegalPages` `Keys` option. Defaults
|
|
133
|
+
* to `LEGAL_PAGE_KEYS`. The service's key type comes from them.
|
|
134
|
+
*/
|
|
135
|
+
keys?: readonly Key[] | undefined;
|
|
136
|
+
onSaved?: OnSaved | undefined;
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
/**
|
|
137
|
-
*
|
|
138
|
-
* and
|
|
140
|
+
* Creates the calls for the legal pages on SjolystInnovation.AppKit.SiteSettings, such as terms,
|
|
141
|
+
* privacy, and cookies. Reading is anonymous and returns null for a page that is missing or cannot be
|
|
142
|
+
* read, so a page shows its fallback. `keys` on the result holds the app's pages, for the editor and
|
|
143
|
+
* the sitemap.
|
|
139
144
|
*/
|
|
140
|
-
export function createLegalService<Key extends string =
|
|
145
|
+
export function createLegalService<const Key extends string = DefaultLegalPageKey>(
|
|
141
146
|
api: AxiosInstance,
|
|
142
147
|
publicApi: AxiosInstance,
|
|
143
|
-
options:
|
|
148
|
+
options: LegalServiceOptions<Key> = {},
|
|
144
149
|
) {
|
|
145
150
|
return {
|
|
151
|
+
keys: options.keys ?? (LEGAL_PAGE_KEYS as readonly string[] as readonly Key[]),
|
|
152
|
+
|
|
146
153
|
async get(key: Key, locale: string): Promise<LegalPage<Key> | null> {
|
|
147
154
|
try {
|
|
148
155
|
return (await publicApi.get<LegalPage<Key>>(`/content/legal/${encodeURIComponent(key)}`, { params: { locale } })).data;
|
|
@@ -153,7 +160,7 @@ export function createLegalService<Key extends string = "terms" | "privacy" | "c
|
|
|
153
160
|
|
|
154
161
|
listAll: () => request(() => api.get<LegalPage<Key>[]>("/content/legal"), "Failed to load legal pages"),
|
|
155
162
|
|
|
156
|
-
async save(key: Key
|
|
163
|
+
async save({ key, locale, title, bodyMarkdown }: { key: Key; locale: string; title: string; bodyMarkdown: string }) {
|
|
157
164
|
const page = await request(
|
|
158
165
|
() => api.put<LegalPage<Key>>(`/content/legal/${encodeURIComponent(key)}/${encodeURIComponent(locale)}`, { title, bodyMarkdown }),
|
|
159
166
|
"Failed to save legal page",
|
|
@@ -164,6 +171,27 @@ export function createLegalService<Key extends string = "terms" | "privacy" | "c
|
|
|
164
171
|
};
|
|
165
172
|
}
|
|
166
173
|
|
|
174
|
+
/** The transactional email of the last days, as SjolystInnovation.AppKit.Email's stats endpoint returns it. */
|
|
175
|
+
export interface EmailStats {
|
|
176
|
+
days: number;
|
|
177
|
+
requests: number;
|
|
178
|
+
delivered: number;
|
|
179
|
+
hardBounces: number;
|
|
180
|
+
softBounces: number;
|
|
181
|
+
spamReports: number;
|
|
182
|
+
blocked: number;
|
|
183
|
+
invalid: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Creates the admin call for the email stats on SjolystInnovation.AppKit.Email's `MapAppKitEmailStats`. */
|
|
187
|
+
export function createEmailStatsService(api: AxiosInstance) {
|
|
188
|
+
return {
|
|
189
|
+
/** The stats for the last `days` days, 30 by default. */
|
|
190
|
+
get: (days = 30) =>
|
|
191
|
+
request(() => api.get<EmailStats>("/admin/email-stats", { params: { days } }), "Failed to load email stats"),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
167
195
|
/** A user in the admin list, as SjolystInnovation.AppKit.Auth's user admin endpoints return it. */
|
|
168
196
|
export interface AdminUser {
|
|
169
197
|
id: string;
|
|
@@ -176,12 +204,15 @@ export interface AdminUser {
|
|
|
176
204
|
roles: string[];
|
|
177
205
|
}
|
|
178
206
|
|
|
179
|
-
/**
|
|
207
|
+
/** Creates the user administration calls on SjolystInnovation.AppKit.Auth: the list, roles, invites, and deletion. */
|
|
180
208
|
export function createAdminUsersService(api: AxiosInstance) {
|
|
181
209
|
return {
|
|
182
210
|
getUsers: (includeDeleted = false) =>
|
|
183
211
|
request(() => api.get<AdminUser[]>("/users", { params: { includeDeleted } }), "Failed to load users"),
|
|
184
212
|
|
|
213
|
+
/** The roles an admin may give, in the order the API configures them. */
|
|
214
|
+
roles: () => request(() => api.get<string[]>("/users/roles"), "Failed to load roles"),
|
|
215
|
+
|
|
185
216
|
addRole: (userId: string, role: string) =>
|
|
186
217
|
request(() => api.post(`/users/${encodeURIComponent(userId)}/roles`, { role }), "Failed to assign role"),
|
|
187
218
|
|
|
@@ -191,7 +222,7 @@ export function createAdminUsersService(api: AxiosInstance) {
|
|
|
191
222
|
deleteUser: (userId: string) =>
|
|
192
223
|
request(() => api.delete(`/users/${encodeURIComponent(userId)}/account`), "Failed to delete user"),
|
|
193
224
|
|
|
194
|
-
invite: (email: string
|
|
225
|
+
invite: ({ email, firstName, lastName, role }: { email: string; firstName: string; lastName: string; role: string }) =>
|
|
195
226
|
request(() => api.post("/users/invite", { email, firstName, lastName, role }), "Failed to send invitation"),
|
|
196
227
|
};
|
|
197
228
|
}
|
|
@@ -3,6 +3,7 @@ import jwt from "jsonwebtoken";
|
|
|
3
3
|
import type { NextAuthOptions, Session, User } from "next-auth";
|
|
4
4
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
5
5
|
import type { JWT } from "next-auth/jwt";
|
|
6
|
+
import { apiErrorCode } from "../api/apiErrorCode";
|
|
6
7
|
import { resolveJwtSecret, type SessionConfig } from "../config";
|
|
7
8
|
import { ABSOLUTE_SESSION_MAX_AGE, CLOCK_TOLERANCE_S, SESSION_ERRORS, SIGN_IN_ERRORS, isTerminalSessionError } from "../session/expiry";
|
|
8
9
|
|
|
@@ -44,12 +45,13 @@ interface AuthorizedUser extends User {
|
|
|
44
45
|
refreshToken: string;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/** Settings for `createAuthOptions`. Pass either `apiUrl` or `http`. */
|
|
47
49
|
export type AuthOptionsSettings = {
|
|
48
50
|
/** The server environment, usually `process.env`. It holds the API JWT secret. */
|
|
49
51
|
env: Record<string, string | undefined>;
|
|
50
52
|
} & (
|
|
51
53
|
| {
|
|
52
|
-
/**
|
|
54
|
+
/** The base URL of the API, such as `https://api.example.com/api`. */
|
|
53
55
|
apiUrl: string;
|
|
54
56
|
http?: never;
|
|
55
57
|
}
|
|
@@ -60,7 +62,8 @@ export type AuthOptionsSettings = {
|
|
|
60
62
|
}
|
|
61
63
|
);
|
|
62
64
|
|
|
63
|
-
//
|
|
65
|
+
// How far ahead of expiry a refresh falls due, at most, so the token is still fresh when the user
|
|
66
|
+
// saves.
|
|
64
67
|
const MAX_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
65
68
|
|
|
66
69
|
// The soonest a refresh can be due. The floor stops a short-lived token from refreshing on every
|
|
@@ -100,6 +103,13 @@ function apiUnavailable(error: unknown): boolean {
|
|
|
100
103
|
return status === undefined || status === 502 || status === 503 || status === 504;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
// The sign-in error for a failed login. Only the API's lockout code is told apart from wrong
|
|
107
|
+
// credentials, because the API gives it whether the password was right or not.
|
|
108
|
+
function signInError(error: unknown): string {
|
|
109
|
+
if (apiUnavailable(error)) return SIGN_IN_ERRORS.unavailable;
|
|
110
|
+
return apiErrorCode(error) === "lockedOut" ? SIGN_IN_ERRORS.lockedOut : SIGN_IN_ERRORS.invalidCredentials;
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
// What a log line may contain. The error itself carries the request, which holds the password or
|
|
104
114
|
// tokens.
|
|
105
115
|
function failureShape(error: unknown) {
|
|
@@ -111,9 +121,9 @@ function failureShape(error: unknown) {
|
|
|
111
121
|
}
|
|
112
122
|
|
|
113
123
|
/**
|
|
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.
|
|
124
|
+
* Creates the next-auth options for email and password sign-in against the API. The API issues a
|
|
125
|
+
* short-lived access token and a refresh token that changes on every refresh. The session refreshes
|
|
126
|
+
* ahead of expiry, rides out temporary API failures, and ends seven days after sign-in.
|
|
117
127
|
*/
|
|
118
128
|
export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSettings): NextAuthOptions {
|
|
119
129
|
const { env } = settings;
|
|
@@ -240,9 +250,7 @@ export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSe
|
|
|
240
250
|
console.error("Sign-in failed", failureShape(error));
|
|
241
251
|
// No cause: next-auth logs this error, and the cause carries the password.
|
|
242
252
|
// eslint-disable-next-line preserve-caught-error
|
|
243
|
-
throw new Error(
|
|
244
|
-
apiUnavailable(error) ? SIGN_IN_ERRORS.unavailable : SIGN_IN_ERRORS.invalidCredentials,
|
|
245
|
-
);
|
|
253
|
+
throw new Error(signInError(error));
|
|
246
254
|
}
|
|
247
255
|
},
|
|
248
256
|
}),
|
|
@@ -1,49 +1,30 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { ArrowUpTrayIcon, TrashIcon } from "@heroicons/react/24/outline";
|
|
4
|
-
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
5
|
+
import { fieldErrorTexts } from "../api/fieldErrorText";
|
|
5
6
|
import { toast } from "../toast";
|
|
7
|
+
import { FieldError } from "../ui/field";
|
|
6
8
|
import { withDefaults, type TextOverrides } from "../ui/withDefaults";
|
|
7
9
|
import { useBranding } from "./BrandingProvider";
|
|
10
|
+
import { defaultBrandingManagerStrings, type BrandingManagerStrings, type BrandingValidationStrings } from "./brandingManagerStrings";
|
|
8
11
|
import { toDataUrl } from "./types";
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
title: string;
|
|
12
|
-
logo: string;
|
|
13
|
-
logoHint: string;
|
|
14
|
-
icon: string;
|
|
15
|
-
iconHint: string;
|
|
16
|
-
none: string;
|
|
17
|
-
choose: string;
|
|
18
|
-
remove: string;
|
|
19
|
-
save: string;
|
|
20
|
-
saving: string;
|
|
21
|
-
saved: string;
|
|
22
|
-
saveFailed: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const defaultBrandingManagerStrings: BrandingManagerStrings = {
|
|
26
|
-
title: "Branding",
|
|
27
|
-
logo: "Logo",
|
|
28
|
-
logoHint: "Shown in the navbar and footer. PNG/SVG, max 2 MB.",
|
|
29
|
-
icon: "Icon (favicon)",
|
|
30
|
-
iconHint: "Browser tab icon. Square PNG/ICO, max 2 MB.",
|
|
31
|
-
none: "none",
|
|
32
|
-
choose: "Choose",
|
|
33
|
-
remove: "Remove",
|
|
34
|
-
save: "Save branding",
|
|
35
|
-
saving: "Saving…",
|
|
36
|
-
saved: "Branding saved",
|
|
37
|
-
saveFailed: "Failed to save branding",
|
|
38
|
-
};
|
|
13
|
+
const IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif,image/x-icon,.ico";
|
|
39
14
|
|
|
15
|
+
/** Props for `BrandingManager`. */
|
|
40
16
|
export interface BrandingManagerProps {
|
|
41
17
|
/**
|
|
42
|
-
*
|
|
43
|
-
* to show the error's message, such as one from `formatApiError`.
|
|
18
|
+
* A function that sends the form: `logo` and `icon` files, and `removeLogo` and `removeIcon` set
|
|
19
|
+
* to `true`. Throw from it to show the error's message, such as one from `formatApiError`.
|
|
44
20
|
*/
|
|
45
21
|
save: (form: FormData) => Promise<unknown>;
|
|
46
|
-
|
|
22
|
+
/** The form's text. `validation` may name only some codes, the rest keep their defaults. */
|
|
23
|
+
strings?:
|
|
24
|
+
| (TextOverrides<Omit<BrandingManagerStrings, "validation">> & {
|
|
25
|
+
validation?: TextOverrides<BrandingValidationStrings> | undefined;
|
|
26
|
+
})
|
|
27
|
+
| undefined;
|
|
47
28
|
}
|
|
48
29
|
|
|
49
30
|
/** A preview is an object URL for the chosen file or a data URL of a raster image. Anything else shows none. */
|
|
@@ -57,11 +38,15 @@ interface SlotProps {
|
|
|
57
38
|
file: File | null;
|
|
58
39
|
onPick: (file: File | null) => void;
|
|
59
40
|
onClear: () => void;
|
|
41
|
+
/** Why the API refused this slot's file in the last save, if it did. */
|
|
42
|
+
error: string | undefined;
|
|
60
43
|
text: BrandingManagerStrings;
|
|
61
44
|
}
|
|
62
45
|
|
|
63
|
-
function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProps) {
|
|
46
|
+
function Slot({ label, hint, currentUrl, file, onPick, onClear, error, text }: SlotProps) {
|
|
64
47
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
const errorId = `${useId()}-error`;
|
|
49
|
+
const describedBy = error ? errorId : undefined;
|
|
65
50
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
|
66
51
|
|
|
67
52
|
// An object URL is an outside resource with a lifetime, which is what an effect is for. Creating
|
|
@@ -93,14 +78,18 @@ function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProp
|
|
|
93
78
|
<input
|
|
94
79
|
ref={inputRef}
|
|
95
80
|
type="file"
|
|
96
|
-
|
|
81
|
+
// The types the API accepts, so the picker does not offer an SVG it would refuse.
|
|
82
|
+
accept={IMAGE_TYPES}
|
|
97
83
|
aria-label={label}
|
|
84
|
+
aria-invalid={error ? true : undefined}
|
|
85
|
+
aria-describedby={describedBy}
|
|
98
86
|
className="hidden"
|
|
99
87
|
onChange={e => onPick(e.target.files?.[0] ?? null)}
|
|
100
88
|
/>
|
|
101
89
|
<button
|
|
102
90
|
type="button"
|
|
103
91
|
onClick={() => inputRef.current?.click()}
|
|
92
|
+
aria-describedby={describedBy}
|
|
104
93
|
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 text-gray-700 font-medium px-3 py-2 text-sm hover:bg-gray-200"
|
|
105
94
|
>
|
|
106
95
|
<ArrowUpTrayIcon className="h-4 w-4" /> {text.choose}
|
|
@@ -112,19 +101,24 @@ function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProp
|
|
|
112
101
|
)}
|
|
113
102
|
</div>
|
|
114
103
|
<p className="mt-1 text-xs text-gray-500">{hint}</p>
|
|
104
|
+
<FieldError id={errorId} error={error} />
|
|
115
105
|
</div>
|
|
116
106
|
);
|
|
117
107
|
}
|
|
118
108
|
|
|
119
109
|
/** The admin form for uploading or removing the logo and the favicon. Needs a `BrandingProvider` above it. */
|
|
120
110
|
export function BrandingManager({ save, strings }: BrandingManagerProps) {
|
|
121
|
-
const
|
|
111
|
+
const { validation: validationOverrides, ...textOverrides } = strings ?? {};
|
|
112
|
+
const text = withDefaults(defaultBrandingManagerStrings, textOverrides);
|
|
113
|
+
const validation = withDefaults(defaultBrandingManagerStrings.validation, validationOverrides);
|
|
122
114
|
const { branding, refresh } = useBranding();
|
|
123
115
|
const [logo, setLogo] = useState<File | null>(null);
|
|
124
116
|
const [icon, setIcon] = useState<File | null>(null);
|
|
125
117
|
const [removeLogo, setRemoveLogo] = useState(false);
|
|
126
118
|
const [removeIcon, setRemoveIcon] = useState(false);
|
|
127
119
|
const [saving, setSaving] = useState(false);
|
|
120
|
+
// The texts for the files the API refused in the last save, keyed by `logo` and `icon`.
|
|
121
|
+
const [refusals, setRefusals] = useState<Record<string, string[]>>({});
|
|
128
122
|
|
|
129
123
|
const currentLogo = removeLogo ? null : toDataUrl(branding.logoData, branding.logoContentType);
|
|
130
124
|
const currentIcon = removeIcon ? null : toDataUrl(branding.iconData, branding.iconContentType);
|
|
@@ -142,10 +136,14 @@ export function BrandingManager({ save, strings }: BrandingManagerProps) {
|
|
|
142
136
|
setIcon(null);
|
|
143
137
|
setRemoveLogo(false);
|
|
144
138
|
setRemoveIcon(false);
|
|
139
|
+
setRefusals({});
|
|
145
140
|
refresh();
|
|
146
141
|
toast.success(text.saved);
|
|
147
142
|
} catch (err) {
|
|
148
|
-
|
|
143
|
+
const texts = fieldErrorTexts(err, validation);
|
|
144
|
+
setRefusals(texts);
|
|
145
|
+
const first = Object.values(texts)[0]?.[0];
|
|
146
|
+
toast.error(first ?? (err instanceof Error && err.message ? err.message : text.saveFailed));
|
|
149
147
|
} finally {
|
|
150
148
|
setSaving(false);
|
|
151
149
|
}
|
|
@@ -161,6 +159,7 @@ export function BrandingManager({ save, strings }: BrandingManagerProps) {
|
|
|
161
159
|
file={logo}
|
|
162
160
|
onPick={f => { setLogo(f); setRemoveLogo(false); }}
|
|
163
161
|
onClear={() => { setLogo(null); setRemoveLogo(true); }}
|
|
162
|
+
error={refusals.logo?.join(" ")}
|
|
164
163
|
text={text}
|
|
165
164
|
/>
|
|
166
165
|
<Slot
|
|
@@ -170,6 +169,7 @@ export function BrandingManager({ save, strings }: BrandingManagerProps) {
|
|
|
170
169
|
file={icon}
|
|
171
170
|
onPick={f => { setIcon(f); setRemoveIcon(false); }}
|
|
172
171
|
onClear={() => { setIcon(null); setRemoveIcon(true); }}
|
|
172
|
+
error={refusals.icon?.join(" ")}
|
|
173
173
|
text={text}
|
|
174
174
|
/>
|
|
175
175
|
<button
|
|
@@ -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.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Plain values, with no "use client", so a server component can import them.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The branding form's text for each refusal code of SjolystInnovation.AppKit.SiteSettings that a
|
|
5
|
+
* chosen file can get. The form shows the API's own message for a code that is not here.
|
|
6
|
+
*/
|
|
7
|
+
export interface BrandingValidationStrings {
|
|
8
|
+
/** The file is not a PNG, JPEG, WebP, GIF, or ICO image. */
|
|
9
|
+
invalidImage: string;
|
|
10
|
+
/** The file is larger than 2 MB. */
|
|
11
|
+
imageTooLarge: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The branding form's text. Pass the app's own language. The defaults are English. */
|
|
15
|
+
export interface BrandingManagerStrings {
|
|
16
|
+
title: string;
|
|
17
|
+
logo: string;
|
|
18
|
+
logoHint: string;
|
|
19
|
+
icon: string;
|
|
20
|
+
iconHint: string;
|
|
21
|
+
none: string;
|
|
22
|
+
choose: string;
|
|
23
|
+
remove: string;
|
|
24
|
+
save: string;
|
|
25
|
+
saving: string;
|
|
26
|
+
saved: string;
|
|
27
|
+
saveFailed: string;
|
|
28
|
+
/** The refusal texts, by code. Any code left out keeps its default. */
|
|
29
|
+
validation: BrandingValidationStrings;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const defaultBrandingManagerStrings: BrandingManagerStrings = {
|
|
33
|
+
title: "Branding",
|
|
34
|
+
logo: "Logo",
|
|
35
|
+
logoHint: "Shown in the navbar and footer. PNG, JPEG, WebP, or GIF, max 2 MB.",
|
|
36
|
+
icon: "Icon (favicon)",
|
|
37
|
+
iconHint: "Browser tab icon. Square PNG or ICO, max 2 MB.",
|
|
38
|
+
none: "none",
|
|
39
|
+
choose: "Choose",
|
|
40
|
+
remove: "Remove",
|
|
41
|
+
save: "Save branding",
|
|
42
|
+
saving: "Saving…",
|
|
43
|
+
saved: "Branding saved",
|
|
44
|
+
saveFailed: "Failed to save branding",
|
|
45
|
+
validation: {
|
|
46
|
+
invalidImage: "The image must be a PNG, JPEG, WebP, GIF or ICO file of at most 2 MB.",
|
|
47
|
+
imageTooLarge: "The image must be a PNG, JPEG, WebP, GIF or ICO file of at most 2 MB.",
|
|
48
|
+
},
|
|
49
|
+
};
|
package/src/branding/index.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
+
export { BrandingManager, type BrandingManagerProps } from "./BrandingManager";
|
|
1
2
|
export {
|
|
2
|
-
BrandingManager,
|
|
3
3
|
defaultBrandingManagerStrings,
|
|
4
|
-
type BrandingManagerProps,
|
|
5
4
|
type BrandingManagerStrings,
|
|
6
|
-
|
|
5
|
+
type BrandingValidationStrings,
|
|
6
|
+
} from "./brandingManagerStrings";
|
|
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,
|