@sjolystinnovation/app-kit 0.19.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 +79 -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 +7 -0
- package/src/ui/LoginPage.tsx +54 -0
- package/src/ui/ResetPasswordFlow.tsx +5 -5
- package/src/ui/authClasses.ts +5 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/loginPageStrings.ts +17 -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,25 @@ 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
|
+
|
|
842
888
|
### `ResetPasswordFlow`
|
|
843
889
|
|
|
844
890
|
From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
|
|
@@ -1114,7 +1160,7 @@ selector such as `.dark` for a scoped theme:
|
|
|
1114
1160
|
| `--color-toggle-on` | `var(--color-primary)`, the track of a `Toggle` that is on |
|
|
1115
1161
|
| `--color-toggle-off` | gray 200, the track of a `Toggle` that is off |
|
|
1116
1162
|
| `--color-toggle-thumb` | white |
|
|
1117
|
-
| `--text-auth-title`, `--text-auth-title--line-height` | `var(--text-2xl)` and its line height, the title of `ResetPasswordFlow` |
|
|
1163
|
+
| `--text-auth-title`, `--text-auth-title--line-height` | `var(--text-2xl)` and its line height, the title of `LoginPage` and `ResetPasswordFlow` |
|
|
1118
1164
|
| `--font-weight-auth-title` | black |
|
|
1119
1165
|
| `--color-auth-heading` | gray 900 |
|
|
1120
1166
|
| `--color-auth-text` | gray 600, the line under the title and the intro |
|
|
@@ -1122,6 +1168,11 @@ selector such as `.dark` for a scoped theme:
|
|
|
1122
1168
|
| `--color-auth-hint` | gray 500, the hint under the password |
|
|
1123
1169
|
| `--color-auth-link`, `-hover` | gray 900, the sign-in links |
|
|
1124
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 |
|
|
1125
1176
|
| `--radius-button` | `var(--radius-lg)` |
|
|
1126
1177
|
| `--color-button-primary-bg` | `var(--color-primary)` |
|
|
1127
1178
|
| `--color-button-primary-text` | `var(--color-primary-foreground)` |
|
|
@@ -1164,9 +1215,9 @@ with it and has to set the ones it uses itself.
|
|
|
1164
1215
|
- For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
|
|
1165
1216
|
copy, because two copies keep two separate toast stores.
|
|
1166
1217
|
- `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
|
|
1167
|
-
- For `@sjolystinnovation/app-kit/ui` and the components in
|
|
1168
|
-
`@heroicons/react` 2. The focus ring and the primary button
|
|
1169
|
-
`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.
|
|
1170
1221
|
- TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
|
|
1171
1222
|
`node16` and `nodenext` reject.
|
|
1172
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
|
@@ -82,6 +82,13 @@
|
|
|
82
82
|
--color-auth-link-hover: var(--color-gray-900);
|
|
83
83
|
--font-weight-auth-link: var(--font-weight-semibold);
|
|
84
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
|
+
|
|
85
92
|
--radius-button: var(--radius-lg);
|
|
86
93
|
--color-button-primary-bg: var(--color-primary);
|
|
87
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
|
+
}
|
|
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|
|
4
4
|
import { useState, type FormEvent } from "react";
|
|
5
5
|
import { passwordProblems, type PasswordStrings } from "../validation/passwordRules";
|
|
6
6
|
import { Alert } from "./Alert";
|
|
7
|
+
import { authLinkClassName, authPageClassName, authTitleClassName } from "./authClasses";
|
|
7
8
|
import { PasswordInput } from "./PasswordInput";
|
|
8
9
|
import { defaultResetPasswordFlowStrings, type ResetPasswordFlowStrings } from "./resetPasswordFlowStrings";
|
|
9
10
|
import { TextInput } from "./TextInput";
|
|
@@ -28,7 +29,6 @@ export interface ResetPasswordFlowProps {
|
|
|
28
29
|
|
|
29
30
|
const button =
|
|
30
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";
|
|
31
|
-
const link = "font-auth-link text-auth-link hover:text-auth-link-hover transition-colors";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Password reset in two steps: the email asks for a code, then the code and a new password set it. The
|
|
@@ -42,7 +42,7 @@ export function ResetPasswordFlow({
|
|
|
42
42
|
initialEmail = "",
|
|
43
43
|
initialCode = "",
|
|
44
44
|
strings,
|
|
45
|
-
className =
|
|
45
|
+
className = authPageClassName,
|
|
46
46
|
passwordStrings,
|
|
47
47
|
}: ResetPasswordFlowProps) {
|
|
48
48
|
const text = withDefaults(defaultResetPasswordFlowStrings, strings);
|
|
@@ -92,16 +92,16 @@ export function ResetPasswordFlow({
|
|
|
92
92
|
|
|
93
93
|
return (
|
|
94
94
|
<div className={className}>
|
|
95
|
-
<h1 className=
|
|
95
|
+
<h1 className={`${authTitleClassName} mb-1`}>{text.title}</h1>
|
|
96
96
|
<p className="text-sm text-auth-text mb-6">
|
|
97
97
|
{text.signInPrompt && <>{text.signInPrompt} </>}
|
|
98
|
-
<Link href={loginHref} className={
|
|
98
|
+
<Link href={loginHref} className={authLinkClassName}>{text.signIn}</Link>
|
|
99
99
|
</p>
|
|
100
100
|
|
|
101
101
|
{done !== null ? (
|
|
102
102
|
<Alert variant="success">
|
|
103
103
|
{done}
|
|
104
|
-
<Link href={loginHref} className={`block mt-3 ${
|
|
104
|
+
<Link href={loginHref} className={`block mt-3 ${authLinkClassName}`}>{text.backToSignIn}</Link>
|
|
105
105
|
</Alert>
|
|
106
106
|
) : step === 1 ? (
|
|
107
107
|
<form onSubmit={sendCode} className="space-y-4">
|
|
@@ -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,6 +8,10 @@ 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";
|
|
@@ -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
|
+
};
|