@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 +121 -28
- package/package.json +5 -1
- package/src/admin/AdminShell.tsx +53 -0
- package/src/admin/AdminUsersPanel.tsx +276 -0
- package/src/admin/adminUsersPanelStrings.ts +73 -0
- package/src/admin/index.ts +6 -0
- package/src/next-auth.d.ts +24 -0
- package/src/styles.css +18 -0
- package/src/ui/LoginPage.tsx +54 -0
- package/src/ui/ResetPasswordFlow.tsx +130 -0
- package/src/ui/authClasses.ts +5 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/loginPageStrings.ts +17 -0
- package/src/ui/resetPasswordFlowStrings.ts +52 -0
- package/src/validation/index.ts +1 -1
- package/src/validation/password.ts +3 -17
- package/src/validation/passwordRules.ts +35 -0
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
|
|
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
|
|
1126
|
-
`@heroicons/react` 2. The focus ring and the primary button
|
|
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.
|
|
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
|
+
};
|
package/src/validation/index.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
export
|
|
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
|
+
}
|