@sjolystinnovation/app-kit 0.1.0 → 0.2.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 +66 -5
- package/package.json +12 -5
- package/src/errors.ts +29 -0
- package/src/index.ts +1 -0
- package/src/styles.css +2 -0
- package/src/ui/Alert.tsx +26 -0
- package/src/ui/PasswordInput.tsx +60 -0
- package/src/ui/index.ts +4 -0
package/README.md
CHANGED
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@sjolystinnovation/app-kit)
|
|
4
4
|
[](./LICENSE)
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
instead of copied.
|
|
8
|
+
Several apps sharing one API also share the code around it: the session plumbing, how a failed
|
|
9
|
+
request becomes a message a user can read, and the form controls on the sign in page. This package
|
|
10
|
+
holds that code once, so the apps install it instead of each keeping a copy.
|
|
12
11
|
|
|
13
12
|
## Install
|
|
14
13
|
|
|
@@ -25,6 +24,15 @@ const nextConfig: NextConfig = {
|
|
|
25
24
|
};
|
|
26
25
|
```
|
|
27
26
|
|
|
27
|
+
The components in `@sjolystinnovation/app-kit/ui` are styled with Tailwind CSS. Import the
|
|
28
|
+
package's stylesheet after Tailwind:
|
|
29
|
+
|
|
30
|
+
```css
|
|
31
|
+
/* src/app/globals.css */
|
|
32
|
+
@import "tailwindcss";
|
|
33
|
+
@import "@sjolystinnovation/app-kit/styles.css";
|
|
34
|
+
```
|
|
35
|
+
|
|
28
36
|
## Usage
|
|
29
37
|
|
|
30
38
|
Declare the values that differ between apps once, then pass that config wherever it is needed. One
|
|
@@ -83,9 +91,62 @@ resolveJwtSecret(sessionConfig, {});
|
|
|
83
91
|
The environment is a parameter rather than a read of `process.env`, which keeps Node types out of a
|
|
84
92
|
package that also runs in the browser.
|
|
85
93
|
|
|
94
|
+
### `formatApiError(error, fallback)`
|
|
95
|
+
|
|
96
|
+
`(error: unknown, fallback: string) => string`
|
|
97
|
+
|
|
98
|
+
Turns a failed request into one line a user can read. For an axios error it takes the first of
|
|
99
|
+
`message`, the first validation error, `detail` and `title` from the response body, which covers
|
|
100
|
+
plain JSON errors and ASP.NET Core problem details alike. Otherwise it uses the error's own message,
|
|
101
|
+
then `fallback`.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
try {
|
|
105
|
+
await api.post("/users", form);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
setError(formatApiError(error, "Could not create the user."));
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `Alert`
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { Alert } from "@sjolystinnovation/app-kit/ui";
|
|
115
|
+
|
|
116
|
+
<Alert variant="error">{message}</Alert>;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Prop | Type | Default | Description |
|
|
120
|
+
| --- | --- | --- | --- |
|
|
121
|
+
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
|
|
122
|
+
| `role` | `"alert" \| "status"` | `"alert"` | Use `status` for text that keeps changing, such as a countdown, so a screen reader does not announce every change. |
|
|
123
|
+
| `children` | `ReactNode` | | Content. |
|
|
124
|
+
|
|
125
|
+
### `PasswordInput`
|
|
126
|
+
|
|
127
|
+
A labelled password field with a button that shows and hides the value. Accepts every `<input>`
|
|
128
|
+
attribute as well.
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
132
|
+
|
|
133
|
+
<PasswordInput label="Password" name="password" required error={errors.password} />;
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
| Prop | Type | Default | Description |
|
|
137
|
+
| --- | --- | --- | --- |
|
|
138
|
+
| `label` | `string` | | Visible label, tied to the input. |
|
|
139
|
+
| `error` | `string` | | Message shown under the field, which also turns the border red. |
|
|
140
|
+
| `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
|
|
141
|
+
| `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
|
|
142
|
+
|
|
143
|
+
Pass the two toggle labels in the app's language. The defaults are English.
|
|
144
|
+
|
|
86
145
|
## Requirements
|
|
87
146
|
|
|
88
147
|
- Next.js 16, React 19, next-auth 4 and axios 1, as peer dependencies the app installs itself.
|
|
148
|
+
- For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 with a `primary` colour in the theme, which the
|
|
149
|
+
focus ring uses, and `@heroicons/react` 2.
|
|
89
150
|
- TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
|
|
90
151
|
`node16` and `nodenext` reject.
|
|
91
152
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjolystinnovation/app-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
},
|
|
12
12
|
"homepage": "https://github.com/sondresjolyst/si-web-packages#readme",
|
|
13
13
|
"type": "module",
|
|
14
|
-
"sideEffects":
|
|
14
|
+
"sideEffects": [
|
|
15
|
+
"*.css"
|
|
16
|
+
],
|
|
15
17
|
"exports": {
|
|
16
|
-
".": "./src/index.ts"
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./ui": "./src/ui/index.ts",
|
|
20
|
+
"./styles.css": "./src/styles.css"
|
|
17
21
|
},
|
|
18
22
|
"files": [
|
|
19
23
|
"src",
|
|
@@ -27,6 +31,7 @@
|
|
|
27
31
|
"test": "vitest run"
|
|
28
32
|
},
|
|
29
33
|
"peerDependencies": {
|
|
34
|
+
"@heroicons/react": "^2",
|
|
30
35
|
"axios": "^1",
|
|
31
36
|
"next": "^16",
|
|
32
37
|
"next-auth": "^4.24",
|
|
@@ -34,6 +39,7 @@
|
|
|
34
39
|
"react-dom": "^19"
|
|
35
40
|
},
|
|
36
41
|
"devDependencies": {
|
|
42
|
+
"@heroicons/react": "^2.2.0",
|
|
37
43
|
"@testing-library/jest-dom": "^7.0.1",
|
|
38
44
|
"@testing-library/react": "^16.3.3",
|
|
39
45
|
"@testing-library/user-event": "^14.6.7",
|
|
@@ -54,6 +60,7 @@
|
|
|
54
60
|
"next-auth",
|
|
55
61
|
"session",
|
|
56
62
|
"jwt",
|
|
57
|
-
"react"
|
|
63
|
+
"react",
|
|
64
|
+
"tailwindcss"
|
|
58
65
|
]
|
|
59
66
|
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
interface ApiErrorBody {
|
|
4
|
+
message?: string;
|
|
5
|
+
detail?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
errors?: Record<string, string[]>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Turns a failed request into one line a user can read. Takes the first of `message`, the first
|
|
12
|
+
* validation error, `detail` and `title` from the response body, so it reads both plain JSON
|
|
13
|
+
* errors and ASP.NET Core problem details. Falls back to the error's own message, then to
|
|
14
|
+
* `fallback`.
|
|
15
|
+
*/
|
|
16
|
+
export function formatApiError(error: unknown, fallback: string): string {
|
|
17
|
+
if (axios.isAxiosError(error)) {
|
|
18
|
+
const data = error.response?.data as ApiErrorBody | undefined;
|
|
19
|
+
if (data?.message) return data.message;
|
|
20
|
+
if (data?.errors) {
|
|
21
|
+
const first = Object.values(data.errors)[0];
|
|
22
|
+
if (first && first.length) return first[0];
|
|
23
|
+
}
|
|
24
|
+
if (data?.detail) return data.detail;
|
|
25
|
+
if (data?.title) return data.title;
|
|
26
|
+
}
|
|
27
|
+
if (error instanceof Error) return error.message;
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
package/src/index.ts
CHANGED
package/src/styles.css
ADDED
package/src/ui/Alert.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface AlertProps {
|
|
4
|
+
variant?: "error" | "success" | "info" | "warning";
|
|
5
|
+
/**
|
|
6
|
+
* Defaults to alert, which a screen reader interrupts for. Use status for text that keeps
|
|
7
|
+
* changing, such as a countdown, so it is not announced on every tick.
|
|
8
|
+
*/
|
|
9
|
+
role?: "alert" | "status";
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const styles: Record<NonNullable<AlertProps["variant"]>, string> = {
|
|
14
|
+
error: "bg-red-50 border-red-200 text-red-700",
|
|
15
|
+
success: "bg-green-50 border-green-200 text-green-700",
|
|
16
|
+
info: "bg-gray-50 border-gray-200 text-gray-700",
|
|
17
|
+
warning: "bg-amber-50 border-amber-200 text-amber-900",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function Alert({ variant = "info", role = "alert", children }: AlertProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div className={`rounded-lg border px-3 py-2 text-sm ${styles[variant]}`} role={role}>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
|
|
4
|
+
import { useId, useState, type InputHTMLAttributes } from "react";
|
|
5
|
+
|
|
6
|
+
export interface PasswordInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
7
|
+
label: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
/** Accessible name of the toggle while the password is hidden. */
|
|
10
|
+
showPasswordLabel?: string;
|
|
11
|
+
/** Accessible name of the toggle while the password is visible. */
|
|
12
|
+
hidePasswordLabel?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PasswordInput({
|
|
16
|
+
label,
|
|
17
|
+
error,
|
|
18
|
+
id,
|
|
19
|
+
required,
|
|
20
|
+
showPasswordLabel = "Show password",
|
|
21
|
+
hidePasswordLabel = "Hide password",
|
|
22
|
+
...props
|
|
23
|
+
}: PasswordInputProps) {
|
|
24
|
+
const [visible, setVisible] = useState(false);
|
|
25
|
+
// Fall back to a generated id so the label is always tied to its control. Without it, a call
|
|
26
|
+
// site that passes neither id nor name renders a label pointing at nothing, which leaves
|
|
27
|
+
// screen reader users with an unidentified field.
|
|
28
|
+
const generated = useId();
|
|
29
|
+
const inputId = id ?? props.name ?? generated;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div>
|
|
33
|
+
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
|
|
34
|
+
{label}
|
|
35
|
+
{required && <span className="text-red-600"> *</span>}
|
|
36
|
+
</label>
|
|
37
|
+
<div className="relative">
|
|
38
|
+
<input
|
|
39
|
+
id={inputId}
|
|
40
|
+
type={visible ? "text" : "password"}
|
|
41
|
+
required={required}
|
|
42
|
+
aria-required={required || undefined}
|
|
43
|
+
className={`w-full rounded-lg border px-3 py-2 pr-10 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary ${
|
|
44
|
+
error ? "border-red-400" : "border-gray-300"
|
|
45
|
+
}`}
|
|
46
|
+
{...props}
|
|
47
|
+
/>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => setVisible((v) => !v)}
|
|
51
|
+
className="absolute inset-y-0 right-0 px-3 text-gray-400 hover:text-gray-600"
|
|
52
|
+
aria-label={visible ? hidePasswordLabel : showPasswordLabel}
|
|
53
|
+
>
|
|
54
|
+
{visible ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
package/src/ui/index.ts
ADDED