@sjolystinnovation/app-kit 0.18.0 → 0.20.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
@@ -210,35 +210,18 @@ Two rules keep a refresh from being lost:
210
210
  - Run the app as one server process. Parallel session reads share one refresh only within a process.
211
211
  Two replicas can refresh the same token at once, with the same result.
212
212
 
213
- The package does not declare these fields on next-auth's types. The app declares them, so its own
214
- code can read them:
213
+ The package declares these fields on next-auth's types in `@sjolystinnovation/app-kit/next-auth`. The
214
+ app loads the declarations once, from one of its own `.d.ts` files, so its code can read them:
215
215
 
216
216
  ```ts
217
217
  // src/types/next-auth.d.ts
218
- import "next-auth";
219
- import "next-auth/jwt";
220
-
221
- declare module "next-auth" {
222
- interface Session {
223
- user: { id: string; name: string; email: string; roles: string[] };
224
- accessToken: string;
225
- error?: string | undefined;
226
- absoluteExpiresAt?: number;
227
- }
228
- }
229
-
230
- declare module "next-auth/jwt" {
231
- interface JWT {
232
- accessToken?: string;
233
- refreshToken?: string;
234
- refreshAt?: number;
235
- loginAt?: number;
236
- user?: { id: string; name: string; email: string; roles: string[] };
237
- error?: string | undefined;
238
- }
239
- }
218
+ import "@sjolystinnovation/app-kit/next-auth";
240
219
  ```
241
220
 
221
+ The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `error` and
222
+ `absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`, `user`
223
+ and `error`.
224
+
242
225
  ### `@sjolystinnovation/app-kit/api`
243
226
 
244
227
  `createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
@@ -594,6 +577,50 @@ if (loading) return <Spinner />;
594
577
 
595
578
  `formatNok(ore)` formats øre as kroner, such as `NOK 373,75`.
596
579
 
580
+ ### `@sjolystinnovation/app-kit/admin`
581
+
582
+ The admin area. `AdminShell` is the frame: a title and a tab bar above the page, behind a
583
+ `RoleGate`. It takes `RoleGate`'s `role`, `fallback`, and `denied` or `deniedHref`. The tab whose
584
+ `href` is the current path is active, with `aria-current="page"`.
585
+
586
+ ```tsx
587
+ // src/app/[locale]/(protected)/admin/layout.tsx
588
+ "use client";
589
+ import { AdminShell } from "@sjolystinnovation/app-kit/admin";
590
+
591
+ export default function AdminLayout({ children }: { children: React.ReactNode }) {
592
+ const { dict, locale } = useDictionary();
593
+ const tabs = [
594
+ { href: `/${locale}/admin/users`, label: dict.admin.users },
595
+ { href: `/${locale}/admin/legal`, label: dict.admin.legal },
596
+ ];
597
+ return (
598
+ <AdminShell role="Admin" title={dict.admin.title} tabs={tabs} deniedHref={`/${locale}`}>
599
+ {children}
600
+ </AdminShell>
601
+ );
602
+ }
603
+ ```
604
+
605
+ `AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
606
+ behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
607
+ email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
608
+ may give. Inline functions are fine. The panel does not fetch again when they change. It reports
609
+ with `toast`, so the app needs sonner and a `Toaster`.
610
+
611
+ ```tsx
612
+ import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
613
+
614
+ <AdminUsersPanel service={adminUsers} getRoles={AdminService.getRoles} strings={dict.adminUsers} />
615
+ ```
616
+
617
+ | Prop | Type | Description |
618
+ | --- | --- | --- |
619
+ | `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
620
+ | `getRoles` | `() => Promise<string[]>` | The roles an admin may give. |
621
+ | `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
622
+ | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. `{name}` and `{role}` are filled in where a text names them. |
623
+
597
624
  ### `@sjolystinnovation/app-kit/session`
598
625
 
599
626
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
@@ -839,6 +866,58 @@ A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
839
866
  `invalidCredentials` for any other error code. A request next-auth refuses without an error code,
840
867
  or one that throws, shows `somethingWentWrong`.
841
868
 
869
+ ### `LoginPage`
870
+
871
+ From `@sjolystinnovation/app-kit/ui`. The sign-in page: a title, `CredentialsForm`, a link to reset
872
+ the password and, when the app has sign-up, a link to it. A sign-in goes to `redirectTo` and refreshes
873
+ the page's server data. It uses the same `auth` theme variables as `ResetPasswordFlow`.
874
+
875
+ ```tsx
876
+ <LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
877
+ ```
878
+
879
+ | Prop | Type | Description |
880
+ | --- | --- | --- |
881
+ | `redirectTo` | `string` | Where a signed-in user goes. |
882
+ | `resetHref` | `string` | The password reset page. |
883
+ | `registerHref` | `string` | The sign-up page. Without it there is no register link. |
884
+ | `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt` (null leaves it out) and `register`. |
885
+ | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
886
+ | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`. |
887
+
888
+ ### `ResetPasswordFlow`
889
+
890
+ From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
891
+ code, then the code and a new password set it. The password is checked against the same rules as
892
+ `passwordSchema` before it is sent, without loading zod. An emailed link with the email and code,
893
+ such as an invite, opens it on the second step. Its colours and title size come from the `auth` and
894
+ `button` theme variables, see [Theming](#theming).
895
+
896
+ ```tsx
897
+ const params = useSearchParams();
898
+
899
+ <ResetPasswordFlow
900
+ requestReset={UserService.requestPasswordReset}
901
+ reset={UserService.resetPassword}
902
+ loginHref="/login"
903
+ initialEmail={params.get("email") ?? ""}
904
+ initialCode={params.get("code") ?? ""}
905
+ strings={{ title: "Tilbakestill passord", signInPrompt: null }}
906
+ />
907
+ ```
908
+
909
+ | Prop | Type | Description |
910
+ | --- | --- | --- |
911
+ | `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
912
+ | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. A `message` in its answer is shown when it succeeds. |
913
+ | `loginHref` | `string` | Where the sign-in links go. |
914
+ | `initialEmail`, `initialCode` | `string` | From the emailed link. A code opens the second step. |
915
+ | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`, such as to sit in the app's own card. |
916
+ | `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. |
917
+ | `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
918
+
919
+ A failure shows the error's message, or `somethingWentWrong` when it has none.
920
+
842
921
  `TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
843
922
  key is optional and may be `undefined`, and a missing or undefined key shows the English default.
844
923
 
@@ -1018,7 +1097,8 @@ const result = passwordSchema({ uppercase: "Ta med en stor bokstav fra A til Z."
1018
1097
  const gargeRule = passwordSchema().max(128, "Be at most 128 characters long.");
1019
1098
  ```
1020
1099
 
1021
- `PASSWORD_MIN_LENGTH` holds the minimum, for hint text.
1100
+ `PASSWORD_MIN_LENGTH` holds the minimum, for hint text. `passwordProblems(password, strings?)` gives the
1101
+ message of every rule a password breaks, in the same order, without zod.
1022
1102
 
1023
1103
  ### `zodFieldErrors(error, options?)`
1024
1104
 
@@ -1080,6 +1160,19 @@ selector such as `.dark` for a scoped theme:
1080
1160
  | `--color-toggle-on` | `var(--color-primary)`, the track of a `Toggle` that is on |
1081
1161
  | `--color-toggle-off` | gray 200, the track of a `Toggle` that is off |
1082
1162
  | `--color-toggle-thumb` | white |
1163
+ | `--text-auth-title`, `--text-auth-title--line-height` | `var(--text-2xl)` and its line height, the title of `LoginPage` and `ResetPasswordFlow` |
1164
+ | `--font-weight-auth-title` | black |
1165
+ | `--color-auth-heading` | gray 900 |
1166
+ | `--color-auth-text` | gray 600, the line under the title and the intro |
1167
+ | `--color-auth-note` | gray 600, the note that a code was sent |
1168
+ | `--color-auth-hint` | gray 500, the hint under the password |
1169
+ | `--color-auth-link`, `-hover` | gray 900, the sign-in links |
1170
+ | `--font-weight-auth-link` | semibold |
1171
+ | `--color-admin-title` | gray 900, the title of `AdminShell` |
1172
+ | `--color-admin-border` | gray 200, the line under the tabs |
1173
+ | `--color-admin-tab`, `-hover` | gray 500 and gray 900 |
1174
+ | `--color-admin-tab-active` | gray 900 |
1175
+ | `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
1083
1176
  | `--radius-button` | `var(--radius-lg)` |
1084
1177
  | `--color-button-primary-bg` | `var(--color-primary)` |
1085
1178
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
@@ -1122,9 +1215,9 @@ with it and has to set the ones it uses itself.
1122
1215
  - For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
1123
1216
  copy, because two copies keep two separate toast stores.
1124
1217
  - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
1125
- - For `@sjolystinnovation/app-kit/ui` and the components in `session/react`: Tailwind CSS 4 and
1126
- `@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
1127
- `primary-foreground` colours, unless their own variables are set.
1218
+ - For `@sjolystinnovation/app-kit/ui`, `@sjolystinnovation/app-kit/admin` and the components in
1219
+ `session/react`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring and the primary button
1220
+ use the theme's `primary` and `primary-foreground` colours, unless their own variables are set.
1128
1221
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
1129
1222
  `node16` and `nodenext` reject.
1130
1223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",
@@ -20,9 +20,13 @@
20
20
  "./session/react": "./src/session/react/index.ts",
21
21
  "./forms": "./src/forms/index.ts",
22
22
  "./auth": "./src/auth/index.ts",
23
+ "./next-auth": {
24
+ "types": "./src/next-auth.d.ts"
25
+ },
23
26
  "./api": "./src/api/index.ts",
24
27
  "./server": "./src/server/index.ts",
25
28
  "./ui": "./src/ui/index.ts",
29
+ "./admin": "./src/admin/index.ts",
26
30
  "./toast": "./src/toast/index.ts",
27
31
  "./validation": "./src/validation/index.ts",
28
32
  "./next-config": "./src/next-config/index.ts",
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import type { ReactNode } from "react";
6
+ import { RoleGate, type RoleGateProps } from "../session/react/RoleGate";
7
+
8
+ /** One tab in the admin navigation. */
9
+ export interface AdminTab {
10
+ href: string;
11
+ label: string;
12
+ }
13
+
14
+ type GateProps = RoleGateProps extends infer P ? (P extends unknown ? Omit<P, "children"> : never) : never;
15
+
16
+ export type AdminShellProps = GateProps & {
17
+ title: ReactNode;
18
+ tabs: readonly AdminTab[];
19
+ children: ReactNode;
20
+ };
21
+
22
+ /**
23
+ * The admin area's frame: a title and a tab bar above the page, for users with `role` only. The tab
24
+ * whose `href` is the current path shows as active. The gate props are `RoleGate`'s.
25
+ */
26
+ export function AdminShell({ title, tabs, children, ...gate }: AdminShellProps) {
27
+ const pathname = usePathname();
28
+ const page = (
29
+ <div className="max-w-5xl mx-auto px-4 sm:px-6 py-10">
30
+ <h1 className="text-2xl font-black text-admin-title mb-4">{title}</h1>
31
+ <nav className="flex gap-1 border-b border-admin-border mb-8 overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
32
+ {tabs.map((tab) => {
33
+ const active = pathname === tab.href;
34
+ return (
35
+ <Link
36
+ key={tab.href}
37
+ href={tab.href}
38
+ aria-current={active ? "page" : undefined}
39
+ className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors shrink-0 whitespace-nowrap ${
40
+ active ? "border-admin-tab-indicator text-admin-tab-active" : "border-transparent text-admin-tab hover:text-admin-tab-hover"
41
+ }`}
42
+ >
43
+ {tab.label}
44
+ </Link>
45
+ );
46
+ })}
47
+ </nav>
48
+ {children}
49
+ </div>
50
+ );
51
+ // The gate props came in as one of RoleGate's two shapes, so they go back as that shape.
52
+ return <RoleGate {...({ ...gate, children: page } as RoleGateProps)} />;
53
+ }
@@ -0,0 +1,276 @@
1
+ "use client";
2
+
3
+ import { PlusIcon, TrashIcon, XMarkIcon } from "@heroicons/react/24/outline";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import type { AdminUser, createAdminUsersService } from "../api/services";
6
+ import { toast } from "../toast";
7
+ import { TextInput } from "../ui/TextInput";
8
+ import { Toggle } from "../ui/Toggle";
9
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
10
+ import { defaultAdminUsersPanelStrings, type AdminUsersPanelStrings } from "./adminUsersPanelStrings";
11
+
12
+ export interface AdminUsersPanelProps {
13
+ /** The user admin calls, such as `createAdminUsersService(api)`. */
14
+ service: ReturnType<typeof createAdminUsersService>;
15
+ /** The roles an admin may give, from the app's own endpoint. */
16
+ getRoles: () => Promise<string[]>;
17
+ /** The role a new invite starts with. `Admin` by default. */
18
+ defaultInviteRole?: string | undefined;
19
+ strings?: TextOverrides<AdminUsersPanelStrings> | undefined;
20
+ }
21
+
22
+ const fill = (text: string, values: Record<string, string>) =>
23
+ text.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
24
+
25
+ const reason = (err: unknown, fallback: string) => (err instanceof Error && err.message ? err.message : fallback);
26
+
27
+ /**
28
+ * The admin users page: every user with their roles, a toggle for deleted users, giving and taking
29
+ * roles, deleting an account after a confirm, and inviting a user by email.
30
+ */
31
+ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin", strings }: AdminUsersPanelProps) {
32
+ const text = withDefaults(defaultAdminUsersPanelStrings, strings);
33
+ const [users, setUsers] = useState<AdminUser[]>([]);
34
+ const [allRoles, setAllRoles] = useState<string[]>([]);
35
+ const [includeDeleted, setIncludeDeleted] = useState(false);
36
+ const [loading, setLoading] = useState(true);
37
+ const [addRoleFor, setAddRoleFor] = useState<string | null>(null);
38
+ const [selectedRole, setSelectedRole] = useState("");
39
+ const [roleLoading, setRoleLoading] = useState(false);
40
+ const [invite, setInvite] = useState({ email: "", firstName: "", lastName: "", role: defaultInviteRole });
41
+ const [inviting, setInviting] = useState(false);
42
+ // Bumped to fetch the list again after a change, through the same effect as every other load.
43
+ const [reloads, setReloads] = useState(0);
44
+ // The latest service, so an inline one does not fetch the list again on every render. The roles
45
+ // load once, so the first getRoles is enough.
46
+ const serviceRef = useRef(service);
47
+ useEffect(() => {
48
+ serviceRef.current = service;
49
+ }, [service]);
50
+ const getRolesRef = useRef(getRoles);
51
+
52
+ // Callers set `loading` before changing what to fetch, so the effect only sets state once the
53
+ // request settles. A request replaced by a newer one is ignored, so a slow answer to an earlier
54
+ // toggle cannot overwrite the list the toggle now shows.
55
+ useEffect(() => {
56
+ let replaced = false;
57
+ serviceRef.current
58
+ .getUsers(includeDeleted)
59
+ .then((list) => {
60
+ if (!replaced) setUsers(list);
61
+ })
62
+ .catch((err: unknown) => {
63
+ if (!replaced) toast.error(reason(err, text.usersLoadFailed));
64
+ })
65
+ .finally(() => {
66
+ if (!replaced) setLoading(false);
67
+ });
68
+ return () => {
69
+ replaced = true;
70
+ };
71
+ }, [includeDeleted, reloads, text.usersLoadFailed]);
72
+
73
+ useEffect(() => {
74
+ getRolesRef
75
+ .current()
76
+ .then(setAllRoles)
77
+ .catch(() => toast.error(text.rolesLoadFailed));
78
+ }, [text.rolesLoadFailed]);
79
+
80
+ const reload = () => {
81
+ setLoading(true);
82
+ setReloads((n) => n + 1);
83
+ };
84
+
85
+ const toggleDeleted = (deleted: boolean) => {
86
+ setLoading(true);
87
+ setIncludeDeleted(deleted);
88
+ };
89
+
90
+ const setRoles = (id: string, roles: string[]) => setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, roles } : u)));
91
+
92
+ const assignRole = async (user: AdminUser) => {
93
+ if (!selectedRole) return;
94
+ setRoleLoading(true);
95
+ try {
96
+ await serviceRef.current.addRole(user.id, selectedRole);
97
+ setRoles(user.id, [...user.roles, selectedRole]);
98
+ toast.success(fill(text.roleAssigned, { name: user.firstName, role: selectedRole }));
99
+ setAddRoleFor(null);
100
+ setSelectedRole("");
101
+ } catch (err) {
102
+ toast.error(reason(err, text.roleAssignFailed));
103
+ } finally {
104
+ setRoleLoading(false);
105
+ }
106
+ };
107
+
108
+ const removeRole = async (user: AdminUser, role: string) => {
109
+ try {
110
+ await serviceRef.current.removeRole(user.id, role);
111
+ setRoles(user.id, user.roles.filter((r) => r !== role));
112
+ toast.success(fill(text.roleRemoved, { name: user.firstName, role }));
113
+ } catch (err) {
114
+ toast.error(reason(err, text.roleRemoveFailed));
115
+ }
116
+ };
117
+
118
+ const deleteUser = async (user: AdminUser) => {
119
+ const label = `${user.firstName} ${user.lastName}`.trim() || user.email;
120
+ if (!window.confirm(fill(text.confirmDelete, { name: label }))) return;
121
+ try {
122
+ await serviceRef.current.deleteUser(user.id);
123
+ toast.success(text.userDeleted);
124
+ reload();
125
+ } catch (err) {
126
+ toast.error(reason(err, text.userDeleteFailed));
127
+ }
128
+ };
129
+
130
+ const sendInvite = async () => {
131
+ if (!invite.email.trim() || !invite.firstName.trim() || !invite.lastName.trim()) {
132
+ toast.error(text.inviteIncomplete);
133
+ return;
134
+ }
135
+ setInviting(true);
136
+ try {
137
+ await serviceRef.current.invite(invite.email.trim(), invite.firstName.trim(), invite.lastName.trim(), invite.role);
138
+ toast.success(text.inviteSent);
139
+ setInvite({ email: "", firstName: "", lastName: "", role: invite.role });
140
+ reload();
141
+ } catch (err) {
142
+ toast.error(reason(err, text.inviteFailed));
143
+ } finally {
144
+ setInviting(false);
145
+ }
146
+ };
147
+
148
+ return (
149
+ <div className="space-y-4">
150
+ <div className="flex items-center justify-between">
151
+ <h2 className="font-bold text-gray-900">{text.users}</h2>
152
+ <Toggle label={text.showDeleted} checked={includeDeleted} onChange={toggleDeleted} />
153
+ </div>
154
+
155
+ <div className="rounded-2xl border border-gray-200 p-4 space-y-3">
156
+ <h3 className="text-sm font-semibold text-gray-900">{text.inviteUser}</h3>
157
+ <div className="flex flex-wrap items-end gap-2">
158
+ <div className="w-40">
159
+ <TextInput label={text.firstName} value={invite.firstName} onChange={(e) => setInvite({ ...invite, firstName: e.target.value })} />
160
+ </div>
161
+ <div className="w-40">
162
+ <TextInput label={text.lastName} value={invite.lastName} onChange={(e) => setInvite({ ...invite, lastName: e.target.value })} />
163
+ </div>
164
+ <div className="flex-1 min-w-[14rem]">
165
+ <TextInput label={text.email} type="email" value={invite.email} onChange={(e) => setInvite({ ...invite, email: e.target.value })} />
166
+ </div>
167
+ <label className="text-xs text-gray-600">
168
+ {text.role}
169
+ <select
170
+ value={invite.role}
171
+ onChange={(e) => setInvite({ ...invite, role: e.target.value })}
172
+ className="mt-1 block rounded-lg border border-gray-300 px-3 py-2 text-sm"
173
+ >
174
+ {allRoles.map((role) => (
175
+ <option key={role} value={role}>
176
+ {role}
177
+ </option>
178
+ ))}
179
+ </select>
180
+ </label>
181
+ <button
182
+ onClick={sendInvite}
183
+ disabled={inviting}
184
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-4 py-2 text-sm hover:brightness-95 disabled:opacity-60 transition"
185
+ >
186
+ {inviting ? text.sending : text.sendInvite}
187
+ </button>
188
+ </div>
189
+ <p className="text-xs text-gray-500">{text.inviteHint}</p>
190
+ </div>
191
+
192
+ {loading ? (
193
+ <p className="text-gray-500">{text.loading}</p>
194
+ ) : (
195
+ <ul className="divide-y divide-gray-100 rounded-2xl border border-gray-200">
196
+ {users.map((user) => {
197
+ const isAddingRole = addRoleFor === user.id;
198
+ const available = allRoles.filter((r) => !user.roles.includes(r));
199
+ return (
200
+ <li key={user.id} className="p-4 space-y-2">
201
+ <div className="flex items-start justify-between gap-3">
202
+ <div className={user.isDeleted ? "text-gray-400" : "text-gray-800"}>
203
+ <span className="font-medium">
204
+ {user.firstName} {user.lastName}
205
+ </span>
206
+ <span className="ml-2 text-xs text-gray-500">{user.email}</span>
207
+ {user.isDeleted && <span className="ml-2 text-xs text-gray-400">({text.deleted})</span>}
208
+ </div>
209
+ {!user.isDeleted && (
210
+ <button
211
+ onClick={() => deleteUser(user)}
212
+ title={text.delete}
213
+ className="shrink-0 p-2 rounded-lg text-red-500 hover:text-red-700 hover:bg-red-50 transition-colors"
214
+ >
215
+ <TrashIcon className="h-4 w-4" />
216
+ </button>
217
+ )}
218
+ </div>
219
+
220
+ <div className="flex flex-wrap items-center gap-1.5">
221
+ {user.roles.map((role) => (
222
+ <span key={role} className="flex items-center gap-1 pl-2 pr-1 py-0.5 bg-gray-100 border border-gray-200 rounded-lg text-xs text-gray-700">
223
+ {role}
224
+ <button onClick={() => removeRole(user, role)} className="hover:text-red-600 transition-colors" title={`${text.remove} ${role}`}>
225
+ <XMarkIcon className="h-3 w-3" />
226
+ </button>
227
+ </span>
228
+ ))}
229
+ {user.roles.length === 0 && <span className="text-xs text-gray-400">{text.noRoles}</span>}
230
+ {!isAddingRole && !user.isDeleted && available.length > 0 && (
231
+ <button
232
+ onClick={() => {
233
+ setAddRoleFor(user.id);
234
+ setSelectedRole(available[0] ?? "");
235
+ }}
236
+ className="flex items-center gap-1 px-2 py-0.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs text-gray-500 hover:text-gray-800 transition"
237
+ >
238
+ <PlusIcon className="h-3 w-3" /> {text.addRole}
239
+ </button>
240
+ )}
241
+ </div>
242
+
243
+ {isAddingRole && (
244
+ <div className="flex items-center gap-2">
245
+ <select
246
+ value={selectedRole}
247
+ onChange={(e) => setSelectedRole(e.target.value)}
248
+ aria-label={text.addRole}
249
+ className="rounded-lg border border-gray-300 px-2 py-1 text-sm"
250
+ >
251
+ {available.map((r) => (
252
+ <option key={r} value={r}>
253
+ {r}
254
+ </option>
255
+ ))}
256
+ </select>
257
+ <button
258
+ onClick={() => assignRole(user)}
259
+ disabled={roleLoading}
260
+ className="rounded-lg bg-gray-900 text-white text-xs px-3 py-1.5 hover:bg-gray-800 disabled:opacity-50 transition"
261
+ >
262
+ {roleLoading ? "…" : text.add}
263
+ </button>
264
+ <button onClick={() => setAddRoleFor(null)} className="rounded-lg border border-gray-300 text-gray-700 text-xs px-3 py-1.5 hover:bg-gray-50 transition">
265
+ {text.cancel}
266
+ </button>
267
+ </div>
268
+ )}
269
+ </li>
270
+ );
271
+ })}
272
+ </ul>
273
+ )}
274
+ </div>
275
+ );
276
+ }
@@ -0,0 +1,73 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /**
4
+ * The users panel's text. Pass the app's own language. The defaults are English. `{name}` and `{role}`
5
+ * are filled in where a text names them.
6
+ */
7
+ export interface AdminUsersPanelStrings {
8
+ users: string;
9
+ showDeleted: string;
10
+ inviteUser: string;
11
+ firstName: string;
12
+ lastName: string;
13
+ email: string;
14
+ role: string;
15
+ sendInvite: string;
16
+ sending: string;
17
+ inviteHint: string;
18
+ loading: string;
19
+ /** The tag after a deleted user's name. */
20
+ deleted: string;
21
+ delete: string;
22
+ /** Before the role name on the button that takes a role away. */
23
+ remove: string;
24
+ noRoles: string;
25
+ addRole: string;
26
+ add: string;
27
+ cancel: string;
28
+ usersLoadFailed: string;
29
+ rolesLoadFailed: string;
30
+ roleAssigned: string;
31
+ roleAssignFailed: string;
32
+ roleRemoved: string;
33
+ roleRemoveFailed: string;
34
+ confirmDelete: string;
35
+ userDeleted: string;
36
+ userDeleteFailed: string;
37
+ inviteIncomplete: string;
38
+ inviteSent: string;
39
+ inviteFailed: string;
40
+ }
41
+
42
+ export const defaultAdminUsersPanelStrings: AdminUsersPanelStrings = {
43
+ users: "Users",
44
+ showDeleted: "Show deleted",
45
+ inviteUser: "Invite user",
46
+ firstName: "First name",
47
+ lastName: "Last name",
48
+ email: "Email",
49
+ role: "Role",
50
+ sendInvite: "Send invitation",
51
+ sending: "Sending…",
52
+ inviteHint: "The user gets an email with a code and sets a password on the forgotten password page.",
53
+ loading: "Loading…",
54
+ deleted: "deleted",
55
+ delete: "Delete",
56
+ remove: "Remove",
57
+ noRoles: "No roles",
58
+ addRole: "Add role",
59
+ add: "Add",
60
+ cancel: "Cancel",
61
+ usersLoadFailed: "Failed to load users",
62
+ rolesLoadFailed: "Failed to load roles",
63
+ roleAssigned: "Added {role} to {name}",
64
+ roleAssignFailed: "Failed to assign role",
65
+ roleRemoved: "Removed {role} from {name}",
66
+ roleRemoveFailed: "Failed to remove role",
67
+ confirmDelete: "Delete {name}? Their name, email and password are removed, and they cannot sign in again.",
68
+ userDeleted: "User deleted",
69
+ userDeleteFailed: "Failed to delete user",
70
+ inviteIncomplete: "Fill in the name and email",
71
+ inviteSent: "Invitation sent. The user gets a code to set a password.",
72
+ inviteFailed: "Failed to send the invitation",
73
+ };
@@ -0,0 +1,6 @@
1
+ export { AdminShell } from "./AdminShell";
2
+ export type { AdminShellProps, AdminTab } from "./AdminShell";
3
+ export { AdminUsersPanel } from "./AdminUsersPanel";
4
+ export type { AdminUsersPanelProps } from "./AdminUsersPanel";
5
+ export { defaultAdminUsersPanelStrings } from "./adminUsersPanelStrings";
6
+ export type { AdminUsersPanelStrings } from "./adminUsersPanelStrings";
@@ -0,0 +1,24 @@
1
+ // The session fields this package's auth options put on next-auth's session and token. An app loads
2
+ // them with `import "@sjolystinnovation/app-kit/next-auth";` in one of its own .d.ts files.
3
+ import "next-auth";
4
+ import "next-auth/jwt";
5
+
6
+ declare module "next-auth" {
7
+ interface Session {
8
+ user: { id: string; name: string; email: string; roles: string[] };
9
+ accessToken: string;
10
+ error?: string | undefined;
11
+ absoluteExpiresAt?: number;
12
+ }
13
+ }
14
+
15
+ declare module "next-auth/jwt" {
16
+ interface JWT {
17
+ accessToken?: string;
18
+ refreshToken?: string;
19
+ refreshAt?: number;
20
+ loginAt?: number;
21
+ user?: { id: string; name: string; email: string; roles: string[] };
22
+ error?: string | undefined;
23
+ }
24
+ }
package/src/styles.css CHANGED
@@ -71,6 +71,24 @@
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
+
85
+ --color-admin-title: var(--color-gray-900);
86
+ --color-admin-border: var(--color-gray-200);
87
+ --color-admin-tab: var(--color-gray-500);
88
+ --color-admin-tab-hover: var(--color-gray-900);
89
+ --color-admin-tab-active: var(--color-gray-900);
90
+ --color-admin-tab-indicator: var(--color-primary);
91
+
74
92
  --radius-button: var(--radius-lg);
75
93
  --color-button-primary-bg: var(--color-primary);
76
94
  --color-button-primary-text: var(--color-primary-foreground);
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { authLinkClassName, authPageClassName, authTitleClassName } from "./authClasses";
6
+ import { CredentialsForm } from "./CredentialsForm";
7
+ import type { CredentialsFormStrings } from "./credentialsFormStrings";
8
+ import { defaultLoginPageStrings, type LoginPageStrings } from "./loginPageStrings";
9
+ import { withDefaults, type TextOverrides } from "./withDefaults";
10
+
11
+ export interface LoginPageProps {
12
+ /** Where a signed-in user goes. */
13
+ redirectTo: string;
14
+ /** The password reset page. */
15
+ resetHref: string;
16
+ /** The sign-up page. Without it the page has no register link. */
17
+ registerHref?: string | undefined;
18
+ strings?: TextOverrides<LoginPageStrings> | undefined;
19
+ /** The form's own text, passed to `CredentialsForm`. */
20
+ formStrings?: TextOverrides<CredentialsFormStrings> | undefined;
21
+ /** Replaces the page's outer spacing, such as when the page sits inside the app's own card. */
22
+ className?: string | undefined;
23
+ }
24
+
25
+ /**
26
+ * The sign-in page: a title, `CredentialsForm`, and links to reset the password and, when the app has
27
+ * one, to sign up. A sign-in goes to `redirectTo` and refreshes the page's server data.
28
+ */
29
+ export function LoginPage({ redirectTo, resetHref, registerHref, strings, formStrings, className = authPageClassName }: LoginPageProps) {
30
+ const text = withDefaults(defaultLoginPageStrings, strings);
31
+ const router = useRouter();
32
+
33
+ return (
34
+ <div className={className}>
35
+ <h1 className={`${authTitleClassName} mb-6`}>{text.title}</h1>
36
+ <CredentialsForm
37
+ strings={formStrings}
38
+ onSignedIn={() => {
39
+ router.push(redirectTo);
40
+ router.refresh();
41
+ }}
42
+ />
43
+ <p className="mt-6 text-sm text-auth-text">
44
+ <Link href={resetHref} className={authLinkClassName}>{text.forgotPassword}</Link>
45
+ </p>
46
+ {registerHref && (
47
+ <p className="mt-2 text-sm text-auth-text">
48
+ {text.registerPrompt && <>{text.registerPrompt} </>}
49
+ <Link href={registerHref} className={authLinkClassName}>{text.register}</Link>
50
+ </p>
51
+ )}
52
+ </div>
53
+ );
54
+ }
@@ -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 { authLinkClassName, authPageClassName, authTitleClassName } from "./authClasses";
8
+ import { PasswordInput } from "./PasswordInput";
9
+ import { defaultResetPasswordFlowStrings, type ResetPasswordFlowStrings } from "./resetPasswordFlowStrings";
10
+ import { TextInput } from "./TextInput";
11
+ import { withDefaults, type TextOverrides } from "./withDefaults";
12
+
13
+ export interface ResetPasswordFlowProps {
14
+ /** Asks the API to email a reset code, such as `createUserService`'s `requestPasswordReset`. */
15
+ requestReset: (data: { email: string }) => Promise<unknown>;
16
+ /** Sets the new password with the code, such as `createUserService`'s `resetPassword`. */
17
+ reset: (data: { email: string; code: string; newPassword: string }) => Promise<{ message?: string } | unknown>;
18
+ /** Where the sign-in links go. */
19
+ loginHref: string;
20
+ /** From the emailed link. A code opens the flow on its last step. */
21
+ initialEmail?: string | undefined;
22
+ initialCode?: string | undefined;
23
+ strings?: TextOverrides<ResetPasswordFlowStrings> | undefined;
24
+ /** Replaces the page's outer spacing, such as when the flow sits inside the app's own card. */
25
+ className?: string | undefined;
26
+ /** The password rule messages, for the app's own language. */
27
+ passwordStrings?: TextOverrides<PasswordStrings> | undefined;
28
+ }
29
+
30
+ const button =
31
+ "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
+
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 = authPageClassName,
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={`${authTitleClassName} 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={authLinkClassName}>{text.signIn}</Link>
99
+ </p>
100
+
101
+ {done !== null ? (
102
+ <Alert variant="success">
103
+ {done}
104
+ <Link href={loginHref} className={`block mt-3 ${authLinkClassName}`}>{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
+ }
@@ -0,0 +1,5 @@
1
+ // The classes the sign-in and password reset pages share, drawn from the auth and button tokens.
2
+
3
+ export const authPageClassName = "max-w-md mx-auto px-4 py-16";
4
+ export const authTitleClassName = "text-auth-title font-auth-title text-auth-heading";
5
+ export const authLinkClassName = "font-auth-link text-auth-link hover:text-auth-link-hover transition-colors";
package/src/ui/index.ts CHANGED
@@ -8,10 +8,18 @@ export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsForm
8
8
  export type { CredentialsFormStrings } from "./credentialsFormStrings";
9
9
  export { ErrorState, defaultErrorStateStrings } from "./ErrorState";
10
10
  export type { ErrorStateProps, ErrorStateStrings } from "./ErrorState";
11
+ export { LoginPage } from "./LoginPage";
12
+ export type { LoginPageProps } from "./LoginPage";
13
+ export { defaultLoginPageStrings } from "./loginPageStrings";
14
+ export type { LoginPageStrings } from "./loginPageStrings";
11
15
  export { NotFoundState, defaultNotFoundStrings } from "./NotFoundState";
12
16
  export type { NotFoundStateProps, NotFoundStrings } from "./NotFoundState";
13
17
  export { PasswordInput } from "./PasswordInput";
14
18
  export type { PasswordInputProps } from "./PasswordInput";
19
+ export { ResetPasswordFlow } from "./ResetPasswordFlow";
20
+ export type { ResetPasswordFlowProps } from "./ResetPasswordFlow";
21
+ export { defaultResetPasswordFlowStrings } from "./resetPasswordFlowStrings";
22
+ export type { ResetPasswordFlowStrings } from "./resetPasswordFlowStrings";
15
23
  export { TextArea } from "./TextArea";
16
24
  export type { TextAreaProps } from "./TextArea";
17
25
  export { TextInput } from "./TextInput";
@@ -0,0 +1,17 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** The sign-in page's text around the form. Pass the app's own language. The defaults are English. */
4
+ export interface LoginPageStrings {
5
+ title: string;
6
+ forgotPassword: string;
7
+ /** Shown before the register link, such as "No account?". Null leaves it out. */
8
+ registerPrompt: string | null;
9
+ register: string;
10
+ }
11
+
12
+ export const defaultLoginPageStrings: LoginPageStrings = {
13
+ title: "Sign in",
14
+ forgotPassword: "Forgot your password?",
15
+ registerPrompt: "No account?",
16
+ register: "Create one",
17
+ };
@@ -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
+ }