@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 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,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 `session/react`: Tailwind CSS 4 and
1168
- `@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
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.19.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 = "max-w-md mx-auto px-4 py-16",
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="text-auth-title font-auth-title text-auth-heading mb-1">{text.title}</h1>
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={link}>{text.signIn}</Link>
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 ${link}`}>{text.backToSignIn}</Link>
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
+ };