@sjolystinnovation/app-kit 0.18.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
@@ -839,6 +839,39 @@ A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
839
839
  `invalidCredentials` for any other error code. A request next-auth refuses without an error code,
840
840
  or one that throws, shows `somethingWentWrong`.
841
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
+
842
875
  `TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
843
876
  key is optional and may be `undefined`, and a missing or undefined key shows the English default.
844
877
 
@@ -1018,7 +1051,8 @@ const result = passwordSchema({ uppercase: "Ta med en stor bokstav fra A til Z."
1018
1051
  const gargeRule = passwordSchema().max(128, "Be at most 128 characters long.");
1019
1052
  ```
1020
1053
 
1021
- `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.
1022
1056
 
1023
1057
  ### `zodFieldErrors(error, options?)`
1024
1058
 
@@ -1080,6 +1114,14 @@ selector such as `.dark` for a scoped theme:
1080
1114
  | `--color-toggle-on` | `var(--color-primary)`, the track of a `Toggle` that is on |
1081
1115
  | `--color-toggle-off` | gray 200, the track of a `Toggle` that is off |
1082
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 |
1083
1125
  | `--radius-button` | `var(--radius-lg)` |
1084
1126
  | `--color-button-primary-bg` | `var(--color-primary)` |
1085
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.18.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",
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
+ };
@@ -1,4 +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
3
  export { zodFieldErrors } from "./fieldErrors";
4
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
+ }