@sjolystinnovation/app-kit 0.16.0 → 0.18.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
@@ -264,6 +264,35 @@ export const getRecipe = (id: number) =>
264
264
  `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
265
265
  It never throws. On failure the pages' own revalidate window is the fallback.
266
266
 
267
+ `apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
268
+ call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
269
+ `{ orgNumber: ["..."] }`. It answers null when there are none, so the form falls back to one message.
270
+
271
+ `createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
272
+ empty `baseURL` throws, and requests only go to `baseURL`.
273
+
274
+ The services below call the endpoints the SjolystInnovation.AppKit .NET packages map, so an app does
275
+ not write them. `api` is a `createApiClient` instance and `publicApi` a `createPublicClient` one.
276
+ Failures throw an `Error` with the API's message, or with the service's own English fallback.
277
+ `onSaved` runs after a save succeeds, such as `() => requestRevalidate("branding")`.
278
+
279
+ | Factory | Methods | .NET package |
280
+ | --- | --- | --- |
281
+ | `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, `deleteAccount` | Auth |
282
+ | `createAdminUsersService(api)` | `getUsers(includeDeleted?)`, `addRole`, `removeRole`, `deleteUser`, `invite` | Auth |
283
+ | `createSettingsService(api, { onSaved? })` | `get`, `update` | SiteSettings |
284
+ | `createBrandingService(api, publicApi, { onSaved? })` | `get`, which answers `{}` when the API cannot be read, and `update(form)` | SiteSettings |
285
+ | `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, `save` | SiteSettings |
286
+
287
+ ```ts
288
+ import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
289
+
290
+ const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL);
291
+ export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
292
+ ```
293
+
294
+ The response types are exported too: `UserProfile`, `AdminUser`, `SiteSettings` and `LegalPage`.
295
+
267
296
  ### `@sjolystinnovation/app-kit/server`
268
297
 
269
298
  For server components and route handlers only. It imports `next/cache` and `next/server`.
@@ -991,6 +1020,20 @@ const gargeRule = passwordSchema().max(128, "Be at most 128 characters long.");
991
1020
 
992
1021
  `PASSWORD_MIN_LENGTH` holds the minimum, for hint text.
993
1022
 
1023
+ ### `zodFieldErrors(error, options?)`
1024
+
1025
+ From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
1026
+ belong to, for showing beside each input. Each field gets its first issue's message, or every message
1027
+ in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
1028
+ Issues that belong to no field are left out.
1029
+
1030
+ ```ts
1031
+ import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
1032
+
1033
+ const parsed = contactSchema.safeParse(form);
1034
+ if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: (issue) => issueMessage(issue, dict) }));
1035
+ ```
1036
+
994
1037
  ## Theming
995
1038
 
996
1039
  The components draw every colour, corner radius and label size from theme variables with a light
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -0,0 +1,23 @@
1
+ import axios from "axios";
2
+
3
+ /**
4
+ * The per-field messages of an ASP.NET validation problem, keyed by the field's name in camelCase so
5
+ * they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
6
+ * `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body. Answers null when there
7
+ * are no field errors, so the caller falls back to one message for the whole form.
8
+ */
9
+ export function apiFieldErrors(error: unknown): Record<string, string[]> | null {
10
+ const data = axios.isAxiosError(error) ? error.response?.data : error;
11
+ const errors = data && typeof data === "object" ? (data as { errors?: unknown }).errors : undefined;
12
+ if (!errors || typeof errors !== "object" || Array.isArray(errors)) return null;
13
+
14
+ // A Map, so a field named like an Object property such as "constructor" is still just a field.
15
+ const fields = new Map<string, string[]>();
16
+ for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
17
+ const messages = (Array.isArray(value) ? value : [value]).filter((m): m is string => typeof m === "string" && m !== "");
18
+ if (messages.length === 0) continue;
19
+ const field = key.charAt(0).toLowerCase() + key.slice(1);
20
+ fields.set(field, [...(fields.get(field) ?? []), ...messages]);
21
+ }
22
+ return fields.size > 0 ? Object.fromEntries(fields) : null;
23
+ }
package/src/api/index.ts CHANGED
@@ -1,3 +1,13 @@
1
+ export { apiFieldErrors } from "./apiFieldErrors";
1
2
  export { createApiClient } from "./createApiClient";
2
3
  export { request } from "./request";
3
4
  export { requestRevalidate } from "./requestRevalidate";
5
+ export {
6
+ createPublicClient,
7
+ createUserService,
8
+ createSettingsService,
9
+ createBrandingService,
10
+ createLegalService,
11
+ createAdminUsersService,
12
+ } from "./services";
13
+ export type { OnSaved, UserProfile, SiteSettings, LegalPage, AdminUser } from "./services";
@@ -0,0 +1,197 @@
1
+ import axios, { type AxiosInstance } from "axios";
2
+ import { getSession } from "next-auth/react";
3
+ import type { Branding } from "../branding/types";
4
+ import { request } from "./request";
5
+
6
+ /**
7
+ * An axios instance for the API's anonymous endpoints, without the session's token. Like
8
+ * `createApiClient`, it only sends requests to `baseURL`.
9
+ */
10
+ export function createPublicClient(baseURL: string): AxiosInstance {
11
+ if (!baseURL) {
12
+ throw new Error("createPublicClient needs the API base URL, and baseURL is empty.");
13
+ }
14
+ return axios.create({ baseURL, allowAbsoluteUrls: false });
15
+ }
16
+
17
+ /** Runs after an admin change saves, such as to purge the pages that show it. */
18
+ export type OnSaved = () => Promise<void> | void;
19
+
20
+ async function currentUserId(): Promise<string> {
21
+ const session = await getSession();
22
+ const id = (session?.user as { id?: string } | undefined)?.id;
23
+ if (!id) throw new Error("Not signed in.");
24
+ return id;
25
+ }
26
+
27
+ /** The signed-in user's account, as SjolystInnovation.AppKit.Auth's account endpoints return it. */
28
+ export interface UserProfile {
29
+ id: string;
30
+ userName: string;
31
+ firstName: string;
32
+ lastName: string;
33
+ email: string;
34
+ createdAt: string;
35
+ }
36
+
37
+ /**
38
+ * Password reset and the signed-in user's account, on the auth and account endpoints of
39
+ * SjolystInnovation.AppKit.Auth. `api` carries the session, `publicApi` does not.
40
+ */
41
+ export function createUserService(api: AxiosInstance, publicApi: AxiosInstance) {
42
+ return {
43
+ requestPasswordReset: (data: { email: string }) =>
44
+ request(() => publicApi.post<{ message: string }>("/auth/request-password-reset", data), "Failed to request password reset code"),
45
+
46
+ resetPassword: (data: { email: string; code: string; newPassword: string }) =>
47
+ request(() => publicApi.post<{ message: string }>("/auth/reset-password", data), "Failed to reset password"),
48
+
49
+ async changePassword(data: { currentPassword: string; newPassword: string }) {
50
+ const id = await currentUserId();
51
+ return request(() => api.put<{ message: string }>(`/users/${encodeURIComponent(id)}/password`, data), "Failed to change password");
52
+ },
53
+
54
+ async getProfile() {
55
+ const id = await currentUserId();
56
+ return request(() => api.get<UserProfile>(`/users/${encodeURIComponent(id)}/profile`), "Failed to load profile");
57
+ },
58
+
59
+ async updateProfile(data: { firstName: string; lastName: string }) {
60
+ const id = await currentUserId();
61
+ return request(() => api.put<UserProfile>(`/users/${encodeURIComponent(id)}/profile`, data), "Failed to update profile");
62
+ },
63
+
64
+ async exportData() {
65
+ const id = await currentUserId();
66
+ return request(() => api.get<unknown>(`/users/${encodeURIComponent(id)}/export`), "Failed to export data");
67
+ },
68
+
69
+ async deleteAccount() {
70
+ const id = await currentUserId();
71
+ await request(() => api.delete(`/users/${encodeURIComponent(id)}/account`), "Failed to delete account");
72
+ },
73
+ };
74
+ }
75
+
76
+ /** The company settings, as SjolystInnovation.AppKit.SiteSettings's `/settings` endpoint holds them. */
77
+ export interface SiteSettings {
78
+ contactRecipientEmail: string;
79
+ companyName: string;
80
+ companyLegalName: string;
81
+ orgNumber: string;
82
+ vatRegistered: boolean;
83
+ streetAddress: string;
84
+ postalCode: string;
85
+ addressLocality: string;
86
+ addressRegion: string;
87
+ publicEmail: string;
88
+ publicPhone: string;
89
+ }
90
+
91
+ /** The admin's company settings on SjolystInnovation.AppKit.SiteSettings. */
92
+ export function createSettingsService(api: AxiosInstance, options: { onSaved?: OnSaved } = {}) {
93
+ return {
94
+ get: () => request(() => api.get<SiteSettings>("/settings"), "Failed to load settings"),
95
+
96
+ async update(data: SiteSettings) {
97
+ const saved = await request(() => api.put<SiteSettings>("/settings", data), "Failed to update settings");
98
+ await options.onSaved?.();
99
+ return saved;
100
+ },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The logo and favicon on SjolystInnovation.AppKit.SiteSettings. Reading is anonymous and answers
106
+ * no branding when the API cannot be reached, so a page still renders.
107
+ */
108
+ export function createBrandingService(api: AxiosInstance, publicApi: AxiosInstance, options: { onSaved?: OnSaved } = {}) {
109
+ return {
110
+ async get(): Promise<Branding> {
111
+ try {
112
+ return (await publicApi.get<Branding>("/branding")).data;
113
+ } catch {
114
+ return {};
115
+ }
116
+ },
117
+
118
+ /** Sends the form `BrandingManager` builds: `logo`, `icon`, `removeLogo` and `removeIcon`. */
119
+ async update(form: FormData): Promise<Branding> {
120
+ const saved = await request(() => api.put<Branding>("/branding", form), "Failed to save branding");
121
+ await options.onSaved?.();
122
+ return saved;
123
+ },
124
+ };
125
+ }
126
+
127
+ /** A legal page, as SjolystInnovation.AppKit.SiteSettings's legal endpoints return it. */
128
+ export interface LegalPage<Key extends string = string> {
129
+ key: Key;
130
+ locale: string;
131
+ title: string;
132
+ bodyMarkdown: string;
133
+ updatedAt: string;
134
+ }
135
+
136
+ /**
137
+ * The terms, privacy and cookie pages on SjolystInnovation.AppKit.SiteSettings. Reading is anonymous
138
+ * and answers null for a page that is missing or cannot be read, so a page shows its fallback.
139
+ */
140
+ export function createLegalService<Key extends string = "terms" | "privacy" | "cookies">(
141
+ api: AxiosInstance,
142
+ publicApi: AxiosInstance,
143
+ options: { onSaved?: OnSaved } = {},
144
+ ) {
145
+ return {
146
+ async get(key: Key, locale: string): Promise<LegalPage<Key> | null> {
147
+ try {
148
+ return (await publicApi.get<LegalPage<Key>>(`/content/legal/${encodeURIComponent(key)}`, { params: { locale } })).data;
149
+ } catch {
150
+ return null;
151
+ }
152
+ },
153
+
154
+ listAll: () => request(() => api.get<LegalPage<Key>[]>("/content/legal"), "Failed to load legal pages"),
155
+
156
+ async save(key: Key, locale: string, title: string, bodyMarkdown: string) {
157
+ const page = await request(
158
+ () => api.put<LegalPage<Key>>(`/content/legal/${encodeURIComponent(key)}/${encodeURIComponent(locale)}`, { title, bodyMarkdown }),
159
+ "Failed to save legal page",
160
+ );
161
+ await options.onSaved?.();
162
+ return page;
163
+ },
164
+ };
165
+ }
166
+
167
+ /** A user in the admin list, as SjolystInnovation.AppKit.Auth's user admin endpoints return it. */
168
+ export interface AdminUser {
169
+ id: string;
170
+ userName: string;
171
+ email: string;
172
+ firstName: string;
173
+ lastName: string;
174
+ createdAt: string;
175
+ isDeleted: boolean;
176
+ roles: string[];
177
+ }
178
+
179
+ /** User administration on SjolystInnovation.AppKit.Auth: the list, roles, invites and deletion. */
180
+ export function createAdminUsersService(api: AxiosInstance) {
181
+ return {
182
+ getUsers: (includeDeleted = false) =>
183
+ request(() => api.get<AdminUser[]>("/users", { params: { includeDeleted } }), "Failed to load users"),
184
+
185
+ addRole: (userId: string, role: string) =>
186
+ request(() => api.post(`/users/${encodeURIComponent(userId)}/roles`, { role }), "Failed to assign role"),
187
+
188
+ removeRole: (userId: string, role: string) =>
189
+ request(() => api.delete(`/users/${encodeURIComponent(userId)}/roles/${encodeURIComponent(role)}`), "Failed to remove role"),
190
+
191
+ deleteUser: (userId: string) =>
192
+ request(() => api.delete(`/users/${encodeURIComponent(userId)}/account`), "Failed to delete user"),
193
+
194
+ invite: (email: string, firstName: string, lastName: string, role: string) =>
195
+ request(() => api.post("/users/invite", { email, firstName, lastName, role }), "Failed to send invitation"),
196
+ };
197
+ }
@@ -0,0 +1,34 @@
1
+ import type { z } from "zod";
2
+
3
+ /** Picks the text shown for an issue, such as a translated message by the issue's code. */
4
+ export type IssueMessage = (issue: z.core.$ZodIssue) => string;
5
+
6
+ /**
7
+ * A failed parse's issues by the top-level field they belong to, for showing beside each input. By
8
+ * default each field gets its first issue's message. With `all: true` it gets every message, in order.
9
+ * `message` replaces zod's text, such as with the app's own translations. Issues without a field,
10
+ * from a refinement of the whole object, are left out.
11
+ */
12
+ export function zodFieldErrors<T>(
13
+ error: z.ZodError<T>,
14
+ options?: { all?: false; message?: IssueMessage },
15
+ ): Partial<Record<Extract<keyof T, string>, string>>;
16
+ export function zodFieldErrors<T>(
17
+ error: z.ZodError<T>,
18
+ options: { all: true; message?: IssueMessage },
19
+ ): Partial<Record<Extract<keyof T, string>, string[]>>;
20
+ export function zodFieldErrors<T>(
21
+ error: z.ZodError<T>,
22
+ options: { all?: boolean; message?: IssueMessage } = {},
23
+ ): object {
24
+ // A Map, so a field named like an Object property such as "constructor" is still just a field.
25
+ const errors = new Map<string, string[]>();
26
+ for (const issue of error.issues) {
27
+ const field = issue.path[0];
28
+ if (typeof field !== "string") continue;
29
+ const messages = errors.get(field) ?? [];
30
+ messages.push(options.message ? options.message(issue) : issue.message);
31
+ errors.set(field, messages);
32
+ }
33
+ return Object.fromEntries([...errors].map(([field, messages]) => [field, options.all ? messages : messages[0]]));
34
+ }
@@ -1,2 +1,4 @@
1
1
  export { PASSWORD_MIN_LENGTH, defaultPasswordStrings, passwordSchema } from "./password";
2
2
  export type { PasswordStrings } from "./password";
3
+ export { zodFieldErrors } from "./fieldErrors";
4
+ export type { IssueMessage } from "./fieldErrors";