@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
@@ -4,10 +4,11 @@ import { useRouter } from "next/navigation";
4
4
  import { useEffect, type ReactNode } from "react";
5
5
  import { useSessionGate } from "./useSessionGate";
6
6
 
7
+ /** Props for `ProtectedGate`. */
7
8
  export interface ProtectedGateProps {
8
- /** Where a signed-out visitor, or a session dead on arrival, is sent. */
9
+ /** The path a signed-out visitor, or a session dead on arrival, is sent to. */
9
10
  loginHref: string;
10
- /** What shows while the page may not render yet. */
11
+ /** The content shown while the page may not render yet. */
11
12
  fallback?: ReactNode | undefined;
12
13
  children: ReactNode;
13
14
  }
@@ -7,23 +7,24 @@ import { useSessionGate } from "./useSessionGate";
7
7
  interface RoleGateBase {
8
8
  /** The role the page needs, as it appears in `session.user.roles`. */
9
9
  role: string;
10
- /** What shows while the session or its roles are not known yet. */
10
+ /** The content shown while the session or its roles are not known yet. */
11
11
  fallback?: ReactNode | undefined;
12
12
  children: ReactNode;
13
13
  }
14
14
 
15
- /** Shows `denied` to a signed-in user without the role. */
15
+ /** The props that show `denied` to a signed-in user without the role. */
16
16
  interface RoleGateWithDenied extends RoleGateBase {
17
17
  denied: ReactNode;
18
18
  deniedHref?: never;
19
19
  }
20
20
 
21
- /** Sends a signed-in user without the role to `deniedHref`. */
21
+ /** The props that send a signed-in user without the role to `deniedHref`. */
22
22
  interface RoleGateWithDeniedHref extends RoleGateBase {
23
23
  deniedHref: string;
24
24
  denied?: never;
25
25
  }
26
26
 
27
+ /** Props for `RoleGate`. Pass either `denied` or `deniedHref`. */
27
28
  export type RoleGateProps = RoleGateWithDenied | RoleGateWithDeniedHref;
28
29
 
29
30
  /**
@@ -3,8 +3,8 @@
3
3
  import { getSession, signOut, useSession } from "next-auth/react";
4
4
  import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { Alert } from "../../ui/Alert";
6
- import { CredentialsForm } from "../../ui/CredentialsForm";
7
- import { SignInRejected, type CredentialsFormStrings } from "../../ui/credentialsFormStrings";
6
+ import { CredentialsForm } from "./CredentialsForm";
7
+ import { SignInRejected, type CredentialsFormStrings } from "./credentialsFormStrings";
8
8
  import { withDefaults, type TextOverrides } from "../../ui/withDefaults";
9
9
  import {
10
10
  closeSessionPrompt,
@@ -34,10 +34,11 @@ function msUntilNextChange(remaining: number): number | null {
34
34
  }
35
35
 
36
36
 
37
+ /** Props for `SessionExpiryGuard`. */
37
38
  export interface SessionExpiryGuardProps {
38
- /** Where a sign-in as a different user is sent after it is signed out. */
39
+ /** The path a sign-in as a different user is sent to after it is signed out. */
39
40
  loginHref: string;
40
- /** Runs after the prompt signs the user back in, such as to show a toast. */
41
+ /** A function that runs after the prompt signs the user back in, such as to show a toast. */
41
42
  onRestored?: (() => void) | undefined;
42
43
  strings?: TextOverrides<SessionExpiryGuardStrings> | undefined;
43
44
  formStrings?: TextOverrides<CredentialsFormStrings> | undefined;
@@ -1,6 +1,6 @@
1
1
  // Plain values, with no "use client", so a server component can import them.
2
2
 
3
- /** Thrown from `onSignedIn` to refuse a sign-in. Its message is shown in the form. */
3
+ /** Thrown inside app-kit from `onSignedIn` to refuse a sign-in. The form shows its message. Not exported. */
4
4
  export class SignInRejected extends Error {}
5
5
 
6
6
  /** The form's text. Pass the app's own language. The defaults are English. */
@@ -10,6 +10,8 @@ export interface CredentialsFormStrings {
10
10
  signIn: string;
11
11
  signingIn: string;
12
12
  invalidCredentials: string;
13
+ /** The text for a login the API has locked after too many failed sign-ins. */
14
+ lockedOut: string;
13
15
  signInUnavailable: string;
14
16
  somethingWentWrong: string;
15
17
  showPassword: string;
@@ -22,6 +24,7 @@ export const defaultCredentialsFormStrings: CredentialsFormStrings = {
22
24
  signIn: "Sign in",
23
25
  signingIn: "Signing in…",
24
26
  invalidCredentials: "Invalid email or password.",
27
+ lockedOut: "Too many failed attempts. This login is temporarily locked, try again later.",
25
28
  signInUnavailable: "Sign-in is unavailable right now. Try again shortly.",
26
29
  somethingWentWrong: "Something went wrong",
27
30
  showPassword: "Show password",
@@ -4,9 +4,15 @@ export { SessionExpiryGuard } from "./SessionExpiryGuard";
4
4
  export type { SessionExpiryGuardProps } from "./SessionExpiryGuard";
5
5
  export { defaultSessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
6
6
  export type { SessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
7
- export { useSessionGate } from "./useSessionGate";
8
- export type { SessionGate } from "./useSessionGate";
9
7
  export { AppSessionProvider } from "./AppSessionProvider";
10
8
  export type { AppSessionProviderProps } from "./AppSessionProvider";
11
9
  export { RoleGate } from "./RoleGate";
12
10
  export type { RoleGateProps } from "./RoleGate";
11
+ export { CredentialsForm } from "./CredentialsForm";
12
+ export type { CredentialsFormProps } from "./CredentialsForm";
13
+ export { defaultCredentialsFormStrings } from "./credentialsFormStrings";
14
+ export type { CredentialsFormStrings } from "./credentialsFormStrings";
15
+ export { LoginPage } from "./LoginPage";
16
+ export type { LoginPageProps } from "./LoginPage";
17
+ export { defaultLoginPageStrings } from "./loginPageStrings";
18
+ export type { LoginPageStrings } from "./loginPageStrings";
@@ -4,7 +4,7 @@
4
4
  export interface LoginPageStrings {
5
5
  title: string;
6
6
  forgotPassword: string;
7
- /** Shown before the register link, such as "No account?". Null leaves it out. */
7
+ /** The text shown before the register link, such as "No account?". Null leaves it out. */
8
8
  registerPrompt: string | null;
9
9
  register: string;
10
10
  }
@@ -7,7 +7,7 @@
7
7
  export interface SessionExpiryGuardStrings {
8
8
  expiredTitle: string;
9
9
  expiredBody: string;
10
- /** Every `{minutes}` is replaced with the minutes left. */
10
+ /** The warning before expiry. Every `{minutes}` is replaced with the minutes left. */
11
11
  expiringSoon: string;
12
12
  reSignIn: string;
13
13
  notRestored: string;
@@ -6,7 +6,7 @@ import { useState, useSyncExternalStore } from "react";
6
6
  import { getSessionPromptOpen, isTerminalSessionError, subscribeSessionPrompt } from "../expiry";
7
7
 
8
8
  /**
9
- * Whether a protected page may render, and whether it must stay rendered.
9
+ * Checks whether a protected page may render, and whether it must stay rendered.
10
10
  *
11
11
  * Every gate on the way down should use this, so they all agree. A nested layout that decides on
12
12
  * its own can blank the page. That undoes the in-place recovery the re-sign-in prompt exists for.
@@ -44,8 +44,8 @@ export function useSessionGate() {
44
44
  wasUsable,
45
45
  recovering,
46
46
  /**
47
- * The one answer every gate on the way down uses. A layout that decides this again can blank
48
- * the page that the gate above it chose to keep.
47
+ * Whether the page may render. Every gate on the way down uses this one answer. A layout that
48
+ * decides it again can blank the page that the gate above it chose to keep.
49
49
  */
50
50
  mayRender:
51
51
  recovering ||
package/src/styles.css CHANGED
@@ -90,6 +90,8 @@
90
90
  --color-admin-tab-indicator: var(--color-primary);
91
91
  --color-chart-grid: var(--color-gray-200);
92
92
  --color-chart-label: var(--color-gray-400);
93
+ --color-admin-stat-label: var(--color-gray-500);
94
+ --color-admin-stat-value: var(--color-gray-700);
93
95
 
94
96
  --radius-button: var(--radius-lg);
95
97
  --color-button-primary-bg: var(--color-primary);
@@ -20,7 +20,7 @@ export const auth = {
20
20
  update: vi.fn(),
21
21
  };
22
22
 
23
- /** A signed-in session with `roles`. Defaults to an admin. */
23
+ /** Builds a signed-in session with `roles`. Defaults to an admin. */
24
24
  export function makeSession({ roles = ["Admin"], ...overrides }: Partial<Session> & { roles?: string[] } = {}): Session {
25
25
  return {
26
26
  user: { id: "1", name: "admin", email: "a@b.no", roles },
@@ -40,7 +40,7 @@ export function setSession(session: Session | null | "loading"): void {
40
40
  : { data: session, status: "authenticated" };
41
41
  }
42
42
 
43
- /** The `next-auth/react` module, reading `auth`. */
43
+ /** Builds the `next-auth/react` module, reading `auth`. */
44
44
  export function nextAuthMock() {
45
45
  return {
46
46
  useSession: () => ({ ...auth.state, update: (...args: unknown[]) => auth.update(...args) }),
@@ -58,7 +58,7 @@ export const navigation = {
58
58
  router: { push: vi.fn(), replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), forward: vi.fn(), prefetch: vi.fn() },
59
59
  };
60
60
 
61
- /** The `next/navigation` hooks, reading `navigation`. */
61
+ /** Builds the `next/navigation` hooks, reading `navigation`. */
62
62
  export function nextNavigationMock() {
63
63
  return {
64
64
  useRouter: () => navigation.router,
@@ -79,7 +79,7 @@ export const toastSpies = {
79
79
  dismiss: vi.fn(),
80
80
  };
81
81
 
82
- /** The `sonner` module, or `@sjolystinnovation/app-kit/toast`. A bare `toast(text)` goes to `toastSpies.message`. */
82
+ /** Builds the `sonner` module, or `@sjolystinnovation/app-kit/toast`. A bare `toast(text)` goes to `toastSpies.message`. */
83
83
  export function toastMock() {
84
84
  const toast = Object.assign((...args: unknown[]) => toastSpies.message(...args), {
85
85
  success: (...args: unknown[]) => toastSpies.success(...args),
@@ -94,20 +94,12 @@ export function toastMock() {
94
94
  return { toast, Toaster: () => null };
95
95
  }
96
96
 
97
- /** The HTTP calls of an app's axios instance as spies. */
98
- export const axiosSpies = { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() };
99
-
100
- /** A module whose default export is an axios instance, such as the app's `services/axiosInstance`. */
101
- export function axiosInstanceMock() {
102
- return { default: axiosSpies };
103
- }
104
-
105
97
  /** Signs out, goes back to `/`, and clears every spy's calls and answers. */
106
98
  export function resetTestMocks(): void {
107
99
  setSession(null);
108
100
  navigation.pathname = "/";
109
101
  navigation.searchParams = new URLSearchParams();
110
- for (const spy of [auth.signIn, auth.signOut, auth.getSession, auth.update, ...Object.values(navigation.router), ...Object.values(toastSpies), ...Object.values(axiosSpies)]) {
102
+ for (const spy of [auth.signIn, auth.signOut, auth.getSession, auth.update, ...Object.values(navigation.router), ...Object.values(toastSpies)]) {
111
103
  spy.mockReset();
112
104
  }
113
105
  }
package/src/ui/Alert.tsx CHANGED
@@ -1,16 +1,17 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { cx } from "./cx";
3
3
 
4
+ /** Props for `Alert`. */
4
5
  export interface AlertProps {
5
6
  variant?: "error" | "success" | "info" | "warning";
6
7
  /**
7
- * Defaults to alert, which a screen reader interrupts for. A screen reader announces status
8
- * without interrupting, on every change of its text. Use none when a separate live region
9
- * announces the text.
8
+ * The ARIA role. A screen reader interrupts for `alert`. It announces `status` without
9
+ * interrupting, on every change of its text. Use `none` when a separate live region announces the
10
+ * text. Defaults to `alert`.
10
11
  */
11
12
  role?: "alert" | "status" | "none";
12
13
  /**
13
- * Classes for placing the alert, such as a margin. Restyle it through the theme variables. A
14
+ * The classes for placing the alert, such as a margin. Restyle it through the theme variables. A
14
15
  * class that clashes with the alert's own wins or loses by the order Tailwind emits them in.
15
16
  */
16
17
  className?: string;
@@ -24,6 +25,7 @@ const styles: Record<NonNullable<AlertProps["variant"]>, string> = {
24
25
  warning: "bg-alert-warning-bg border-alert-warning-border text-alert-warning-text",
25
26
  };
26
27
 
28
+ /** Renders a message in an error, success, info, or warning box. */
27
29
  export function Alert({ variant = "info", role = "alert", className, children }: AlertProps) {
28
30
  return (
29
31
  <div className={cx("rounded-alert border px-3 py-2 text-sm", styles[variant], className)} role={role}>
@@ -1,42 +1,43 @@
1
1
  /** The widths the API serves an uploaded image at, as webp. */
2
2
  export const CONTENT_IMAGE_WIDTHS: readonly number[] = [384, 640, 768, 1024, 1366, 1600];
3
3
 
4
- /** Where the browser fetches an uploaded image. Site-relative, through the app's content images rewrite. */
4
+ /** Builds the site-relative path where the browser fetches an uploaded image, through the app's content images rewrite. */
5
5
  export function contentImagePath(id: string, base = "/content-images"): string {
6
6
  return `${base}/${encodeURIComponent(id)}`;
7
7
  }
8
8
 
9
9
  /**
10
- * The `srcset` for an uploaded image. The API serves the nearest webp at or above the requested
11
- * width, or the original when the browser does not accept webp.
10
+ * Builds the `srcset` for an uploaded image. The API serves the nearest webp at or above the
11
+ * requested width, or the original when the browser does not accept webp.
12
12
  */
13
13
  export function contentImageSrcSet(id: string, widths: readonly number[] = CONTENT_IMAGE_WIDTHS, base?: string): string {
14
14
  return widths.map(w => `${contentImagePath(id, base)}?w=${w} ${w}w`).join(", ");
15
15
  }
16
16
 
17
+ /** Props for `ContentImage`. */
17
18
  export interface ContentImageProps {
18
- /** Uploaded image id. Null falls back to `fallbackSrc`, which is served as it is. */
19
+ /** The uploaded image's id. Null falls back to `fallbackSrc`, which is served as it is. */
19
20
  imageId: string | null;
20
- /** Empty for an image that carries no meaning of its own, such as a decorative backdrop. */
21
+ /** The alt text. Leave it empty for an image that carries no meaning of its own, such as a decorative backdrop. */
21
22
  alt: string;
22
- /** Widths the image is shown at. Required, since a browser assumes 100vw without it. */
23
+ /** The widths the image is shown at. It is required, since a browser assumes 100vw without it. */
23
24
  sizes: string;
24
25
  /** A static path for when there is no uploaded image. */
25
26
  fallbackSrc?: string | undefined;
26
27
  className?: string | undefined;
27
- /** Intrinsic size, to reserve space. Only needed where the layout fixes no ratio. */
28
+ /** The intrinsic size, to reserve space. You only need it where the layout fixes no ratio. */
28
29
  width?: number | undefined;
29
30
  height?: number | undefined;
30
- /** Set on the one image visible without scrolling. It loads eagerly, at high priority. */
31
+ /** Whether this is the one image visible without scrolling. That image loads eagerly, at high priority. */
31
32
  priority?: boolean | undefined;
32
33
  "aria-hidden"?: boolean | undefined;
33
- /** Path the content images rewrite serves, `/content-images` by default. */
34
+ /** The path the content images rewrite serves. Defaults to `/content-images`. */
34
35
  base?: string | undefined;
35
- /** Widths in the `srcset`. Defaults to the widths the API serves. */
36
+ /** The widths in the `srcset`. Defaults to the widths the API serves. */
36
37
  widths?: readonly number[] | undefined;
37
38
  }
38
39
 
39
- /** An image from the content API, served responsively from the `sizes` a call site declares. */
40
+ /** Renders an image from the content API, served responsively from the `sizes` a call site declares. */
40
41
  export function ContentImage({
41
42
  imageId,
42
43
  alt,
@@ -2,33 +2,21 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { cx } from "./cx";
5
+ import { defaultErrorStateStrings, type ErrorStateStrings } from "./errorStateStrings";
5
6
  import { withDefaults, type TextOverrides } from "./withDefaults";
6
7
 
7
- export interface ErrorStateStrings {
8
- title: string;
9
- body: string;
10
- tryAgain: string;
11
- home: string;
12
- }
13
-
14
- export const defaultErrorStateStrings: ErrorStateStrings = {
15
- title: "Temporarily unavailable",
16
- body: "We can't reach the service right now. Please try again shortly.",
17
- tryAgain: "Try again",
18
- home: "Go to the front page",
19
- };
20
-
8
+ /** Props for `ErrorState`. */
21
9
  export interface ErrorStateProps {
22
10
  /** The `reset` Next passes to an `error.tsx`. It renders the page again. */
23
11
  reset: () => void;
24
- /** Where the front page link goes, such as `/` or `/en`. */
12
+ /** The front page link's target, such as `/` or `/en`. */
25
13
  homeHref: string;
26
14
  strings?: TextOverrides<ErrorStateStrings> | undefined;
27
- /** Classes for placing it, on the outer element. */
15
+ /** The classes for placing the component, on its outer element. */
28
16
  className?: string | undefined;
29
17
  }
30
18
 
31
- /** What an `error.tsx` shows when a page cannot be rendered. The response is still a 500. */
19
+ /** Renders the content of an `error.tsx` when a page cannot be rendered. The response is still a 500. */
32
20
  export function ErrorState({ reset, homeHref, strings, className }: ErrorStateProps) {
33
21
  const text = withDefaults(defaultErrorStateStrings, strings);
34
22
  return (
@@ -1,30 +1,20 @@
1
1
  import Link from "next/link";
2
2
  import { cx } from "./cx";
3
+ import { defaultNotFoundStateStrings, type NotFoundStateStrings } from "./notFoundStateStrings";
3
4
  import { withDefaults, type TextOverrides } from "./withDefaults";
4
5
 
5
- export interface NotFoundStrings {
6
- code: string;
7
- body: string;
8
- home: string;
9
- }
10
-
11
- export const defaultNotFoundStrings: NotFoundStrings = {
12
- code: "404",
13
- body: "This page does not exist.",
14
- home: "Go home",
15
- };
16
-
6
+ /** Props for `NotFoundState`. */
17
7
  export interface NotFoundStateProps {
18
- /** Where the home link goes, such as `/` or `/en`. */
8
+ /** The home link's target, such as `/` or `/en`. */
19
9
  homeHref: string;
20
- strings?: TextOverrides<NotFoundStrings> | undefined;
21
- /** Classes for placing it, on the outer element. */
10
+ strings?: TextOverrides<NotFoundStateStrings> | undefined;
11
+ /** The classes for placing the component, on its outer element. */
22
12
  className?: string | undefined;
23
13
  }
24
14
 
25
- /** What a `not-found.tsx` shows. The message is the page's heading. */
15
+ /** Renders the content of a `not-found.tsx`. The message is the page's heading. */
26
16
  export function NotFoundState({ homeHref, strings, className }: NotFoundStateProps) {
27
- const text = withDefaults(defaultNotFoundStrings, strings);
17
+ const text = withDefaults(defaultNotFoundStateStrings, strings);
28
18
  return (
29
19
  <div className={cx("min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4 text-center", className)}>
30
20
  <span className="text-6xl font-black font-state-code text-state-code">{text.code}</span>
@@ -4,21 +4,22 @@ import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
4
4
  import { useState, type InputHTMLAttributes } from "react";
5
5
  import { FieldError, FieldLabel, inputClass, useField } from "./field";
6
6
 
7
- /** The input's type is left out: the component switches it to show and hide the value. */
7
+ /** Props for `PasswordInput`. They leave out the input's `type`, since the component switches it to show and hide the value. */
8
8
  export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
9
9
  label: string;
10
10
  error?: string | undefined;
11
- /** Accessible name of the toggle while the password is hidden. */
11
+ /** The accessible name of the toggle while the password is hidden. */
12
12
  showPasswordLabel?: string | undefined;
13
- /** Accessible name of the toggle while the password is visible. */
13
+ /** The accessible name of the toggle while the password is visible. */
14
14
  hidePasswordLabel?: string | undefined;
15
15
  /**
16
- * Classes for placing the field, such as a margin. They go on the outer element, which holds the
17
- * label, the input and the error. Restyle the field through the theme variables.
16
+ * The classes for placing the field, such as a margin. They go on the outer element, which holds
17
+ * the label, the input, and the error. Restyle the field through the theme variables.
18
18
  */
19
19
  className?: string;
20
20
  }
21
21
 
22
+ /** Renders a labeled password field with a button that shows and hides the value. */
22
23
  export function PasswordInput({
23
24
  label,
24
25
  error,
@@ -2,38 +2,56 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { useState, type FormEvent } from "react";
5
+ import { errorText, fieldErrorTexts } from "../api/fieldErrorText";
5
6
  import { passwordProblems, type PasswordStrings } from "../validation/passwordRules";
6
7
  import { Alert } from "./Alert";
7
8
  import { authLinkClassName, authPageClassName, authTitleClassName } from "./authClasses";
8
9
  import { PasswordInput } from "./PasswordInput";
9
- import { defaultResetPasswordFlowStrings, type ResetPasswordFlowStrings } from "./resetPasswordFlowStrings";
10
+ import {
11
+ defaultResetPasswordFlowStrings,
12
+ type ResetPasswordErrorStrings,
13
+ type ResetPasswordFlowStrings,
14
+ type ResetPasswordValidationStrings,
15
+ } from "./resetPasswordFlowStrings";
10
16
  import { TextInput } from "./TextInput";
11
17
  import { withDefaults, type TextOverrides } from "./withDefaults";
12
18
 
19
+ /** Props for `ResetPasswordFlow`. */
13
20
  export interface ResetPasswordFlowProps {
14
- /** Asks the API to email a reset code, such as `createUserService`'s `requestPasswordReset`. */
21
+ /** A function that asks the API to email a reset code, such as `createUserService`'s `requestPasswordReset`. */
15
22
  requestReset: (data: { email: string }) => Promise<unknown>;
16
- /** Sets the new password with the code, such as `createUserService`'s `resetPassword`. */
23
+ /** A function that sets the new password with the code, such as `createUserService`'s `resetPassword`. */
17
24
  reset: (data: { email: string; code: string; newPassword: string }) => Promise<{ message?: string } | unknown>;
18
- /** Where the sign-in links go. */
25
+ /** The path the sign-in links go to. */
19
26
  loginHref: string;
20
- /** From the emailed link. A code opens the flow on its last step. */
27
+ /** The email from the emailed link. */
21
28
  initialEmail?: string | undefined;
29
+ /** The code from the emailed link. A code opens the flow on its last step. */
22
30
  initialCode?: string | undefined;
23
- strings?: TextOverrides<ResetPasswordFlowStrings> | undefined;
24
- /** Replaces the page's outer spacing, such as when the flow sits inside the app's own card. */
31
+ /** The flow's text. `validation` and `errors` may name only some codes, the rest keep their defaults. */
32
+ strings?:
33
+ | (TextOverrides<Omit<ResetPasswordFlowStrings, "validation" | "errors">> & {
34
+ validation?: TextOverrides<ResetPasswordValidationStrings> | undefined;
35
+ errors?: TextOverrides<ResetPasswordErrorStrings> | undefined;
36
+ })
37
+ | undefined;
38
+ /** The classes that replace the page's outer spacing, such as when the flow sits inside the app's own card. */
25
39
  className?: string | undefined;
26
40
  /** The password rule messages, for the app's own language. */
27
41
  passwordStrings?: TextOverrides<PasswordStrings> | undefined;
28
42
  }
29
43
 
44
+ // The fields each step shows. A field error for any other field goes in the message above the form.
45
+ const STEP_FIELDS = { 1: ["email"], 2: ["email", "code", "newPassword"] } as const;
46
+
30
47
  const button =
31
48
  "w-full rounded-button bg-button-primary-bg text-button-primary-text font-semibold py-2.5 hover:brightness-95 disabled:opacity-60 transition";
32
49
 
33
50
  /**
34
- * Password reset in two steps: the email asks for a code, then the code and a new password set it. The
35
- * new password is checked against the API's rules before it is sent. A link with the email and code,
36
- * such as an invite, opens on the second step.
51
+ * Renders password reset in two steps: the email asks for a code, then the code and a new password
52
+ * set it. The flow checks the new password against the API's rules before it sends it. A link with
53
+ * the email and code, such as an invite, opens on the second step. The API's refusals show in the
54
+ * app's language through `validation` and `errors`, each field's under its field.
37
55
  */
38
56
  export function ResetPasswordFlow({
39
57
  requestReset,
@@ -45,26 +63,45 @@ export function ResetPasswordFlow({
45
63
  className = authPageClassName,
46
64
  passwordStrings,
47
65
  }: ResetPasswordFlowProps) {
48
- const text = withDefaults(defaultResetPasswordFlowStrings, strings);
66
+ const { validation: validationOverrides, errors: errorOverrides, ...textOverrides } = strings ?? {};
67
+ const text = withDefaults(defaultResetPasswordFlowStrings, textOverrides);
68
+ const validation = withDefaults(defaultResetPasswordFlowStrings.validation, validationOverrides);
69
+ const errors = withDefaults(defaultResetPasswordFlowStrings.errors, errorOverrides);
49
70
  const [step, setStep] = useState<1 | 2>(initialCode ? 2 : 1);
50
71
  const [email, setEmail] = useState(initialEmail);
51
72
  const [code, setCode] = useState(initialCode);
52
73
  const [newPassword, setNewPassword] = useState("");
53
74
  const [error, setError] = useState<string | null>(null);
75
+ // The texts for the fields the last attempt got wrong, keyed by field name.
76
+ const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
54
77
  const [done, setDone] = useState<string | null>(null);
55
78
  const [loading, setLoading] = useState(false);
56
79
 
57
- const failure = (err: unknown) => (err instanceof Error && err.message ? err.message : text.somethingWentWrong);
80
+ // Field errors go under the fields this step shows, and the message holds the rest.
81
+ const fail = (err: unknown, shown: readonly string[]) => {
82
+ const texts = Object.entries(fieldErrorTexts(err, validation));
83
+ if (texts.length === 0) {
84
+ setError(errorText(err, errors, text.somethingWentWrong));
85
+ return;
86
+ }
87
+ // Only the fields this step shows render their errors.
88
+ setFieldErrors(Object.fromEntries(texts));
89
+ // Empty when every field error has its field, which shows no message.
90
+ setError(texts.filter(([field]) => !shown.includes(field)).flatMap(([, list]) => list).join(" "));
91
+ };
92
+
93
+ const fieldError = (field: string) => fieldErrors[field]?.join(" ");
58
94
 
59
95
  const sendCode = async (event: FormEvent) => {
60
96
  event.preventDefault();
61
97
  setError(null);
98
+ setFieldErrors({});
62
99
  setLoading(true);
63
100
  try {
64
101
  await requestReset({ email });
65
102
  setStep(2);
66
103
  } catch (err) {
67
- setError(failure(err));
104
+ fail(err, STEP_FIELDS[1]);
68
105
  } finally {
69
106
  setLoading(false);
70
107
  }
@@ -73,9 +110,10 @@ export function ResetPasswordFlow({
73
110
  const setPassword = async (event: FormEvent) => {
74
111
  event.preventDefault();
75
112
  setError(null);
113
+ setFieldErrors({});
76
114
  const problems = passwordProblems(newPassword, passwordStrings);
77
115
  if (problems.length > 0) {
78
- setError(text.passwordInvalid ?? problems.join(" "));
116
+ setFieldErrors({ newPassword: text.passwordInvalid === null ? problems : [text.passwordInvalid] });
79
117
  return;
80
118
  }
81
119
  setLoading(true);
@@ -84,7 +122,7 @@ export function ResetPasswordFlow({
84
122
  const message = (result as { message?: unknown } | null | undefined)?.message;
85
123
  setDone(text.done ?? (typeof message === "string" ? message : ""));
86
124
  } catch (err) {
87
- setError(failure(err));
125
+ fail(err, STEP_FIELDS[2]);
88
126
  } finally {
89
127
  setLoading(false);
90
128
  }
@@ -107,7 +145,7 @@ export function ResetPasswordFlow({
107
145
  <form onSubmit={sendCode} className="space-y-4">
108
146
  {error && <Alert variant="error">{error}</Alert>}
109
147
  {text.intro && <p className="text-sm text-auth-text">{text.intro}</p>}
110
- <TextInput label={text.email} name="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
148
+ <TextInput label={text.email} name="email" type="email" error={fieldError("email")} value={email} onChange={(e) => setEmail(e.target.value)} required />
111
149
  <button type="submit" disabled={loading} className={button}>
112
150
  {loading ? text.requestingCode : text.requestCode}
113
151
  </button>
@@ -116,9 +154,16 @@ export function ResetPasswordFlow({
116
154
  <form onSubmit={setPassword} className="space-y-4">
117
155
  {text.codeSent && <p className="text-sm text-auth-note">{text.codeSent}</p>}
118
156
  {error && <Alert variant="error">{error}</Alert>}
119
- <TextInput label={text.email} name="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
120
- <TextInput label={text.code} name="code" value={code} onChange={(e) => setCode(e.target.value)} required />
121
- <PasswordInput label={text.newPassword} name="newPassword" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
157
+ <TextInput label={text.email} name="email" type="email" error={fieldError("email")} value={email} onChange={(e) => setEmail(e.target.value)} required />
158
+ <TextInput label={text.code} name="code" error={fieldError("code")} value={code} onChange={(e) => setCode(e.target.value)} required />
159
+ <PasswordInput
160
+ label={text.newPassword}
161
+ name="newPassword"
162
+ error={fieldError("newPassword")}
163
+ value={newPassword}
164
+ onChange={(e) => setNewPassword(e.target.value)}
165
+ required
166
+ />
122
167
  {text.passwordHint && <p className="text-xs text-auth-hint">{text.passwordHint}</p>}
123
168
  <button type="submit" disabled={loading} className={button}>
124
169
  {loading ? text.settingPassword : text.setPassword}
@@ -3,20 +3,22 @@
3
3
  import { useCallback, useEffect, useRef, type TextareaHTMLAttributes } from "react";
4
4
  import { FieldError, FieldLabel, inputClass, useField } from "./field";
5
5
 
6
+ /** Props for `TextArea`. */
6
7
  export interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
7
8
  label: string;
8
9
  error?: string | undefined;
9
10
  /**
10
- * Classes for placing the field, such as a margin. They go on the outer element, which holds the
11
- * label, the text area and the error. Restyle the field through the theme variables.
11
+ * The classes for placing the field, such as a margin. They go on the outer element, which holds
12
+ * the label, the text area, and the error. Restyle the field through the theme variables.
12
13
  */
13
14
  className?: string | undefined;
14
- /** Grows with its text, from `rows` lines up to `maxRows` lines. On by default. */
15
+ /** Whether the box grows with its text, from `rows` lines up to `maxRows` lines. Defaults to `true`. */
15
16
  autoGrow?: boolean | undefined;
16
- /** The most lines an auto-growing box shows before it scrolls. Defaults to 12. */
17
+ /** The most lines an auto-growing box shows before it scrolls. Defaults to `12`. */
17
18
  maxRows?: number | undefined;
18
19
  }
19
20
 
21
+ /** Renders a labeled text area that can grow with its text. */
20
22
  export function TextArea({ label, error, className, autoGrow = true, maxRows = 12, rows = 3, onInput, ...props }: TextAreaProps) {
21
23
  const field = useField(props, error);
22
24
  const ref = useRef<HTMLTextAreaElement>(null);
@@ -3,16 +3,18 @@
3
3
  import type { InputHTMLAttributes } from "react";
4
4
  import { FieldError, FieldLabel, inputClass, useField } from "./field";
5
5
 
6
+ /** Props for `TextInput`. */
6
7
  export interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
7
8
  label: string;
8
9
  error?: string | undefined;
9
10
  /**
10
- * Classes for placing the field, such as a margin. They go on the outer element, which holds the
11
- * label, the input and the error. Restyle the field through the theme variables.
11
+ * The classes for placing the field, such as a margin. They go on the outer element, which holds
12
+ * the label, the input, and the error. Restyle the field through the theme variables.
12
13
  */
13
14
  className?: string | undefined;
14
15
  }
15
16
 
17
+ /** Renders a labeled text input with an optional error message. */
16
18
  export function TextInput({ label, error, className, ...props }: TextInputProps) {
17
19
  const field = useField(props, error);
18
20