@sjolystinnovation/app-kit 0.17.0 → 0.19.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 CHANGED
@@ -264,6 +264,10 @@ export const getRecipe = (id: number) =>
264
264
  `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
265
265
  It never throws. On failure the pages' own revalidate window is the fallback.
266
266
 
267
+ `apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
268
+ call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
269
+ `{ orgNumber: ["..."] }`. It answers null when there are none, so the form falls back to one message.
270
+
267
271
  `createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
268
272
  empty `baseURL` throws, and requests only go to `baseURL`.
269
273
 
@@ -835,6 +839,39 @@ A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
835
839
  `invalidCredentials` for any other error code. A request next-auth refuses without an error code,
836
840
  or one that throws, shows `somethingWentWrong`.
837
841
 
842
+ ### `ResetPasswordFlow`
843
+
844
+ From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
845
+ code, then the code and a new password set it. The password is checked against the same rules as
846
+ `passwordSchema` before it is sent, without loading zod. An emailed link with the email and code,
847
+ such as an invite, opens it on the second step. Its colours and title size come from the `auth` and
848
+ `button` theme variables, see [Theming](#theming).
849
+
850
+ ```tsx
851
+ const params = useSearchParams();
852
+
853
+ <ResetPasswordFlow
854
+ requestReset={UserService.requestPasswordReset}
855
+ reset={UserService.resetPassword}
856
+ loginHref="/login"
857
+ initialEmail={params.get("email") ?? ""}
858
+ initialCode={params.get("code") ?? ""}
859
+ strings={{ title: "Tilbakestill passord", signInPrompt: null }}
860
+ />
861
+ ```
862
+
863
+ | Prop | Type | Description |
864
+ | --- | --- | --- |
865
+ | `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
866
+ | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. A `message` in its answer is shown when it succeeds. |
867
+ | `loginHref` | `string` | Where the sign-in links go. |
868
+ | `initialEmail`, `initialCode` | `string` | From the emailed link. A code opens the second step. |
869
+ | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`, such as to sit in the app's own card. |
870
+ | `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. `signInPrompt`, `intro`, `codeSent` and `passwordHint` set to null are left out. `done` set to null shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to null lists every broken rule. |
871
+ | `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
872
+
873
+ A failure shows the error's message, or `somethingWentWrong` when it has none.
874
+
838
875
  `TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
839
876
  key is optional and may be `undefined`, and a missing or undefined key shows the English default.
840
877
 
@@ -1014,7 +1051,22 @@ const result = passwordSchema({ uppercase: "Ta med en stor bokstav fra A til Z."
1014
1051
  const gargeRule = passwordSchema().max(128, "Be at most 128 characters long.");
1015
1052
  ```
1016
1053
 
1017
- `PASSWORD_MIN_LENGTH` holds the minimum, for hint text.
1054
+ `PASSWORD_MIN_LENGTH` holds the minimum, for hint text. `passwordProblems(password, strings?)` gives the
1055
+ message of every rule a password breaks, in the same order, without zod.
1056
+
1057
+ ### `zodFieldErrors(error, options?)`
1058
+
1059
+ From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
1060
+ belong to, for showing beside each input. Each field gets its first issue's message, or every message
1061
+ in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
1062
+ Issues that belong to no field are left out.
1063
+
1064
+ ```ts
1065
+ import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
1066
+
1067
+ const parsed = contactSchema.safeParse(form);
1068
+ if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: (issue) => issueMessage(issue, dict) }));
1069
+ ```
1018
1070
 
1019
1071
  ## Theming
1020
1072
 
@@ -1062,6 +1114,14 @@ selector such as `.dark` for a scoped theme:
1062
1114
  | `--color-toggle-on` | `var(--color-primary)`, the track of a `Toggle` that is on |
1063
1115
  | `--color-toggle-off` | gray 200, the track of a `Toggle` that is off |
1064
1116
  | `--color-toggle-thumb` | white |
1117
+ | `--text-auth-title`, `--text-auth-title--line-height` | `var(--text-2xl)` and its line height, the title of `ResetPasswordFlow` |
1118
+ | `--font-weight-auth-title` | black |
1119
+ | `--color-auth-heading` | gray 900 |
1120
+ | `--color-auth-text` | gray 600, the line under the title and the intro |
1121
+ | `--color-auth-note` | gray 600, the note that a code was sent |
1122
+ | `--color-auth-hint` | gray 500, the hint under the password |
1123
+ | `--color-auth-link`, `-hover` | gray 900, the sign-in links |
1124
+ | `--font-weight-auth-link` | semibold |
1065
1125
  | `--radius-button` | `var(--radius-lg)` |
1066
1126
  | `--color-button-primary-bg` | `var(--color-primary)` |
1067
1127
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -0,0 +1,23 @@
1
+ import axios from "axios";
2
+
3
+ /**
4
+ * The per-field messages of an ASP.NET validation problem, keyed by the field's name in camelCase so
5
+ * they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
6
+ * `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body. Answers null when there
7
+ * are no field errors, so the caller falls back to one message for the whole form.
8
+ */
9
+ export function apiFieldErrors(error: unknown): Record<string, string[]> | null {
10
+ const data = axios.isAxiosError(error) ? error.response?.data : error;
11
+ const errors = data && typeof data === "object" ? (data as { errors?: unknown }).errors : undefined;
12
+ if (!errors || typeof errors !== "object" || Array.isArray(errors)) return null;
13
+
14
+ // A Map, so a field named like an Object property such as "constructor" is still just a field.
15
+ const fields = new Map<string, string[]>();
16
+ for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
17
+ const messages = (Array.isArray(value) ? value : [value]).filter((m): m is string => typeof m === "string" && m !== "");
18
+ if (messages.length === 0) continue;
19
+ const field = key.charAt(0).toLowerCase() + key.slice(1);
20
+ fields.set(field, [...(fields.get(field) ?? []), ...messages]);
21
+ }
22
+ return fields.size > 0 ? Object.fromEntries(fields) : null;
23
+ }
package/src/api/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { apiFieldErrors } from "./apiFieldErrors";
1
2
  export { createApiClient } from "./createApiClient";
2
3
  export { request } from "./request";
3
4
  export { requestRevalidate } from "./requestRevalidate";
package/src/styles.css CHANGED
@@ -71,6 +71,17 @@
71
71
  --color-payment-secondary-hover-bg: var(--color-gray-200);
72
72
  --color-payment-secondary-text: var(--color-gray-700);
73
73
 
74
+ --text-auth-title: var(--text-2xl);
75
+ --text-auth-title--line-height: var(--text-2xl--line-height);
76
+ --font-weight-auth-title: var(--font-weight-black);
77
+ --color-auth-heading: var(--color-gray-900);
78
+ --color-auth-text: var(--color-gray-600);
79
+ --color-auth-note: var(--color-gray-600);
80
+ --color-auth-hint: var(--color-gray-500);
81
+ --color-auth-link: var(--color-gray-900);
82
+ --color-auth-link-hover: var(--color-gray-900);
83
+ --font-weight-auth-link: var(--font-weight-semibold);
84
+
74
85
  --radius-button: var(--radius-lg);
75
86
  --color-button-primary-bg: var(--color-primary);
76
87
  --color-button-primary-text: var(--color-primary-foreground);
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useState, type FormEvent } from "react";
5
+ import { passwordProblems, type PasswordStrings } from "../validation/passwordRules";
6
+ import { Alert } from "./Alert";
7
+ import { PasswordInput } from "./PasswordInput";
8
+ import { defaultResetPasswordFlowStrings, type ResetPasswordFlowStrings } from "./resetPasswordFlowStrings";
9
+ import { TextInput } from "./TextInput";
10
+ import { withDefaults, type TextOverrides } from "./withDefaults";
11
+
12
+ export interface ResetPasswordFlowProps {
13
+ /** Asks the API to email a reset code, such as `createUserService`'s `requestPasswordReset`. */
14
+ requestReset: (data: { email: string }) => Promise<unknown>;
15
+ /** Sets the new password with the code, such as `createUserService`'s `resetPassword`. */
16
+ reset: (data: { email: string; code: string; newPassword: string }) => Promise<{ message?: string } | unknown>;
17
+ /** Where the sign-in links go. */
18
+ loginHref: string;
19
+ /** From the emailed link. A code opens the flow on its last step. */
20
+ initialEmail?: string | undefined;
21
+ initialCode?: string | undefined;
22
+ strings?: TextOverrides<ResetPasswordFlowStrings> | undefined;
23
+ /** Replaces the page's outer spacing, such as when the flow sits inside the app's own card. */
24
+ className?: string | undefined;
25
+ /** The password rule messages, for the app's own language. */
26
+ passwordStrings?: TextOverrides<PasswordStrings> | undefined;
27
+ }
28
+
29
+ const button =
30
+ "w-full rounded-button bg-button-primary-bg text-button-primary-text font-semibold py-2.5 hover:brightness-95 disabled:opacity-60 transition";
31
+ const link = "font-auth-link text-auth-link hover:text-auth-link-hover transition-colors";
32
+
33
+ /**
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.
37
+ */
38
+ export function ResetPasswordFlow({
39
+ requestReset,
40
+ reset,
41
+ loginHref,
42
+ initialEmail = "",
43
+ initialCode = "",
44
+ strings,
45
+ className = "max-w-md mx-auto px-4 py-16",
46
+ passwordStrings,
47
+ }: ResetPasswordFlowProps) {
48
+ const text = withDefaults(defaultResetPasswordFlowStrings, strings);
49
+ const [step, setStep] = useState<1 | 2>(initialCode ? 2 : 1);
50
+ const [email, setEmail] = useState(initialEmail);
51
+ const [code, setCode] = useState(initialCode);
52
+ const [newPassword, setNewPassword] = useState("");
53
+ const [error, setError] = useState<string | null>(null);
54
+ const [done, setDone] = useState<string | null>(null);
55
+ const [loading, setLoading] = useState(false);
56
+
57
+ const failure = (err: unknown) => (err instanceof Error && err.message ? err.message : text.somethingWentWrong);
58
+
59
+ const sendCode = async (event: FormEvent) => {
60
+ event.preventDefault();
61
+ setError(null);
62
+ setLoading(true);
63
+ try {
64
+ await requestReset({ email });
65
+ setStep(2);
66
+ } catch (err) {
67
+ setError(failure(err));
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ };
72
+
73
+ const setPassword = async (event: FormEvent) => {
74
+ event.preventDefault();
75
+ setError(null);
76
+ const problems = passwordProblems(newPassword, passwordStrings);
77
+ if (problems.length > 0) {
78
+ setError(text.passwordInvalid ?? problems.join(" "));
79
+ return;
80
+ }
81
+ setLoading(true);
82
+ try {
83
+ const result = await reset({ email, code, newPassword });
84
+ const message = (result as { message?: unknown } | null | undefined)?.message;
85
+ setDone(text.done ?? (typeof message === "string" ? message : ""));
86
+ } catch (err) {
87
+ setError(failure(err));
88
+ } finally {
89
+ setLoading(false);
90
+ }
91
+ };
92
+
93
+ return (
94
+ <div className={className}>
95
+ <h1 className="text-auth-title font-auth-title text-auth-heading mb-1">{text.title}</h1>
96
+ <p className="text-sm text-auth-text mb-6">
97
+ {text.signInPrompt && <>{text.signInPrompt} </>}
98
+ <Link href={loginHref} className={link}>{text.signIn}</Link>
99
+ </p>
100
+
101
+ {done !== null ? (
102
+ <Alert variant="success">
103
+ {done}
104
+ <Link href={loginHref} className={`block mt-3 ${link}`}>{text.backToSignIn}</Link>
105
+ </Alert>
106
+ ) : step === 1 ? (
107
+ <form onSubmit={sendCode} className="space-y-4">
108
+ {error && <Alert variant="error">{error}</Alert>}
109
+ {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 />
111
+ <button type="submit" disabled={loading} className={button}>
112
+ {loading ? text.requestingCode : text.requestCode}
113
+ </button>
114
+ </form>
115
+ ) : (
116
+ <form onSubmit={setPassword} className="space-y-4">
117
+ {text.codeSent && <p className="text-sm text-auth-note">{text.codeSent}</p>}
118
+ {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 />
122
+ {text.passwordHint && <p className="text-xs text-auth-hint">{text.passwordHint}</p>}
123
+ <button type="submit" disabled={loading} className={button}>
124
+ {loading ? text.settingPassword : text.setPassword}
125
+ </button>
126
+ </form>
127
+ )}
128
+ </div>
129
+ );
130
+ }
package/src/ui/index.ts CHANGED
@@ -12,6 +12,10 @@ export { NotFoundState, defaultNotFoundStrings } from "./NotFoundState";
12
12
  export type { NotFoundStateProps, NotFoundStrings } from "./NotFoundState";
13
13
  export { PasswordInput } from "./PasswordInput";
14
14
  export type { PasswordInputProps } from "./PasswordInput";
15
+ export { ResetPasswordFlow } from "./ResetPasswordFlow";
16
+ export type { ResetPasswordFlowProps } from "./ResetPasswordFlow";
17
+ export { defaultResetPasswordFlowStrings } from "./resetPasswordFlowStrings";
18
+ export type { ResetPasswordFlowStrings } from "./resetPasswordFlowStrings";
15
19
  export { TextArea } from "./TextArea";
16
20
  export type { TextAreaProps } from "./TextArea";
17
21
  export { TextInput } from "./TextInput";
@@ -0,0 +1,52 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /**
4
+ * The reset flow's text. Pass the app's own language. The defaults are English. A text that is null
5
+ * is left out of the page.
6
+ */
7
+ export interface ResetPasswordFlowStrings {
8
+ title: string;
9
+ /** Shown before the sign-in link under the title, such as "Remembered it?". */
10
+ signInPrompt: string | null;
11
+ signIn: string;
12
+ /** The link back to sign-in once the password is set. */
13
+ backToSignIn: string;
14
+ /** Shown above the email field on the first step. */
15
+ intro: string | null;
16
+ /** Shown at the top of the second step, once a code is on its way. */
17
+ codeSent: string | null;
18
+ email: string;
19
+ code: string;
20
+ newPassword: string;
21
+ /** Shown under the new password field. */
22
+ passwordHint: string | null;
23
+ requestCode: string;
24
+ requestingCode: string;
25
+ setPassword: string;
26
+ settingPassword: string;
27
+ /** Shown when the password is set. Null shows the API's own message, or only the link when it sends none. */
28
+ done: string | null;
29
+ /** Shown for a password the rules refuse. Null lists each rule it breaks. */
30
+ passwordInvalid: string | null;
31
+ somethingWentWrong: string;
32
+ }
33
+
34
+ export const defaultResetPasswordFlowStrings: ResetPasswordFlowStrings = {
35
+ title: "Reset password",
36
+ signInPrompt: "Remembered it?",
37
+ signIn: "Sign in",
38
+ backToSignIn: "Back to sign in →",
39
+ intro: null,
40
+ codeSent: "Check your email for the reset code.",
41
+ email: "Email",
42
+ code: "Reset code",
43
+ newPassword: "New password",
44
+ passwordHint: null,
45
+ requestCode: "Send reset code",
46
+ requestingCode: "Sending…",
47
+ setPassword: "Reset password",
48
+ settingPassword: "Resetting…",
49
+ done: null,
50
+ passwordInvalid: null,
51
+ somethingWentWrong: "Something went wrong",
52
+ };
@@ -0,0 +1,34 @@
1
+ import type { z } from "zod";
2
+
3
+ /** Picks the text shown for an issue, such as a translated message by the issue's code. */
4
+ export type IssueMessage = (issue: z.core.$ZodIssue) => string;
5
+
6
+ /**
7
+ * A failed parse's issues by the top-level field they belong to, for showing beside each input. By
8
+ * default each field gets its first issue's message. With `all: true` it gets every message, in order.
9
+ * `message` replaces zod's text, such as with the app's own translations. Issues without a field,
10
+ * from a refinement of the whole object, are left out.
11
+ */
12
+ export function zodFieldErrors<T>(
13
+ error: z.ZodError<T>,
14
+ options?: { all?: false; message?: IssueMessage },
15
+ ): Partial<Record<Extract<keyof T, string>, string>>;
16
+ export function zodFieldErrors<T>(
17
+ error: z.ZodError<T>,
18
+ options: { all: true; message?: IssueMessage },
19
+ ): Partial<Record<Extract<keyof T, string>, string[]>>;
20
+ export function zodFieldErrors<T>(
21
+ error: z.ZodError<T>,
22
+ options: { all?: boolean; message?: IssueMessage } = {},
23
+ ): object {
24
+ // A Map, so a field named like an Object property such as "constructor" is still just a field.
25
+ const errors = new Map<string, string[]>();
26
+ for (const issue of error.issues) {
27
+ const field = issue.path[0];
28
+ if (typeof field !== "string") continue;
29
+ const messages = errors.get(field) ?? [];
30
+ messages.push(options.message ? options.message(issue) : issue.message);
31
+ errors.set(field, messages);
32
+ }
33
+ return Object.fromEntries([...errors].map(([field, messages]) => [field, options.all ? messages : messages[0]]));
34
+ }
@@ -1,2 +1,4 @@
1
- export { PASSWORD_MIN_LENGTH, defaultPasswordStrings, passwordSchema } from "./password";
1
+ export { PASSWORD_MIN_LENGTH, defaultPasswordStrings, passwordProblems, passwordSchema } from "./password";
2
2
  export type { PasswordStrings } from "./password";
3
+ export { zodFieldErrors } from "./fieldErrors";
4
+ export type { IssueMessage } from "./fieldErrors";
@@ -1,23 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
3
+ import { PASSWORD_MIN_LENGTH, defaultPasswordStrings, type PasswordStrings } from "./passwordRules";
3
4
 
4
- /** The fewest characters a password may have. */
5
- export const PASSWORD_MIN_LENGTH = 8;
6
-
7
- /** The message for each password rule that fails. */
8
- export interface PasswordStrings {
9
- tooShort: string;
10
- lowercase: string;
11
- uppercase: string;
12
- digit: string;
13
- }
14
-
15
- export const defaultPasswordStrings: PasswordStrings = {
16
- tooShort: `Use at least ${PASSWORD_MIN_LENGTH} characters.`,
17
- lowercase: "Include a lowercase letter from a to z.",
18
- uppercase: "Include an uppercase letter from A to Z.",
19
- digit: "Include a digit from 0 to 9.",
20
- };
5
+ export { PASSWORD_MIN_LENGTH, defaultPasswordStrings, passwordProblems } from "./passwordRules";
6
+ export type { PasswordStrings } from "./passwordRules";
21
7
 
22
8
  /**
23
9
  * A zod schema for a new password, with the rules the APIs enforce through ASP.NET Identity. Identity
@@ -0,0 +1,35 @@
1
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
2
+
3
+ // No zod here, so the reset form in /ui can check a password without the optional zod peer.
4
+
5
+ /** The fewest characters a password may have. */
6
+ export const PASSWORD_MIN_LENGTH = 8;
7
+
8
+ /** The message for each password rule that fails. */
9
+ export interface PasswordStrings {
10
+ tooShort: string;
11
+ lowercase: string;
12
+ uppercase: string;
13
+ digit: string;
14
+ }
15
+
16
+ export const defaultPasswordStrings: PasswordStrings = {
17
+ tooShort: `Use at least ${PASSWORD_MIN_LENGTH} characters.`,
18
+ lowercase: "Include a lowercase letter from a to z.",
19
+ uppercase: "Include an uppercase letter from A to Z.",
20
+ digit: "Include a digit from 0 to 9.",
21
+ };
22
+
23
+ /**
24
+ * The message of every password rule the password breaks, in the order `passwordSchema` checks them.
25
+ * An empty list means the APIs accept it.
26
+ */
27
+ export function passwordProblems(password: string, strings?: TextOverrides<PasswordStrings>): string[] {
28
+ const text = withDefaults(defaultPasswordStrings, strings);
29
+ const problems: string[] = [];
30
+ if (password.length < PASSWORD_MIN_LENGTH) problems.push(text.tooShort);
31
+ if (!/[a-z]/.test(password)) problems.push(text.lowercase);
32
+ if (!/[A-Z]/.test(password)) problems.push(text.uppercase);
33
+ if (!/[0-9]/.test(password)) problems.push(text.digit);
34
+ return problems;
35
+ }