@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.
Files changed (99) hide show
  1. package/README.md +1287 -860
  2. package/package.json +21 -4
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +93 -37
  5. package/src/admin/CompanySettingsForm.tsx +35 -14
  6. package/src/admin/EmailStatsPanel.tsx +76 -0
  7. package/src/admin/LegalPagesEditor.tsx +45 -17
  8. package/src/admin/LocaleTabs.tsx +4 -3
  9. package/src/admin/StatHistoryChart.tsx +4 -3
  10. package/src/admin/adminUsersPanelStrings.ts +70 -3
  11. package/src/admin/companySettingsFormStrings.ts +26 -0
  12. package/src/admin/emailStatsPanelStrings.ts +29 -0
  13. package/src/admin/index.ts +14 -5
  14. package/src/admin/legalPagesEditorStrings.ts +26 -1
  15. package/src/admin/statHistoryChartStrings.ts +3 -2
  16. package/src/api/apiErrorCode.ts +28 -0
  17. package/src/api/apiFieldErrors.ts +84 -13
  18. package/src/api/company.ts +4 -3
  19. package/src/api/createApiClient.ts +3 -2
  20. package/src/api/failureBody.ts +10 -0
  21. package/src/api/fieldErrorText.ts +32 -0
  22. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  23. package/src/api/index.ts +7 -2
  24. package/src/api/legalPage.ts +10 -0
  25. package/src/api/request.ts +14 -4
  26. package/src/api/revalidateTargets.ts +7 -5
  27. package/src/api/services.ts +53 -22
  28. package/src/auth/createAuthOptions.ts +16 -8
  29. package/src/branding/BrandingManager.tsx +37 -37
  30. package/src/branding/BrandingProvider.tsx +5 -3
  31. package/src/branding/brandingManagerStrings.ts +49 -0
  32. package/src/branding/index.ts +4 -5
  33. package/src/branding/server.ts +10 -9
  34. package/src/branding/types.ts +3 -3
  35. package/src/config.ts +10 -10
  36. package/src/forms/useFormDraft.ts +9 -7
  37. package/src/index.ts +1 -3
  38. package/src/legal/LegalPageView.tsx +5 -19
  39. package/src/legal/index.ts +4 -1
  40. package/src/legal/keys.ts +10 -0
  41. package/src/legal/legalPageViewStrings.ts +17 -0
  42. package/src/locales/nb.ts +330 -0
  43. package/src/markdown/Markdown.tsx +12 -10
  44. package/src/markdown/index.ts +1 -1
  45. package/src/next-auth.d.ts +11 -9
  46. package/src/next-config/headers.ts +96 -0
  47. package/src/next-config/index.ts +18 -107
  48. package/src/payments/PaymentReturn.tsx +9 -7
  49. package/src/payments/formatNok.ts +1 -1
  50. package/src/payments/usePollUntilFinal.ts +7 -5
  51. package/src/sections/SectionRenderer.tsx +275 -0
  52. package/src/sections/SectionsEditor.tsx +151 -0
  53. package/src/sections/SmartLink.tsx +42 -0
  54. package/src/sections/editors.tsx +311 -0
  55. package/src/sections/factory.ts +26 -0
  56. package/src/sections/index.ts +69 -0
  57. package/src/sections/sectionsEditorStrings.ts +108 -0
  58. package/src/sections/types.ts +107 -0
  59. package/src/seo/JsonLd.tsx +1 -1
  60. package/src/seo/environment.ts +3 -3
  61. package/src/seo/index.ts +1 -1
  62. package/src/seo/og/index.tsx +5 -4
  63. package/src/seo/schema.ts +6 -2
  64. package/src/seo/site.ts +49 -37
  65. package/src/seo/sitemap.ts +2 -2
  66. package/src/server/publicApi.ts +13 -9
  67. package/src/server/revalidateRoute.ts +10 -8
  68. package/src/session/expiry.ts +11 -6
  69. package/src/session/index.ts +0 -1
  70. package/src/session/react/AppSessionProvider.tsx +3 -2
  71. package/src/{ui → session/react}/CredentialsForm.tsx +17 -10
  72. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  73. package/src/session/react/ProtectedGate.tsx +3 -2
  74. package/src/session/react/RoleGate.tsx +4 -3
  75. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  76. package/src/{ui → session/react}/credentialsFormStrings.ts +4 -1
  77. package/src/session/react/index.ts +8 -2
  78. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  79. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  80. package/src/session/react/useSessionGate.ts +3 -3
  81. package/src/styles.css +2 -0
  82. package/src/testing/index.ts +5 -13
  83. package/src/ui/Alert.tsx +6 -4
  84. package/src/ui/ContentImage.tsx +12 -11
  85. package/src/ui/ErrorState.tsx +5 -17
  86. package/src/ui/NotFoundState.tsx +7 -17
  87. package/src/ui/PasswordInput.tsx +6 -5
  88. package/src/ui/ResetPasswordFlow.tsx +64 -19
  89. package/src/ui/TextArea.tsx +6 -4
  90. package/src/ui/TextInput.tsx +4 -2
  91. package/src/ui/Toggle.tsx +3 -3
  92. package/src/ui/errorStateStrings.ts +16 -0
  93. package/src/ui/field.tsx +5 -3
  94. package/src/ui/index.ts +10 -14
  95. package/src/ui/notFoundStateStrings.ts +14 -0
  96. package/src/ui/resetPasswordFlowStrings.ts +51 -7
  97. package/src/validation/fieldErrors.ts +4 -3
  98. package/src/validation/password.ts +5 -5
  99. 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
- /** Paths below each locale segment, such as `/builds/[slug]`. `""` is the locale's front page. */
6
+ /** The paths below each locale segment, such as `/builds/[slug]`. `""` is the locale's front page. */
7
7
  pages?: readonly string[] | undefined;
8
- /** Paths outside the locale segments, such as `/sitemap.xml`. */
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
- * The cache targets an app's admin edits purge, in one place for both sides: `targets` names them
20
- * for fetch tags, `paths` is what `createRevalidateRoute` purges for each, and `revalidate` asks the
21
- * route to purge one after an edit. A target with no paths is purged by its tag alone.
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[];
@@ -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
- * An axios instance for the API's anonymous endpoints, without the session's token. Like
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
- /** Runs after an admin change saves, such as to purge the pages that show it. */
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
- * Password reset and the signed-in user's account, on the auth and account endpoints of
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
- /** The admin's company settings on SjolystInnovation.AppKit.SiteSettings. */
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
- * The logo and favicon on SjolystInnovation.AppKit.SiteSettings. Reading is anonymous and answers
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` and `removeIcon`. */
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
- /** A legal page, as SjolystInnovation.AppKit.SiteSettings's legal endpoints return it. */
128
- export interface LegalPage<Key extends string = string> {
129
- key: Key;
130
- locale: string;
131
- title: string;
132
- bodyMarkdown: string;
133
- updatedAt: string;
129
+ /** Options for `createLegalService`. */
130
+ export interface LegalServiceOptions<Key extends string> {
131
+ /**
132
+ * The app's legal pages, the same keys as the API's `MapAppKitLegalPages` `Keys` option. Defaults
133
+ * to `LEGAL_PAGE_KEYS`. The service's key type comes from them.
134
+ */
135
+ keys?: readonly Key[] | undefined;
136
+ onSaved?: OnSaved | undefined;
134
137
  }
135
138
 
136
139
  /**
137
- * The terms, privacy and cookie pages on SjolystInnovation.AppKit.SiteSettings. Reading is anonymous
138
- * and answers null for a page that is missing or cannot be read, so a page shows its fallback.
140
+ * Creates the calls for the legal pages on SjolystInnovation.AppKit.SiteSettings, such as terms,
141
+ * privacy, and cookies. Reading is anonymous and returns null for a page that is missing or cannot be
142
+ * read, so a page shows its fallback. `keys` on the result holds the app's pages, for the editor and
143
+ * the sitemap.
139
144
  */
140
- export function createLegalService<Key extends string = "terms" | "privacy" | "cookies">(
145
+ export function createLegalService<const Key extends string = DefaultLegalPageKey>(
141
146
  api: AxiosInstance,
142
147
  publicApi: AxiosInstance,
143
- options: { onSaved?: OnSaved } = {},
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, locale: string, title: string, bodyMarkdown: string) {
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
- /** User administration on SjolystInnovation.AppKit.Auth: the list, roles, invites and deletion. */
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, firstName: string, lastName: string, role: 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
- /** Base URL of the API, such as `https://api.example.com/api`. */
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
- // Refresh ahead of expiry, so the token is still fresh when the user saves.
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 short-lived
115
- * access token and a refresh token that changes on every refresh. The session refreshes ahead of
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
- export interface BrandingManagerStrings {
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
- * Sends the form: `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw
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
- strings?: TextOverrides<BrandingManagerStrings> | undefined;
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
- accept="image/*"
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 text = withDefaults(defaultBrandingManagerStrings, strings);
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
- toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
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
- /** The branding from the nearest `BrandingProvider`. Empty outside one. */
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
- /** Loads the branding, such as a GET of `/branding`. A failure keeps the branding it has. */
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
- /** Holds the logo and favicon for the components below it, and loads them again every five minutes. */
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
+ };
@@ -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
- } from "./BrandingManager";
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 { toDataUrl, type Branding } from "./types";
13
+ export type { Branding } from "./types";
@@ -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
- /** Loads the branding, such as a cached GET of `/branding`. Throws when the API cannot be reached. */
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
- /** Its content type, such as `image/png` or `image/svg+xml`. */
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
- * The response for an `app/icon.ts` route: the favicon set in admin settings when it is a PNG,
16
- * JPEG, WebP, GIF or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox CSP
17
- * that a CSP header from the app's `next.config` replaces.
18
- * When the branding cannot be loaded it answers 503 with `no-store`, not the
19
- * bundled icon, since a browser would cache that in place of the one set in settings. The route
20
- * needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment.
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
- * A short digest of icon bytes, for a URL that changes when the icon does, such as
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
  */
@@ -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
- /** Base64 image bytes. */
3
+ /** The logo's image bytes, in base64. */
4
4
  logoData?: string | null | undefined;
5
5
  logoContentType?: string | null | undefined;
6
- /** Base64 image bytes. */
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
- /** A `data:` URL for base64 image bytes, or null when either part is missing. */
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
- * Name of the environment variable holding the API JWT secret, such as API_JWT_SECRET. Apps that
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
- * Where an expired or rejected session sends the browser. An app with a locale prefix uses
13
- * something like "/en/login". An app without one uses "/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
- * 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
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
- * 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:".
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 entry point needs no Node
61
- * types and stays safe to import in browser code.
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,