@sjolystinnovation/app-kit 1.0.1 → 1.0.2
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
|
@@ -20,6 +20,7 @@ security headers. Each part is a separate entry point, so an app imports only wh
|
|
|
20
20
|
- [`auth`](#sjolystinnovationapp-kitauth) and [`next-auth`](#sjolystinnovationapp-kitnext-auth)
|
|
21
21
|
- [`session`](#sjolystinnovationapp-kitsession) and [`session/react`](#sjolystinnovationapp-kitsessionreact)
|
|
22
22
|
- [`api`](#sjolystinnovationapp-kitapi) and [`server`](#sjolystinnovationapp-kitserver)
|
|
23
|
+
- [`company`](#sjolystinnovationapp-kitcompany)
|
|
23
24
|
- [`forms`](#sjolystinnovationapp-kitforms), [`toast`](#sjolystinnovationapp-kittoast), and [`validation`](#sjolystinnovationapp-kitvalidation)
|
|
24
25
|
- [`ui`](#sjolystinnovationapp-kitui)
|
|
25
26
|
- [`admin`](#sjolystinnovationapp-kitadmin)
|
|
@@ -51,12 +52,12 @@ ones the entry points you import need:
|
|
|
51
52
|
| `react-markdown` 10, `remark-gfm` 4, and `rehype-sanitize` 6 | `markdown`, `legal`, and `sections` |
|
|
52
53
|
| `vitest` 5 | `testing` |
|
|
53
54
|
|
|
54
|
-
Every other entry point, such as the root entry, `next-config`, `seo`, `forms`, `legal`,
|
|
55
|
-
`locales/nb`, works without `@heroicons/react`. Install a single copy of `sonner`, because two
|
|
55
|
+
Every other entry point, such as the root entry, `next-config`, `seo`, `forms`, `legal`, `company`,
|
|
56
|
+
or `locales/nb`, works without `@heroicons/react`. Install a single copy of `sonner`, because two
|
|
56
57
|
copies keep two separate toast stores. `jsonwebtoken` comes with the package.
|
|
57
58
|
|
|
58
|
-
For a site without sign-in, which uses only entries such as the root entry, `ui`, `seo`,
|
|
59
|
-
`next-config`:
|
|
59
|
+
For a site without sign-in, which uses only entries such as the root entry, `ui`, `seo`, `company`,
|
|
60
|
+
or `next-config`:
|
|
60
61
|
|
|
61
62
|
```sh
|
|
62
63
|
npm install @sjolystinnovation/app-kit next react react-dom @heroicons/react
|
|
@@ -101,6 +102,7 @@ The components use Tailwind CSS 4. Import the package's stylesheet after Tailwin
|
|
|
101
102
|
| [`@sjolystinnovation/app-kit/session`](#sjolystinnovationapp-kitsession) | Session error codes and the re-sign-in prompt flag. Safe on the server. |
|
|
102
103
|
| [`@sjolystinnovation/app-kit/session/react`](#sjolystinnovationapp-kitsessionreact) | The session provider, gates, expiry guard, sign-in form, and login page. |
|
|
103
104
|
| [`@sjolystinnovation/app-kit/api`](#sjolystinnovationapp-kitapi) | The axios clients, error messages, field errors, and services for the API's endpoints. |
|
|
105
|
+
| [`@sjolystinnovation/app-kit/company`](#sjolystinnovationapp-kitcompany) | `mergeCompanyInfo`, `formatOrgNumber`, and the `CompanyInfo` type. Imports no peer dependency. |
|
|
104
106
|
| [`@sjolystinnovation/app-kit/server`](#sjolystinnovationapp-kitserver) | Cached public GETs and the revalidate route. Server only. |
|
|
105
107
|
| [`@sjolystinnovation/app-kit/forms`](#sjolystinnovationapp-kitforms) | `useFormDraft`, which keeps form values in `localStorage`. |
|
|
106
108
|
| [`@sjolystinnovation/app-kit/toast`](#sjolystinnovationapp-kittoast) | sonner's `toast`, quiet on errors while the re-sign-in prompt is open. |
|
|
@@ -615,24 +617,8 @@ The package also exports the response types `UserProfile`, `AdminUser`, `SiteSet
|
|
|
615
617
|
|
|
616
618
|
### `mergeCompanyInfo` and `formatOrgNumber`
|
|
617
619
|
|
|
618
|
-
`
|
|
619
|
-
|
|
620
|
-
fallback's name is required. The legal name falls back to the name, and VAT to not registered.
|
|
621
|
-
|
|
622
|
-
`formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered, and
|
|
623
|
-
gives an empty string without a number.
|
|
624
|
-
|
|
625
|
-
```ts
|
|
626
|
-
// src/lib/companyInfo.ts
|
|
627
|
-
import { formatOrgNumber, mergeCompanyInfo, type CompanyInfo } from "@sjolystinnovation/app-kit/api";
|
|
628
|
-
|
|
629
|
-
export const getCompanyInfo = async () =>
|
|
630
|
-
mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), { name: "Example AS" });
|
|
631
|
-
|
|
632
|
-
export const orgLine = (company: CompanyInfo) => formatOrgNumber(company);
|
|
633
|
-
```
|
|
634
|
-
|
|
635
|
-
The `CompanyInfo` and `CompanyFallback` types describe the two shapes.
|
|
620
|
+
`./api` also exports the [`company`](#sjolystinnovationapp-kitcompany) helpers and types, so
|
|
621
|
+
existing imports keep working.
|
|
636
622
|
|
|
637
623
|
### `defineRevalidateTargets`
|
|
638
624
|
|
|
@@ -661,7 +647,8 @@ The package also exports the `RevalidateTargetsConfig` and `RevalidateTargetPath
|
|
|
661
647
|
|
|
662
648
|
## `@sjolystinnovation/app-kit/server`
|
|
663
649
|
|
|
664
|
-
For server components and route handlers only. It imports `next/cache
|
|
650
|
+
For server components and route handlers only. It imports `next/cache`, `next/constants`, and
|
|
651
|
+
`next/server`.
|
|
665
652
|
|
|
666
653
|
### `createPublicApi`
|
|
667
654
|
|
|
@@ -688,6 +675,19 @@ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicA
|
|
|
688
675
|
);
|
|
689
676
|
```
|
|
690
677
|
|
|
678
|
+
`next build` prerenders the pages that call these, so by default a build fails when the API is down.
|
|
679
|
+
Set `nullOnFailureDuringBuild: true` to build those pages empty instead. During the build, an
|
|
680
|
+
unreachable API or an error answer gives `null` and logs a warning with the path and status, so the
|
|
681
|
+
empty page shows in the build log. ISR fills the page on its first revalidate once the API answers.
|
|
682
|
+
At runtime a failure still throws, so a working page is never replaced by an empty one.
|
|
683
|
+
|
|
684
|
+
```ts
|
|
685
|
+
export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicApi(
|
|
686
|
+
requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
|
|
687
|
+
{ nullOnFailureDuringBuild: true },
|
|
688
|
+
);
|
|
689
|
+
```
|
|
690
|
+
|
|
691
691
|
The package also exports the `PublicApiOptions`, `PublicGetOptions`, and `PublicResponse` types.
|
|
692
692
|
|
|
693
693
|
### `createRevalidateRoute`
|
|
@@ -714,6 +714,30 @@ export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: "Admin"
|
|
|
714
714
|
`{ builds: ["/builds", "/builds/[slug]", "/sitemap.xml"] }`. The package also exports the
|
|
715
715
|
`RevalidateRouteOptions` type.
|
|
716
716
|
|
|
717
|
+
## `@sjolystinnovation/app-kit/company`
|
|
718
|
+
|
|
719
|
+
The company details for public pages, such as a footer or a contact page. The entry imports no
|
|
720
|
+
peer dependency, so a static site without axios or next-auth can use it.
|
|
721
|
+
|
|
722
|
+
`mergeCompanyInfo(data, fallback)` gives the company details: the API's `/company` answer, with the
|
|
723
|
+
app's own value for each field it left blank or could not give. Only the fallback's name is
|
|
724
|
+
required. The legal name falls back to the name, and VAT to not registered.
|
|
725
|
+
|
|
726
|
+
`formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered, and
|
|
727
|
+
gives an empty string without a number.
|
|
728
|
+
|
|
729
|
+
```ts
|
|
730
|
+
// src/lib/companyInfo.ts
|
|
731
|
+
import { formatOrgNumber, mergeCompanyInfo, type CompanyInfo } from "@sjolystinnovation/app-kit/company";
|
|
732
|
+
|
|
733
|
+
export const getCompanyInfo = async () =>
|
|
734
|
+
mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), { name: "Example AS" });
|
|
735
|
+
|
|
736
|
+
export const orgLine = (company: CompanyInfo) => formatOrgNumber(company);
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
The `CompanyInfo` and `CompanyFallback` types describe the two shapes.
|
|
740
|
+
|
|
717
741
|
## `@sjolystinnovation/app-kit/forms`
|
|
718
742
|
|
|
719
743
|
`useFormDraft(config, options)` keeps a form's values in `localStorage`, so a sign-out, reload, or
|
|
@@ -981,7 +1005,7 @@ const params = useSearchParams();
|
|
|
981
1005
|
| `loginHref` | `string` | Where the sign-in links go. |
|
|
982
1006
|
| `initialEmail`, `initialCode` | `string` | The values from the emailed link. A code opens the second step. |
|
|
983
1007
|
| `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`, such as to sit in the app's own card. |
|
|
984
|
-
| `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. The English defaults include an intro, the password rules as `passwordHint`, and a `done` text. Set `signInPrompt`, `intro`, `codeSent`, or `passwordHint` to `null` to leave it out. `done` set to `null` shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to `null` lists every broken rule. `validation` and `errors` may name only some codes. |
|
|
1008
|
+
| `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. The English defaults include an intro, the password rules as `passwordHint`, and a `done` text. Set `signInPrompt`, `intro`, `codeSent`, or `passwordHint` to `null` to leave it out. The hint gives way to the new password's error while one shows, since the default `passwordInvalid` repeats the rules. `done` set to `null` shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to `null` lists every broken rule. `validation` and `errors` may name only some codes. |
|
|
985
1009
|
| `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
|
|
986
1010
|
|
|
987
1011
|
A password that breaks the rules is marked under the new password field, without a call to the API.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjolystinnovation/app-kit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"types": "./src/next-auth.d.ts"
|
|
28
28
|
},
|
|
29
29
|
"./api": "./src/api/index.ts",
|
|
30
|
+
"./company": "./src/company/index.ts",
|
|
30
31
|
"./server": "./src/server/index.ts",
|
|
31
32
|
"./ui": "./src/ui/index.ts",
|
|
32
33
|
"./admin": "./src/admin/index.ts",
|
|
@@ -111,7 +112,7 @@
|
|
|
111
112
|
"@vitejs/plugin-react": "^6.1.2",
|
|
112
113
|
"axios": "^1.20.0",
|
|
113
114
|
"jsdom": "^30.1.1",
|
|
114
|
-
"next": "^16.
|
|
115
|
+
"next": "^16.4.0",
|
|
115
116
|
"next-auth": "^4.24.15",
|
|
116
117
|
"react": "^19.3.0",
|
|
117
118
|
"react-dom": "^19.3.0",
|
package/src/api/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { apiErrorCode } from "./apiErrorCode";
|
|
2
2
|
export { apiFieldErrors } from "./apiFieldErrors";
|
|
3
|
-
export { formatOrgNumber, mergeCompanyInfo } from "
|
|
4
|
-
export type { CompanyFallback, CompanyInfo } from "
|
|
3
|
+
export { formatOrgNumber, mergeCompanyInfo } from "../company";
|
|
4
|
+
export type { CompanyFallback, CompanyInfo } from "../company";
|
|
5
5
|
export { createApiClient } from "./createApiClient";
|
|
6
6
|
export { formatApiError } from "./formatApiError";
|
|
7
7
|
export type { StatusMessages } from "./formatApiError";
|
package/src/server/publicApi.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PHASE_PRODUCTION_BUILD } from "next/constants";
|
|
2
|
+
|
|
1
3
|
/** A response from `publicGetWithMeta`, with its `Last-Modified` date. */
|
|
2
4
|
export interface PublicResponse<T> {
|
|
3
5
|
data: T;
|
|
@@ -15,6 +17,12 @@ export interface PublicGetOptions {
|
|
|
15
17
|
export interface PublicApiOptions {
|
|
16
18
|
/** The seconds a cached response stays fresh. Defaults to `60`. */
|
|
17
19
|
revalidate?: number | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a failed fetch gives `null` with a warning, in place of `PublicApiError`, while
|
|
22
|
+
* `next build` prerenders. The page then builds empty and ISR fills it on its first revalidate.
|
|
23
|
+
* Outside the build a failure still throws. Defaults to `false`.
|
|
24
|
+
*/
|
|
25
|
+
nullOnFailureDuringBuild?: boolean | undefined;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
/** Thrown when the API could not be reached, or answered with no usable content. */
|
|
@@ -43,7 +51,8 @@ function lastModifiedOf(response: Response): Date | null {
|
|
|
43
51
|
* handlers.
|
|
44
52
|
*
|
|
45
53
|
* A 404 returns null. An unreachable or failing API throws `PublicApiError`, so a failed fetch is
|
|
46
|
-
* never cached as an empty page in place of a working one.
|
|
54
|
+
* never cached as an empty page in place of a working one. With `nullOnFailureDuringBuild`, a
|
|
55
|
+
* failure during `next build` gives null instead, so the build does not depend on the API.
|
|
47
56
|
*/
|
|
48
57
|
export function createPublicApi(baseUrl: string, options: PublicApiOptions = {}) {
|
|
49
58
|
if (!baseUrl) {
|
|
@@ -68,6 +77,19 @@ export function createPublicApi(baseUrl: string, options: PublicApiOptions = {})
|
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
async function publicGetWithMeta<T>(path: string, opts: PublicGetOptions = {}): Promise<PublicResponse<T> | null> {
|
|
80
|
+
try {
|
|
81
|
+
return await fetchWithMeta<T>(path, opts);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Read at call time, since next build sets the phase for the process that prerenders.
|
|
84
|
+
if (!options.nullOnFailureDuringBuild || !(error instanceof PublicApiError) || process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD) {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
console.warn(`${error.message} during next build. The page builds without it until it revalidates.`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchWithMeta<T>(path: string, opts: PublicGetOptions): Promise<PublicResponse<T> | null> {
|
|
71
93
|
const url = urlFor(path);
|
|
72
94
|
if (url == null) return null;
|
|
73
95
|
let response: Response;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
-
import { useState, type FormEvent } from "react";
|
|
4
|
+
import { useId, useState, type FormEvent } from "react";
|
|
5
5
|
import { errorText, fieldErrorTexts } from "../api/fieldErrorText";
|
|
6
6
|
import { passwordProblems, type PasswordStrings } from "../validation/passwordRules";
|
|
7
7
|
import { Alert } from "./Alert";
|
|
@@ -76,6 +76,7 @@ export function ResetPasswordFlow({
|
|
|
76
76
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
|
77
77
|
const [done, setDone] = useState<string | null>(null);
|
|
78
78
|
const [loading, setLoading] = useState(false);
|
|
79
|
+
const hintId = `${useId()}-hint`;
|
|
79
80
|
|
|
80
81
|
// Field errors go under the fields this step shows, and the message holds the rest.
|
|
81
82
|
const fail = (err: unknown, shown: readonly string[]) => {
|
|
@@ -91,6 +92,10 @@ export function ResetPasswordFlow({
|
|
|
91
92
|
};
|
|
92
93
|
|
|
93
94
|
const fieldError = (field: string) => fieldErrors[field]?.join(" ");
|
|
95
|
+
// The error under the new password says what to fix, and by default it holds the rules, so the
|
|
96
|
+
// hint steps aside while it shows.
|
|
97
|
+
const passwordError = fieldError("newPassword");
|
|
98
|
+
const showHint = Boolean(text.passwordHint) && !passwordError;
|
|
94
99
|
|
|
95
100
|
const sendCode = async (event: FormEvent) => {
|
|
96
101
|
event.preventDefault();
|
|
@@ -159,12 +164,13 @@ export function ResetPasswordFlow({
|
|
|
159
164
|
<PasswordInput
|
|
160
165
|
label={text.newPassword}
|
|
161
166
|
name="newPassword"
|
|
162
|
-
error={
|
|
167
|
+
error={passwordError}
|
|
168
|
+
aria-describedby={showHint ? hintId : undefined}
|
|
163
169
|
value={newPassword}
|
|
164
170
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
165
171
|
required
|
|
166
172
|
/>
|
|
167
|
-
{
|
|
173
|
+
{showHint && <p id={hintId} className="text-xs text-auth-hint">{text.passwordHint}</p>}
|
|
168
174
|
<button type="submit" disabled={loading} className={button}>
|
|
169
175
|
{loading ? text.settingPassword : text.setPassword}
|
|
170
176
|
</button>
|
|
@@ -50,7 +50,7 @@ export interface ResetPasswordFlowStrings {
|
|
|
50
50
|
email: string;
|
|
51
51
|
code: string;
|
|
52
52
|
newPassword: string;
|
|
53
|
-
/** The text shown under the new password field. */
|
|
53
|
+
/** The text shown under the new password field. It gives way to the field's error while one shows. */
|
|
54
54
|
passwordHint: string | null;
|
|
55
55
|
requestCode: string;
|
|
56
56
|
requestingCode: string;
|