@sjolystinnovation/app-kit 0.4.0 → 0.6.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 +301 -72
- package/package.json +10 -4
- package/src/api/createApiClient.ts +43 -0
- package/src/api/index.ts +2 -0
- package/src/api/request.ts +22 -0
- package/src/auth/createAuthOptions.ts +313 -0
- package/src/auth/index.ts +2 -0
- package/src/config.ts +20 -9
- package/src/errors.ts +12 -8
- package/src/forms/useFormDraft.ts +20 -22
- package/src/index.ts +1 -1
- package/src/session/expiry.ts +17 -6
- package/src/session/index.ts +1 -0
- package/src/session/react/ProtectedGate.tsx +34 -0
- package/src/session/react/SessionExpiryGuard.tsx +294 -0
- package/src/session/react/index.ts +6 -0
- package/src/session/react/sessionExpiryGuardStrings.ts +26 -0
- package/src/session/react/useSessionGate.ts +6 -8
- package/src/styles.css +16 -3
- package/src/ui/Alert.tsx +6 -6
- package/src/ui/CredentialsForm.tsx +113 -0
- package/src/ui/PasswordInput.tsx +12 -31
- package/src/ui/TextInput.tsx +28 -0
- package/src/ui/credentialsFormStrings.ts +29 -0
- package/src/ui/field.tsx +51 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/withDefaults.ts +14 -0
package/README.md
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
> Building blocks for Next.js apps that sign in against a JWT API through next-auth
|
|
7
7
|
|
|
8
|
-
Several apps sharing one API also share the code around it:
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Several apps sharing one API also share the code around it:
|
|
9
|
+
|
|
10
|
+
- the session plumbing
|
|
11
|
+
- how a failed request becomes a message a user can read
|
|
12
|
+
- the form controls on the sign in page
|
|
13
|
+
|
|
14
|
+
This package holds that code once, and each app installs it.
|
|
11
15
|
|
|
12
16
|
## Install
|
|
13
17
|
|
|
@@ -24,8 +28,8 @@ const nextConfig: NextConfig = {
|
|
|
24
28
|
};
|
|
25
29
|
```
|
|
26
30
|
|
|
27
|
-
The components in `@sjolystinnovation/app-kit/ui` are styled with Tailwind CSS.
|
|
28
|
-
package's stylesheet after Tailwind:
|
|
31
|
+
The components in `@sjolystinnovation/app-kit/ui` and `session/react` are styled with Tailwind CSS.
|
|
32
|
+
Import the package's stylesheet after Tailwind:
|
|
29
33
|
|
|
30
34
|
```css
|
|
31
35
|
/* src/app/globals.css */
|
|
@@ -70,15 +74,23 @@ const secret = resolveJwtSecret(sessionConfig, process.env);
|
|
|
70
74
|
|
|
71
75
|
`(config: SessionConfig) => SessionConfig`
|
|
72
76
|
|
|
73
|
-
Returns the config unchanged, so TypeScript checks its shape where it is declared
|
|
74
|
-
|
|
77
|
+
Returns the config unchanged, so TypeScript checks its shape once, where it is declared. Throws
|
|
78
|
+
when `draftStoragePrefix` is empty or contains a colon.
|
|
79
|
+
|
|
80
|
+
### `requireEnv(name, value)`
|
|
81
|
+
|
|
82
|
+
`(name: string, value: string | undefined) => string`
|
|
83
|
+
|
|
84
|
+
Returns `value`, or throws `Missing <name>.` when it is unset or empty. Pass the value read in
|
|
85
|
+
place, such as `process.env.NEXT_PUBLIC_API_URL`, so Next.js can inline a public variable at build
|
|
86
|
+
time.
|
|
75
87
|
|
|
76
88
|
### `resolveJwtSecret(config, env)`
|
|
77
89
|
|
|
78
90
|
`(config: SessionConfig, env: Record<string, string | undefined>) => string`
|
|
79
91
|
|
|
80
|
-
Reads the secret named by `config.jwtSecretEnvVar`. Throws when that variable is unset or empty,
|
|
81
|
-
|
|
92
|
+
Reads the secret named by `config.jwtSecretEnvVar`. Throws when that variable is unset or empty,
|
|
93
|
+
with the variable's name in the message.
|
|
82
94
|
|
|
83
95
|
```ts
|
|
84
96
|
resolveJwtSecret(sessionConfig, { API_JWT_SECRET: "s3cret" });
|
|
@@ -88,8 +100,8 @@ resolveJwtSecret(sessionConfig, {});
|
|
|
88
100
|
// Error: Missing API_JWT_SECRET. The app verifies API session tokens with it.
|
|
89
101
|
```
|
|
90
102
|
|
|
91
|
-
The environment is a parameter
|
|
92
|
-
|
|
103
|
+
The environment is a parameter, so the root entry point needs no Node types and works in browser
|
|
104
|
+
code. `@sjolystinnovation/app-kit/auth` is server only and does load Node types.
|
|
93
105
|
|
|
94
106
|
### `formatApiError(error, fallback, statusMessages?)`
|
|
95
107
|
|
|
@@ -110,11 +122,16 @@ body can come from a proxy, such as "Bad Gateway", so the caller's own words win
|
|
|
110
122
|
A problem details `title` is never shown. ASP.NET Core fills it with generic text, such as "Bad
|
|
111
123
|
Request" or "An error occurred while processing your request.", which says less than `fallback`.
|
|
112
124
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
A few more rules:
|
|
126
|
+
|
|
127
|
+
- Key `0` in `statusMessages` covers a request that got no response, because of a dropped
|
|
128
|
+
connection or a timeout.
|
|
129
|
+
- Only text on a single line counts as a message.
|
|
130
|
+
- Axios's own message, such as "Request failed with status code 500", is never shown.
|
|
131
|
+
- A cancelled request gives `fallback`.
|
|
132
|
+
- Any other `Error` gives its own message.
|
|
133
|
+
- A body requested as an `arraybuffer` or a `blob`, as for a file download, is not read. A failed
|
|
134
|
+
download gets `statusMessages` or `fallback`.
|
|
118
135
|
|
|
119
136
|
```ts
|
|
120
137
|
try {
|
|
@@ -124,44 +141,185 @@ try {
|
|
|
124
141
|
}
|
|
125
142
|
```
|
|
126
143
|
|
|
144
|
+
### `createAuthOptions(config, settings)`
|
|
145
|
+
|
|
146
|
+
From `@sjolystinnovation/app-kit/auth`. Server only. Builds the next-auth options for an app that
|
|
147
|
+
signs in against the API with email and password:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// src/lib/authOptions.ts
|
|
151
|
+
import { requireEnv } from "@sjolystinnovation/app-kit";
|
|
152
|
+
import { createAuthOptions } from "@sjolystinnovation/app-kit/auth";
|
|
153
|
+
import { sessionConfig } from "@/lib/session";
|
|
154
|
+
|
|
155
|
+
export const authOptions = createAuthOptions(sessionConfig, {
|
|
156
|
+
apiUrl: requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
|
|
157
|
+
env: process.env,
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// src/app/api/auth/[...nextauth]/route.ts
|
|
163
|
+
import NextAuth from "next-auth/next";
|
|
164
|
+
import { authOptions } from "@/lib/authOptions";
|
|
165
|
+
|
|
166
|
+
const handler = NextAuth(authOptions);
|
|
167
|
+
|
|
168
|
+
export { handler as GET, handler as POST };
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Setting | Type | Description |
|
|
172
|
+
| --- | --- | --- |
|
|
173
|
+
| `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login` and refresh to `/auth/refresh-token` under it. An empty value throws. |
|
|
174
|
+
| `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret named by `jwtSecretEnvVar`. |
|
|
175
|
+
| `http` | `AxiosInstance` | In place of `apiUrl`, for tests. The client for sign-in and refresh, with its own base URL. |
|
|
176
|
+
|
|
177
|
+
next-auth reads `NEXTAUTH_SECRET` itself and refuses to run in production without it.
|
|
178
|
+
|
|
179
|
+
What the options do:
|
|
180
|
+
|
|
181
|
+
- **Sign-in** posts `{ email, password }` and expects `{ token, refreshToken }`. The access token is
|
|
182
|
+
verified with the API JWT secret, allowing 60 seconds of clock difference between the server and
|
|
183
|
+
the API. Its `sub`, `unique_name` and `role` claims become the user's `id`, `name` and `roles`.
|
|
184
|
+
The log gets the status, error code and message, never the request. A failed sign-in reports one
|
|
185
|
+
of these in next-auth's `error`:
|
|
186
|
+
- `SIGN_IN_ERRORS.unavailable` when the API does not answer, or a gateway returns 502, 503 or 504.
|
|
187
|
+
- `SIGN_IN_ERRORS.invalidCredentials` for every other failure, including an unreadable token and
|
|
188
|
+
an answer without both tokens.
|
|
189
|
+
- **Refresh** runs ahead of expiry: five minutes before it, or a quarter of the token's lifetime
|
|
190
|
+
before it when that is shorter. It is never scheduled sooner than 30 seconds out, so a token that
|
|
191
|
+
lives under 30 seconds is refreshed after it expires. Parallel reads of one session in one server
|
|
192
|
+
process share a single refresh. Its answer is kept for a minute, because the API rotates the
|
|
193
|
+
refresh token on every call.
|
|
194
|
+
- **Failures:** a 400 or 401 from the refresh ends the session with `RefreshTokenRejected`. Any
|
|
195
|
+
other failure keeps it and tries again a minute later. A session without a refresh token ends with
|
|
196
|
+
`NoRefreshToken` when its refresh falls due.
|
|
197
|
+
- **Cap:** the session ends seven days after sign-in with `AbsoluteSessionExpired`, however recently
|
|
198
|
+
it was refreshed.
|
|
199
|
+
- **Session:** it carries `user`, `accessToken`, `error`, and `absoluteExpiresAt`, the end of the
|
|
200
|
+
seven days in milliseconds.
|
|
201
|
+
- **Sign-in page:** `pages.signIn` is `config.loginRoute`.
|
|
202
|
+
|
|
203
|
+
Two rules keep a refresh from being lost:
|
|
204
|
+
|
|
205
|
+
- Read the session on the client, through `useSession` or `getSession`. In the App Router,
|
|
206
|
+
`getServerSession(authOptions)` cannot store a cookie, in server components, route handlers and
|
|
207
|
+
server actions alike. A refresh there replaces the tokens at the API and loses the new ones. The
|
|
208
|
+
next refresh then reuses the old token, and the API revokes every session the user has. On the
|
|
209
|
+
server, read the token with `getToken` from `next-auth/jwt`, which never refreshes.
|
|
210
|
+
- Run the app as one server process. Parallel session reads share one refresh only within a process.
|
|
211
|
+
Two replicas can refresh the same token at once, with the same result.
|
|
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:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
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
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### `@sjolystinnovation/app-kit/api`
|
|
243
|
+
|
|
244
|
+
`createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
|
|
245
|
+
the browser. It reads the session through next-auth's `getSession`, which has no cookies to read on
|
|
246
|
+
the server. An empty `baseURL` throws. Each request carries the session's access token. A 401
|
|
247
|
+
opens the re-sign-in prompt when the session is gone or has a terminal error. It does not sign
|
|
248
|
+
the user out, so the page keeps its state. The error still rejects as usual.
|
|
249
|
+
|
|
250
|
+
`request(call, fallback, statusMessages?)` runs a call and returns its `data`. A failure becomes an
|
|
251
|
+
`Error` with the message `formatApiError` gives it. A cancelled call rethrows the cancel unchanged:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import { requireEnv } from "@sjolystinnovation/app-kit";
|
|
255
|
+
import { createApiClient, request } from "@sjolystinnovation/app-kit/api";
|
|
256
|
+
|
|
257
|
+
const api = createApiClient(requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL));
|
|
258
|
+
|
|
259
|
+
export const getRecipe = (id: number) =>
|
|
260
|
+
request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
|
|
261
|
+
```
|
|
262
|
+
|
|
127
263
|
### `@sjolystinnovation/app-kit/session`
|
|
128
264
|
|
|
129
265
|
Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
|
|
130
266
|
callbacks.
|
|
131
267
|
|
|
132
|
-
The gate reads `session.error`.
|
|
133
|
-
|
|
268
|
+
The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
|
|
269
|
+
countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
|
|
270
|
+
to fill in the prompt. `createAuthOptions` sets all of these. An app with its own next-auth options sets them in its
|
|
271
|
+
callbacks:
|
|
134
272
|
|
|
135
273
|
```ts
|
|
274
|
+
const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
|
275
|
+
|
|
136
276
|
callbacks: {
|
|
277
|
+
jwt({ token, user }) {
|
|
278
|
+
if (user) token.loginAt = Date.now();
|
|
279
|
+
// A token without a sign-in time counts as expired.
|
|
280
|
+
if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
|
|
281
|
+
token.error = SESSION_ERRORS.absoluteExpiry;
|
|
282
|
+
}
|
|
283
|
+
// Also set token.error when a refresh is refused.
|
|
284
|
+
return token;
|
|
285
|
+
},
|
|
137
286
|
session({ session, token }) {
|
|
287
|
+
session.user.id = token.sub ?? "";
|
|
138
288
|
session.error = token.error;
|
|
289
|
+
session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
|
|
139
290
|
return session;
|
|
140
291
|
},
|
|
141
292
|
},
|
|
142
293
|
```
|
|
143
294
|
|
|
144
|
-
For that to compile,
|
|
295
|
+
For that to compile, these fields have to be on next-auth's `Session` and `JWT` types. Add them to
|
|
145
296
|
the app's existing next-auth type declarations, or create them:
|
|
146
297
|
|
|
147
298
|
```ts
|
|
148
299
|
// src/types/next-auth.d.ts
|
|
149
300
|
import "next-auth";
|
|
150
301
|
import "next-auth/jwt";
|
|
302
|
+
import type { DefaultSession } from "next-auth";
|
|
151
303
|
|
|
152
304
|
declare module "next-auth" {
|
|
153
305
|
interface Session {
|
|
306
|
+
user: { id: string } & DefaultSession["user"];
|
|
154
307
|
error?: string | undefined;
|
|
308
|
+
absoluteExpiresAt?: number | undefined;
|
|
155
309
|
}
|
|
156
310
|
}
|
|
157
311
|
|
|
158
312
|
declare module "next-auth/jwt" {
|
|
159
313
|
interface JWT {
|
|
160
314
|
error?: string | undefined;
|
|
315
|
+
loginAt?: number | undefined;
|
|
161
316
|
}
|
|
162
317
|
}
|
|
163
318
|
```
|
|
164
319
|
|
|
320
|
+
`SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
|
|
321
|
+
`SignInUnavailable`. Map them to the app's own messages.
|
|
322
|
+
|
|
165
323
|
`SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
|
|
166
324
|
recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
|
|
167
325
|
is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
|
|
@@ -178,51 +336,26 @@ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise
|
|
|
178
336
|
|
|
179
337
|
Copies of the package installed side by side share the one flag, as long as their releases use the
|
|
180
338
|
same prompt state format. A release that changes the format says so in its changelog. An app's own
|
|
181
|
-
copy of these functions does not
|
|
182
|
-
|
|
339
|
+
copy of these functions does not share the flag. So the interceptor, the prompt and every gate all
|
|
340
|
+
import them from this package.
|
|
183
341
|
|
|
184
342
|
### `useSessionGate()`
|
|
185
343
|
|
|
186
344
|
From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
|
|
187
345
|
reads `useSession`, so it needs a `SessionProvider` above it.
|
|
188
346
|
|
|
189
|
-
A page that rendered on a healthy session stays rendered
|
|
190
|
-
while the session is re-read and while the prompt recovers it, so an open form keeps its values. A
|
|
191
|
-
session that turns signed out with no prompt open blanks the page. A page the user opens
|
|
192
|
-
on a dead session does not render. Use `mayRender` in every gate and layout on the way down, so
|
|
193
|
-
none of them blanks a page that another one kept.
|
|
347
|
+
A page that rendered on a healthy session stays rendered, so an open form keeps its values:
|
|
194
348
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
349
|
+
- when the session gets a terminal error
|
|
350
|
+
- while the session is re-read
|
|
351
|
+
- while the prompt recovers it
|
|
198
352
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
import { useSessionGate } from "@sjolystinnovation/app-kit/session/react";
|
|
203
|
-
import { useRouter } from "next/navigation";
|
|
204
|
-
import { useEffect } from "react";
|
|
205
|
-
|
|
206
|
-
export function ProtectedGate({
|
|
207
|
-
loginHref,
|
|
208
|
-
children,
|
|
209
|
-
}: {
|
|
210
|
-
loginHref: string;
|
|
211
|
-
children: React.ReactNode;
|
|
212
|
-
}) {
|
|
213
|
-
const router = useRouter();
|
|
214
|
-
const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
|
|
215
|
-
|
|
216
|
-
useEffect(() => {
|
|
217
|
-
if (promptOpen) return;
|
|
218
|
-
if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
|
|
219
|
-
router.push(loginHref);
|
|
220
|
-
}
|
|
221
|
-
}, [status, usable, wasUsable, promptOpen, router, loginHref]);
|
|
353
|
+
A session that turns signed out with no prompt open blanks the page. A page the user opens on a
|
|
354
|
+
dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
|
|
355
|
+
them blanks a page that another one kept.
|
|
222
356
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
```
|
|
357
|
+
The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
|
|
358
|
+
layout that needs its own decision, such as a role check, so it agrees with the gate.
|
|
226
359
|
|
|
227
360
|
| Field | Description |
|
|
228
361
|
| --- | --- |
|
|
@@ -235,6 +368,50 @@ export function ProtectedGate({
|
|
|
235
368
|
|
|
236
369
|
`SessionGate` is the type of the result.
|
|
237
370
|
|
|
371
|
+
### `ProtectedGate`
|
|
372
|
+
|
|
373
|
+
From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It renders the
|
|
374
|
+
page only when `useSessionGate` allows it, and sends a signed-out visitor or a session dead on
|
|
375
|
+
arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
<ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
|
|
379
|
+
{children}
|
|
380
|
+
</ProtectedGate>
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
| Prop | Type | Description |
|
|
384
|
+
| --- | --- | --- |
|
|
385
|
+
| `loginHref` | `string` | Where the redirect goes. An app with a locale in the path builds it for the current locale. |
|
|
386
|
+
| `fallback` | `ReactNode` | What shows while the page may not render yet. Defaults to nothing. |
|
|
387
|
+
|
|
388
|
+
### `SessionExpiryGuard`
|
|
389
|
+
|
|
390
|
+
From `@sjolystinnovation/app-kit/session/react`. Render it once, inside the `SessionProvider`. It
|
|
391
|
+
shows:
|
|
392
|
+
|
|
393
|
+
- a banner in the last 30 minutes before `session.absoluteExpiresAt`, with the minutes left. That is
|
|
394
|
+
seven days after sign-in with `createAuthOptions`.
|
|
395
|
+
- a banner when the session is dead
|
|
396
|
+
- the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
|
|
397
|
+
|
|
398
|
+
A sign-in in the prompt keeps the page and its form. A sign-in in the prompt as a different user is
|
|
399
|
+
signed out and sent to `loginHref`, so the prompt never hands the page to another user.
|
|
400
|
+
|
|
401
|
+
```tsx
|
|
402
|
+
<SessionExpiryGuard
|
|
403
|
+
loginHref="/login"
|
|
404
|
+
onRestored={() => toast.success("You are signed in again.")}
|
|
405
|
+
/>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
| Prop | Type | Description |
|
|
409
|
+
| --- | --- | --- |
|
|
410
|
+
| `loginHref` | `string` | Where a sign-in as a different user is sent after it is signed out. |
|
|
411
|
+
| `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
|
|
412
|
+
| `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
|
|
413
|
+
| `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
|
|
414
|
+
|
|
238
415
|
### `useFormDraft(config, options)`
|
|
239
416
|
|
|
240
417
|
From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
|
|
@@ -260,15 +437,52 @@ Mount an edit form once the entity has loaded, with its values already in `value
|
|
|
260
437
|
the values it first sees under a key as the untouched form, so values that arrive later count as an
|
|
261
438
|
edit and are written over the draft being offered.
|
|
262
439
|
|
|
263
|
-
It returns
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
440
|
+
It returns:
|
|
441
|
+
|
|
442
|
+
- `pending`, the stored draft. The page offers it to the user and does not apply it. It is read
|
|
443
|
+
when the owner is first known and again whenever the owner or `scope` changes, so it can appear
|
|
444
|
+
after the form mounts.
|
|
445
|
+
- `dismiss()`, to stop offering it.
|
|
446
|
+
- `clear()`, to drop the stored draft after a successful save.
|
|
267
447
|
|
|
268
448
|
Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
|
|
269
449
|
`draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
|
|
270
450
|
removed when its form next reads it.
|
|
271
451
|
|
|
452
|
+
### `CredentialsForm`
|
|
453
|
+
|
|
454
|
+
From `@sjolystinnovation/app-kit/ui`. Email and password sign-in through next-auth's credentials
|
|
455
|
+
provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
|
|
456
|
+
the prompt.
|
|
457
|
+
|
|
458
|
+
```tsx
|
|
459
|
+
<CredentialsForm onSignedIn={() => router.push("/")} strings={{ signIn: "Logg inn" }} />
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
| Prop | Type | Description |
|
|
463
|
+
| --- | --- | --- |
|
|
464
|
+
| `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected` with a message to refuse the sign-in. |
|
|
465
|
+
| `initialEmail` | `string` | The email the field starts with. |
|
|
466
|
+
| `children` | `ReactNode` | Extra buttons next to the submit button. |
|
|
467
|
+
| `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
|
|
468
|
+
| `onPendingChange` | `(pending: boolean) => void` | Runs when a sign-in starts and ends, such as to keep a dialog open until it finishes. |
|
|
469
|
+
|
|
470
|
+
A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
|
|
471
|
+
`invalidCredentials` for any other error code. A request next-auth refuses without an error code,
|
|
472
|
+
or one that throws, shows `somethingWentWrong`.
|
|
473
|
+
|
|
474
|
+
`TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
|
|
475
|
+
key is optional and may be `undefined`, and a missing or undefined key shows the English default.
|
|
476
|
+
|
|
477
|
+
### `TextInput`
|
|
478
|
+
|
|
479
|
+
From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look and props as
|
|
480
|
+
`PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
|
|
481
|
+
|
|
482
|
+
```tsx
|
|
483
|
+
<TextInput label="Email" name="email" type="email" required error={errors.email} />
|
|
484
|
+
```
|
|
485
|
+
|
|
272
486
|
### `Alert`
|
|
273
487
|
|
|
274
488
|
```tsx
|
|
@@ -280,8 +494,8 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
|
|
|
280
494
|
| Prop | Type | Default | Description |
|
|
281
495
|
| --- | --- | --- | --- |
|
|
282
496
|
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
|
|
283
|
-
| `role` | `"alert" \| "status"` | `"alert"` |
|
|
284
|
-
| `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables
|
|
497
|
+
| `role` | `"alert" \| "status" \| "none"` | `"alert"` | A screen reader announces `status` without interrupting, on every change of its text. Use `none` when a separate live region announces the text. |
|
|
498
|
+
| `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables. A clashing class wins or loses by the order Tailwind emits them in. |
|
|
285
499
|
| `children` | `ReactNode` | | Content. |
|
|
286
500
|
|
|
287
501
|
### `PasswordInput`
|
|
@@ -302,7 +516,7 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
|
302
516
|
| `error` | `string` | | Message shown under the field. It turns the border red, marks the input invalid and is read out with it by screen readers. |
|
|
303
517
|
| `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
|
|
304
518
|
| `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
|
|
305
|
-
| `className` | `string` | | Classes for placing the field, such as a margin. They go on the outer element around the label, input and error. Restyle the field through the theme variables
|
|
519
|
+
| `className` | `string` | | Classes for placing the field, such as a margin. They go on the outer element around the label, input and error. Restyle the field through the theme variables. |
|
|
306
520
|
|
|
307
521
|
Pass the two toggle labels in the app's language. The defaults are English.
|
|
308
522
|
|
|
@@ -349,25 +563,40 @@ selector such as `.dark` for a scoped theme:
|
|
|
349
563
|
| `--input-focus-ring-width` | `2px` |
|
|
350
564
|
| `--color-input-toggle` | gray 400, the show password icon |
|
|
351
565
|
| `--color-input-toggle-hover` | gray 600 |
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
`--color-
|
|
566
|
+
| `--radius-button` | `var(--radius-lg)` |
|
|
567
|
+
| `--color-button-primary-bg` | `var(--color-primary)` |
|
|
568
|
+
| `--color-button-primary-text` | `var(--color-primary-foreground)` |
|
|
569
|
+
| `--color-button-secondary-border` | gray 300 |
|
|
570
|
+
| `--color-button-secondary-text` | gray 700 |
|
|
571
|
+
| `--color-button-secondary-hover-bg` | gray 50 |
|
|
572
|
+
| `--radius-dialog` | `var(--radius-xl)` |
|
|
573
|
+
| `--color-dialog-backdrop` | black at 40% |
|
|
574
|
+
| `--color-dialog-bg` | white |
|
|
575
|
+
| `--color-dialog-title` | gray 900 |
|
|
576
|
+
| `--color-dialog-text` | gray 600 |
|
|
577
|
+
|
|
578
|
+
A default that points at another variable is read once for the whole page. The focus ring and the
|
|
579
|
+
primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
|
|
580
|
+
changes those also sets `--color-input-focus-ring`, `--color-button-primary-bg` and
|
|
581
|
+
`--color-button-primary-text` in the same selector.
|
|
356
582
|
|
|
357
583
|
An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
|
|
358
584
|
with it and has to set the ones it uses itself.
|
|
359
585
|
|
|
360
586
|
## Requirements
|
|
361
587
|
|
|
362
|
-
- Next.js 16, React 19, next-auth 4 and axios 1, as peer dependencies the
|
|
363
|
-
|
|
364
|
-
|
|
588
|
+
- Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
|
|
589
|
+
app installs itself.
|
|
590
|
+
- `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
|
|
591
|
+
- For `@sjolystinnovation/app-kit/ui` and the components in `session/react`: Tailwind CSS 4 and
|
|
592
|
+
`@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
|
|
593
|
+
`primary-foreground` colours, unless their own variables are set.
|
|
365
594
|
- TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
|
|
366
595
|
`node16` and `nodenext` reject.
|
|
367
596
|
|
|
368
|
-
There is no build output.
|
|
369
|
-
|
|
370
|
-
`
|
|
597
|
+
There is no build output. Bundlers strip or relocate `"use client"` directives, and shipping source
|
|
598
|
+
keeps them intact. The consumer compiles this source with its own `compilerOptions`, and
|
|
599
|
+
`skipLibCheck` does not exempt it.
|
|
371
600
|
|
|
372
601
|
## License
|
|
373
602
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjolystinnovation/app-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"./session": "./src/session/index.ts",
|
|
20
20
|
"./session/react": "./src/session/react/index.ts",
|
|
21
21
|
"./forms": "./src/forms/index.ts",
|
|
22
|
+
"./auth": "./src/auth/index.ts",
|
|
23
|
+
"./api": "./src/api/index.ts",
|
|
22
24
|
"./ui": "./src/ui/index.ts",
|
|
23
25
|
"./styles.css": "./src/styles.css"
|
|
24
26
|
},
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
},
|
|
36
38
|
"peerDependencies": {
|
|
37
39
|
"@heroicons/react": "^2",
|
|
38
|
-
"axios": "^1",
|
|
40
|
+
"axios": "^1.20.0",
|
|
39
41
|
"next": "^16",
|
|
40
42
|
"next-auth": "^4.24",
|
|
41
43
|
"react": "^19",
|
|
@@ -49,7 +51,7 @@
|
|
|
49
51
|
"@types/react": "^19",
|
|
50
52
|
"@types/react-dom": "^19",
|
|
51
53
|
"@vitejs/plugin-react": "^6.1.2",
|
|
52
|
-
"axios": "^1.
|
|
54
|
+
"axios": "^1.20.0",
|
|
53
55
|
"jsdom": "^30.1.1",
|
|
54
56
|
"next": "^16.3.6",
|
|
55
57
|
"next-auth": "^4.24.15",
|
|
@@ -65,5 +67,9 @@
|
|
|
65
67
|
"jwt",
|
|
66
68
|
"react",
|
|
67
69
|
"tailwindcss"
|
|
68
|
-
]
|
|
70
|
+
],
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@types/jsonwebtoken": "^9.0.8",
|
|
73
|
+
"jsonwebtoken": "^9.0.2"
|
|
74
|
+
}
|
|
69
75
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import axios, { type AxiosInstance } from "axios";
|
|
2
|
+
import { getSession } from "next-auth/react";
|
|
3
|
+
import { isTerminalSessionError, openSessionPrompt } from "../session/expiry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An axios instance for the signed-in API, for browser code. Each request carries the session's
|
|
7
|
+
* access token. A 401 on a dead session opens the re-sign-in prompt, so the page keeps its state.
|
|
8
|
+
*/
|
|
9
|
+
export function createApiClient(baseURL: string): AxiosInstance {
|
|
10
|
+
if (!baseURL) {
|
|
11
|
+
throw new Error("createApiClient needs the API base URL, and baseURL is empty.");
|
|
12
|
+
}
|
|
13
|
+
// allowAbsoluteUrls: false joins every URL onto baseURL, so the token only goes to the API.
|
|
14
|
+
const client = axios.create({ baseURL, allowAbsoluteUrls: false });
|
|
15
|
+
|
|
16
|
+
// broadcast: false, so a request does not make every other open tab refetch its session.
|
|
17
|
+
client.interceptors.request.use(async (request) => {
|
|
18
|
+
const session = await getSession({ broadcast: false });
|
|
19
|
+
const accessToken = (session as { accessToken?: string } | null)?.accessToken;
|
|
20
|
+
if (accessToken) {
|
|
21
|
+
request.headers.Authorization = `Bearer ${accessToken}`;
|
|
22
|
+
}
|
|
23
|
+
return request;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
client.interceptors.response.use(
|
|
27
|
+
(response) => response,
|
|
28
|
+
async (error) => {
|
|
29
|
+
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
|
30
|
+
const session = await getSession({ broadcast: false });
|
|
31
|
+
// No session also opens the prompt. The cookie is gone, or the session fetch failed. Either
|
|
32
|
+
// way there is no error field to read, and signing in again recovers both.
|
|
33
|
+
const sessionError = (session as { error?: string } | null)?.error;
|
|
34
|
+
if (!session || isTerminalSessionError(sessionError)) {
|
|
35
|
+
openSessionPrompt();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return Promise.reject(error);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return client;
|
|
43
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { formatApiError, type StatusMessages } from "../errors";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs an API call and returns its data. A failure throws an `Error` with a message for the user,
|
|
6
|
+
* from `formatApiError`. A cancelled call rethrows the cancel as it is, so the caller can ignore it.
|
|
7
|
+
*/
|
|
8
|
+
export async function request<T>(
|
|
9
|
+
call: () => Promise<{ data: T }>,
|
|
10
|
+
fallback: string,
|
|
11
|
+
statusMessages?: StatusMessages,
|
|
12
|
+
): Promise<T> {
|
|
13
|
+
try {
|
|
14
|
+
const response = await call();
|
|
15
|
+
return response.data;
|
|
16
|
+
} catch (error: unknown) {
|
|
17
|
+
if (axios.isCancel(error)) throw error;
|
|
18
|
+
// No cause. Errors get logged, and the axios error carries the access token and request body.
|
|
19
|
+
// eslint-disable-next-line preserve-caught-error
|
|
20
|
+
throw new Error(formatApiError(error, fallback, statusMessages));
|
|
21
|
+
}
|
|
22
|
+
}
|