@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
|
@@ -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
|
-
/**
|
|
9
|
+
/** The path a signed-out visitor, or a session dead on arrival, is sent to. */
|
|
9
10
|
loginHref: string;
|
|
10
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 "
|
|
7
|
-
import { SignInRejected, type CredentialsFormStrings } from "
|
|
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
|
-
/**
|
|
39
|
+
/** The path a sign-in as a different user is sent to after it is signed out. */
|
|
39
40
|
loginHref: string;
|
|
40
|
-
/**
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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);
|
package/src/testing/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const auth = {
|
|
|
20
20
|
update: vi.fn(),
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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)
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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}>
|
package/src/ui/ContentImage.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
19
|
+
/** The uploaded image's id. Null falls back to `fallbackSrc`, which is served as it is. */
|
|
19
20
|
imageId: string | null;
|
|
20
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
34
|
+
/** The path the content images rewrite serves. Defaults to `/content-images`. */
|
|
34
35
|
base?: string | undefined;
|
|
35
|
-
/**
|
|
36
|
+
/** The widths in the `srcset`. Defaults to the widths the API serves. */
|
|
36
37
|
widths?: readonly number[] | undefined;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/**
|
|
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,
|
package/src/ui/ErrorState.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
12
|
+
/** The front page link's target, such as `/` or `/en`. */
|
|
25
13
|
homeHref: string;
|
|
26
14
|
strings?: TextOverrides<ErrorStateStrings> | undefined;
|
|
27
|
-
/**
|
|
15
|
+
/** The classes for placing the component, on its outer element. */
|
|
28
16
|
className?: string | undefined;
|
|
29
17
|
}
|
|
30
18
|
|
|
31
|
-
/**
|
|
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 (
|
package/src/ui/NotFoundState.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
8
|
+
/** The home link's target, such as `/` or `/en`. */
|
|
19
9
|
homeHref: string;
|
|
20
|
-
strings?: TextOverrides<
|
|
21
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
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>
|
package/src/ui/PasswordInput.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
11
|
+
/** The accessible name of the toggle while the password is hidden. */
|
|
12
12
|
showPasswordLabel?: string | undefined;
|
|
13
|
-
/**
|
|
13
|
+
/** The accessible name of the toggle while the password is visible. */
|
|
14
14
|
hidePasswordLabel?: string | undefined;
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
25
|
+
/** The path the sign-in links go to. */
|
|
19
26
|
loginHref: string;
|
|
20
|
-
/**
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
*
|
|
35
|
-
* new password
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
package/src/ui/TextArea.tsx
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
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);
|
package/src/ui/TextInput.tsx
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|