@sjolystinnovation/app-kit 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -265
- package/package.json +1 -1
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +9 -3
- package/src/admin/CompanySettingsForm.tsx +2 -1
- package/src/admin/LegalPagesEditor.tsx +5 -4
- package/src/admin/LocaleTabs.tsx +4 -3
- package/src/admin/StatHistoryChart.tsx +4 -3
- package/src/admin/adminUsersPanelStrings.ts +3 -3
- package/src/admin/legalPagesEditorStrings.ts +1 -1
- package/src/admin/statHistoryChartStrings.ts +3 -2
- package/src/api/apiFieldErrors.ts +4 -4
- package/src/api/company.ts +4 -3
- package/src/api/createApiClient.ts +3 -2
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +2 -1
- package/src/api/request.ts +2 -2
- package/src/api/revalidateTargets.ts +7 -5
- package/src/api/services.ts +14 -13
- package/src/auth/createAuthOptions.ts +7 -5
- package/src/branding/BrandingManager.tsx +10 -5
- package/src/branding/BrandingProvider.tsx +5 -3
- package/src/branding/index.ts +1 -2
- package/src/branding/server.ts +10 -9
- package/src/branding/types.ts +3 -3
- package/src/config.ts +10 -10
- package/src/forms/useFormDraft.ts +9 -7
- package/src/index.ts +1 -3
- package/src/legal/LegalPageView.tsx +6 -5
- package/src/markdown/Markdown.tsx +12 -10
- package/src/markdown/index.ts +1 -1
- package/src/next-auth.d.ts +11 -9
- package/src/next-config/headers.ts +96 -0
- package/src/next-config/index.ts +18 -107
- package/src/payments/PaymentReturn.tsx +9 -7
- package/src/payments/formatNok.ts +1 -1
- package/src/payments/usePollUntilFinal.ts +7 -5
- package/src/seo/JsonLd.tsx +1 -1
- package/src/seo/environment.ts +3 -3
- package/src/seo/index.ts +0 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +44 -32
- package/src/seo/sitemap.ts +2 -2
- package/src/server/publicApi.ts +13 -9
- package/src/server/revalidateRoute.ts +10 -8
- package/src/session/expiry.ts +8 -5
- package/src/session/react/AppSessionProvider.tsx +3 -2
- package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
- package/src/{ui → session/react}/LoginPage.tsx +7 -6
- package/src/session/react/ProtectedGate.tsx +3 -2
- package/src/session/react/RoleGate.tsx +4 -3
- package/src/session/react/SessionExpiryGuard.tsx +5 -4
- package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
- package/src/session/react/index.ts +8 -2
- package/src/{ui → session/react}/loginPageStrings.ts +1 -1
- package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
- package/src/session/react/useSessionGate.ts +3 -3
- package/src/testing/index.ts +5 -5
- package/src/ui/Alert.tsx +6 -4
- package/src/ui/ContentImage.tsx +12 -11
- package/src/ui/ErrorState.tsx +5 -3
- package/src/ui/NotFoundState.tsx +9 -7
- package/src/ui/PasswordInput.tsx +6 -5
- package/src/ui/ResetPasswordFlow.tsx +10 -8
- package/src/ui/TextArea.tsx +6 -4
- package/src/ui/TextInput.tsx +4 -2
- package/src/ui/Toggle.tsx +3 -3
- package/src/ui/field.tsx +5 -3
- package/src/ui/index.ts +2 -10
- package/src/ui/resetPasswordFlowStrings.ts +7 -7
- package/src/validation/fieldErrors.ts +4 -3
- package/src/validation/password.ts +5 -5
- package/src/validation/passwordRules.ts +2 -2
|
@@ -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 from `onSignedIn` to refuse a sign-in. The form shows its message. */
|
|
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. */
|
|
@@ -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 { SignInRejected, 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/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),
|
|
@@ -97,7 +97,7 @@ export function toastMock() {
|
|
|
97
97
|
/** The HTTP calls of an app's axios instance as spies. */
|
|
98
98
|
export const axiosSpies = { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() };
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/** Builds a module whose default export is an axios instance, such as the app's `services/axiosInstance`. */
|
|
101
101
|
export function axiosInstanceMock() {
|
|
102
102
|
return { default: axiosSpies };
|
|
103
103
|
}
|
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
|
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|
|
4
4
|
import { cx } from "./cx";
|
|
5
5
|
import { withDefaults, type TextOverrides } from "./withDefaults";
|
|
6
6
|
|
|
7
|
+
/** The error page's text. Pass the app's own language. The defaults are English. */
|
|
7
8
|
export interface ErrorStateStrings {
|
|
8
9
|
title: string;
|
|
9
10
|
body: string;
|
|
@@ -18,17 +19,18 @@ export const defaultErrorStateStrings: ErrorStateStrings = {
|
|
|
18
19
|
home: "Go to the front page",
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
/** Props for `ErrorState`. */
|
|
21
23
|
export interface ErrorStateProps {
|
|
22
24
|
/** The `reset` Next passes to an `error.tsx`. It renders the page again. */
|
|
23
25
|
reset: () => void;
|
|
24
|
-
/**
|
|
26
|
+
/** The front page link's target, such as `/` or `/en`. */
|
|
25
27
|
homeHref: string;
|
|
26
28
|
strings?: TextOverrides<ErrorStateStrings> | undefined;
|
|
27
|
-
/**
|
|
29
|
+
/** The classes for placing the component, on its outer element. */
|
|
28
30
|
className?: string | undefined;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
/**
|
|
33
|
+
/** Renders the content of an `error.tsx` when a page cannot be rendered. The response is still a 500. */
|
|
32
34
|
export function ErrorState({ reset, homeHref, strings, className }: ErrorStateProps) {
|
|
33
35
|
const text = withDefaults(defaultErrorStateStrings, strings);
|
|
34
36
|
return (
|
package/src/ui/NotFoundState.tsx
CHANGED
|
@@ -2,29 +2,31 @@ import Link from "next/link";
|
|
|
2
2
|
import { cx } from "./cx";
|
|
3
3
|
import { withDefaults, type TextOverrides } from "./withDefaults";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** The not-found page's text. Pass the app's own language. The defaults are English. */
|
|
6
|
+
export interface NotFoundStateStrings {
|
|
6
7
|
code: string;
|
|
7
8
|
body: string;
|
|
8
9
|
home: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export const
|
|
12
|
+
export const defaultNotFoundStateStrings: NotFoundStateStrings = {
|
|
12
13
|
code: "404",
|
|
13
14
|
body: "This page does not exist.",
|
|
14
15
|
home: "Go home",
|
|
15
16
|
};
|
|
16
17
|
|
|
18
|
+
/** Props for `NotFoundState`. */
|
|
17
19
|
export interface NotFoundStateProps {
|
|
18
|
-
/**
|
|
20
|
+
/** The home link's target, such as `/` or `/en`. */
|
|
19
21
|
homeHref: string;
|
|
20
|
-
strings?: TextOverrides<
|
|
21
|
-
/**
|
|
22
|
+
strings?: TextOverrides<NotFoundStateStrings> | undefined;
|
|
23
|
+
/** The classes for placing the component, on its outer element. */
|
|
22
24
|
className?: string | undefined;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
/**
|
|
27
|
+
/** Renders the content of a `not-found.tsx`. The message is the page's heading. */
|
|
26
28
|
export function NotFoundState({ homeHref, strings, className }: NotFoundStateProps) {
|
|
27
|
-
const text = withDefaults(
|
|
29
|
+
const text = withDefaults(defaultNotFoundStateStrings, strings);
|
|
28
30
|
return (
|
|
29
31
|
<div className={cx("min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4 text-center", className)}>
|
|
30
32
|
<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,
|
|
@@ -10,18 +10,20 @@ import { defaultResetPasswordFlowStrings, type ResetPasswordFlowStrings } from "
|
|
|
10
10
|
import { TextInput } from "./TextInput";
|
|
11
11
|
import { withDefaults, type TextOverrides } from "./withDefaults";
|
|
12
12
|
|
|
13
|
+
/** Props for `ResetPasswordFlow`. */
|
|
13
14
|
export interface ResetPasswordFlowProps {
|
|
14
|
-
/**
|
|
15
|
+
/** A function that asks the API to email a reset code, such as `createUserService`'s `requestPasswordReset`. */
|
|
15
16
|
requestReset: (data: { email: string }) => Promise<unknown>;
|
|
16
|
-
/**
|
|
17
|
+
/** A function that sets the new password with the code, such as `createUserService`'s `resetPassword`. */
|
|
17
18
|
reset: (data: { email: string; code: string; newPassword: string }) => Promise<{ message?: string } | unknown>;
|
|
18
|
-
/**
|
|
19
|
+
/** The path the sign-in links go to. */
|
|
19
20
|
loginHref: string;
|
|
20
|
-
/**
|
|
21
|
+
/** The email from the emailed link. */
|
|
21
22
|
initialEmail?: string | undefined;
|
|
23
|
+
/** The code from the emailed link. A code opens the flow on its last step. */
|
|
22
24
|
initialCode?: string | undefined;
|
|
23
25
|
strings?: TextOverrides<ResetPasswordFlowStrings> | undefined;
|
|
24
|
-
/**
|
|
26
|
+
/** The classes that replace the page's outer spacing, such as when the flow sits inside the app's own card. */
|
|
25
27
|
className?: string | undefined;
|
|
26
28
|
/** The password rule messages, for the app's own language. */
|
|
27
29
|
passwordStrings?: TextOverrides<PasswordStrings> | undefined;
|
|
@@ -31,9 +33,9 @@ const button =
|
|
|
31
33
|
"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
34
|
|
|
33
35
|
/**
|
|
34
|
-
*
|
|
35
|
-
* new password
|
|
36
|
-
* such as an invite, opens on the second step.
|
|
36
|
+
* Renders password reset in two steps: the email asks for a code, then the code and a new password
|
|
37
|
+
* set it. The flow checks the new password against the API's rules before it sends it. A link with
|
|
38
|
+
* the email and code, such as an invite, opens on the second step.
|
|
37
39
|
*/
|
|
38
40
|
export function ResetPasswordFlow({
|
|
39
41
|
requestReset,
|
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
|
|
package/src/ui/Toggle.tsx
CHANGED
|
@@ -5,14 +5,14 @@ import { cx } from "./cx";
|
|
|
5
5
|
|
|
6
6
|
type ToggleBase = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "role" | "type" | "aria-checked"> & {
|
|
7
7
|
checked: boolean;
|
|
8
|
-
/**
|
|
8
|
+
/** A function called with the new state when the switch is flipped. */
|
|
9
9
|
onChange: (checked: boolean) => void;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
/** A switch needs a name
|
|
12
|
+
/** Props for `Toggle`. A switch needs a name: either a visible `label`, or an `aria-label` when there is none. */
|
|
13
13
|
export type ToggleProps = ToggleBase & ({ label: string } | { label?: undefined; "aria-label": string });
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** Renders an on and off switch, for a setting that takes effect at once. */
|
|
16
16
|
export function Toggle({ checked, onChange, label, className, disabled, onClick, ...props }: ToggleProps) {
|
|
17
17
|
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
18
18
|
onClick?.(event);
|
package/src/ui/field.tsx
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
import { useId, type AriaAttributes, type ReactNode } from "react";
|
|
4
4
|
import { cx } from "./cx";
|
|
5
5
|
|
|
6
|
-
/** The props of a field control that useField reads. An `<input>` and a `<textarea>` both have them. */
|
|
6
|
+
/** The props of a field control that `useField` reads. An `<input>` and a `<textarea>` both have them. */
|
|
7
7
|
export type FieldControlProps = Pick<AriaAttributes, "aria-required" | "aria-invalid" | "aria-describedby"> & {
|
|
8
8
|
id?: string | undefined;
|
|
9
9
|
required?: boolean | undefined;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/** Builds the ids and aria attributes a labeled input needs, from the caller's own props. */
|
|
13
13
|
export function useField(props: FieldControlProps, error: string | undefined) {
|
|
14
14
|
// A generated id ties the label to its own control, even when two fields on a page share a name.
|
|
15
15
|
const generated = useId();
|
|
@@ -29,7 +29,7 @@ export function useField(props: FieldControlProps, error: string | undefined) {
|
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/** Builds the input's classes, with the error border when there is an error. */
|
|
33
33
|
export function inputClass(error: string | undefined, extra?: string): string {
|
|
34
34
|
return cx(
|
|
35
35
|
"w-full rounded-input border px-3 py-input-y text-sm bg-input-bg text-input-text placeholder-input-placeholder focus:outline-hidden focus:ring-(length:--input-focus-ring-width) focus:ring-input-focus-ring",
|
|
@@ -38,6 +38,7 @@ export function inputClass(error: string | undefined, extra?: string): string {
|
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Renders a field's label, with a star when the field is required. */
|
|
41
42
|
export function FieldLabel({ htmlFor, required, children }: { htmlFor: string; required: boolean | undefined; children: ReactNode }) {
|
|
42
43
|
return (
|
|
43
44
|
<label htmlFor={htmlFor} className="block text-field-label font-medium text-field-label-text mb-field-label">
|
|
@@ -47,6 +48,7 @@ export function FieldLabel({ htmlFor, required, children }: { htmlFor: string; r
|
|
|
47
48
|
);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/** Renders a field's error message, or nothing when there is none. */
|
|
50
52
|
export function FieldError({ id, error }: { id: string; error: string | undefined }) {
|
|
51
53
|
if (!error) return null;
|
|
52
54
|
return (
|
package/src/ui/index.ts
CHANGED
|
@@ -2,18 +2,10 @@ export { Alert } from "./Alert";
|
|
|
2
2
|
export type { AlertProps } from "./Alert";
|
|
3
3
|
export { ContentImage, CONTENT_IMAGE_WIDTHS, contentImagePath, contentImageSrcSet } from "./ContentImage";
|
|
4
4
|
export type { ContentImageProps } from "./ContentImage";
|
|
5
|
-
export { CredentialsForm } from "./CredentialsForm";
|
|
6
|
-
export type { CredentialsFormProps } from "./CredentialsForm";
|
|
7
|
-
export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsFormStrings";
|
|
8
|
-
export type { CredentialsFormStrings } from "./credentialsFormStrings";
|
|
9
5
|
export { ErrorState, defaultErrorStateStrings } from "./ErrorState";
|
|
10
6
|
export type { ErrorStateProps, ErrorStateStrings } from "./ErrorState";
|
|
11
|
-
export {
|
|
12
|
-
export type {
|
|
13
|
-
export { defaultLoginPageStrings } from "./loginPageStrings";
|
|
14
|
-
export type { LoginPageStrings } from "./loginPageStrings";
|
|
15
|
-
export { NotFoundState, defaultNotFoundStrings } from "./NotFoundState";
|
|
16
|
-
export type { NotFoundStateProps, NotFoundStrings } from "./NotFoundState";
|
|
7
|
+
export { NotFoundState, defaultNotFoundStateStrings } from "./NotFoundState";
|
|
8
|
+
export type { NotFoundStateProps, NotFoundStateStrings } from "./NotFoundState";
|
|
17
9
|
export { PasswordInput } from "./PasswordInput";
|
|
18
10
|
export type { PasswordInputProps } from "./PasswordInput";
|
|
19
11
|
export { ResetPasswordFlow } from "./ResetPasswordFlow";
|
|
@@ -6,27 +6,27 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export interface ResetPasswordFlowStrings {
|
|
8
8
|
title: string;
|
|
9
|
-
/**
|
|
9
|
+
/** The text shown before the sign-in link under the title, such as "Remembered it?". */
|
|
10
10
|
signInPrompt: string | null;
|
|
11
11
|
signIn: string;
|
|
12
|
-
/** The link back to sign-in once the password is set. */
|
|
12
|
+
/** The text of the link back to sign-in once the password is set. */
|
|
13
13
|
backToSignIn: string;
|
|
14
|
-
/**
|
|
14
|
+
/** The text shown above the email field on the first step. */
|
|
15
15
|
intro: string | null;
|
|
16
|
-
/**
|
|
16
|
+
/** The text shown at the top of the second step, once a code is on its way. */
|
|
17
17
|
codeSent: string | null;
|
|
18
18
|
email: string;
|
|
19
19
|
code: string;
|
|
20
20
|
newPassword: string;
|
|
21
|
-
/**
|
|
21
|
+
/** The text shown under the new password field. */
|
|
22
22
|
passwordHint: string | null;
|
|
23
23
|
requestCode: string;
|
|
24
24
|
requestingCode: string;
|
|
25
25
|
setPassword: string;
|
|
26
26
|
settingPassword: string;
|
|
27
|
-
/**
|
|
27
|
+
/** The text shown when the password is set. Null shows the API's own message, or only the link when the API sends none. */
|
|
28
28
|
done: string | null;
|
|
29
|
-
/**
|
|
29
|
+
/** The text shown for a password the rules refuse. Null lists each rule it breaks. */
|
|
30
30
|
passwordInvalid: string | null;
|
|
31
31
|
somethingWentWrong: string;
|
|
32
32
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/** A function that picks the text shown for an issue, such as a translated message by the issue's code. */
|
|
4
4
|
export type IssueMessage = (issue: z.core.$ZodIssue) => string;
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
* default each field gets its first issue's message. With `all: true` it gets every
|
|
7
|
+
* Groups a failed parse's issues by the top-level field they belong to, for showing beside each
|
|
8
|
+
* input. By default each field gets its first issue's message. With `all: true` it gets every
|
|
9
|
+
* message, in order.
|
|
9
10
|
* `message` replaces zod's text, such as with the app's own translations. Issues without a field,
|
|
10
11
|
* from a refinement of the whole object, are left out.
|
|
11
12
|
*/
|
|
@@ -6,11 +6,11 @@ export { PASSWORD_MIN_LENGTH, defaultPasswordStrings, passwordProblems } from ".
|
|
|
6
6
|
export type { PasswordStrings } from "./passwordRules";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
* counts only ASCII letters and digits, so "Å" is neither an uppercase nor a lowercase
|
|
11
|
-
* Length counts UTF-16 code units, as Identity does. There is no upper limit, because
|
|
12
|
-
* none. An app whose API sets one adds it with `.max()`. A failing password gets one
|
|
13
|
-
* it breaks.
|
|
9
|
+
* Builds a zod schema for a new password, with the rules the APIs enforce through ASP.NET Identity.
|
|
10
|
+
* Identity counts only ASCII letters and digits, so "Å" is neither an uppercase nor a lowercase
|
|
11
|
+
* letter here. Length counts UTF-16 code units, as Identity does. There is no upper limit, because
|
|
12
|
+
* Identity has none. An app whose API sets one adds it with `.max()`. A failing password gets one
|
|
13
|
+
* issue per rule it breaks.
|
|
14
14
|
*/
|
|
15
15
|
export function passwordSchema(strings?: TextOverrides<PasswordStrings>) {
|
|
16
16
|
const text = withDefaults(defaultPasswordStrings, strings);
|
|
@@ -21,8 +21,8 @@ export const defaultPasswordStrings: PasswordStrings = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
* An empty list means the APIs accept it.
|
|
24
|
+
* Lists the message of every password rule the password breaks, in the order `passwordSchema`
|
|
25
|
+
* checks them. An empty list means the APIs accept it.
|
|
26
26
|
*/
|
|
27
27
|
export function passwordProblems(password: string, strings?: TextOverrides<PasswordStrings>): string[] {
|
|
28
28
|
const text = withDefaults(defaultPasswordStrings, strings);
|