@sjolystinnovation/app-kit 0.15.0 → 0.17.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 +26 -1
- package/package.json +1 -1
- package/src/api/index.ts +9 -0
- package/src/api/services.ts +197 -0
- package/src/auth/createAuthOptions.ts +17 -0
package/README.md
CHANGED
|
@@ -170,7 +170,7 @@ export { handler as GET, handler as POST };
|
|
|
170
170
|
|
|
171
171
|
| Setting | Type | Description |
|
|
172
172
|
| --- | --- | --- |
|
|
173
|
-
| `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login
|
|
173
|
+
| `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login`, refresh to `/auth/refresh-token` and sign-out to `/auth/logout` under it. An empty value throws. |
|
|
174
174
|
| `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret named by `jwtSecretEnvVar`. |
|
|
175
175
|
| `http` | `AxiosInstance` | In place of `apiUrl`, for tests. The client for sign-in and refresh, with its own base URL. |
|
|
176
176
|
|
|
@@ -264,6 +264,31 @@ 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
|
+
`createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
|
|
268
|
+
empty `baseURL` throws, and requests only go to `baseURL`.
|
|
269
|
+
|
|
270
|
+
The services below call the endpoints the SjolystInnovation.AppKit .NET packages map, so an app does
|
|
271
|
+
not write them. `api` is a `createApiClient` instance and `publicApi` a `createPublicClient` one.
|
|
272
|
+
Failures throw an `Error` with the API's message, or with the service's own English fallback.
|
|
273
|
+
`onSaved` runs after a save succeeds, such as `() => requestRevalidate("branding")`.
|
|
274
|
+
|
|
275
|
+
| Factory | Methods | .NET package |
|
|
276
|
+
| --- | --- | --- |
|
|
277
|
+
| `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, `deleteAccount` | Auth |
|
|
278
|
+
| `createAdminUsersService(api)` | `getUsers(includeDeleted?)`, `addRole`, `removeRole`, `deleteUser`, `invite` | Auth |
|
|
279
|
+
| `createSettingsService(api, { onSaved? })` | `get`, `update` | SiteSettings |
|
|
280
|
+
| `createBrandingService(api, publicApi, { onSaved? })` | `get`, which answers `{}` when the API cannot be read, and `update(form)` | SiteSettings |
|
|
281
|
+
| `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, `save` | SiteSettings |
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
|
|
285
|
+
|
|
286
|
+
const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL);
|
|
287
|
+
export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The response types are exported too: `UserProfile`, `AdminUser`, `SiteSettings` and `LegalPage`.
|
|
291
|
+
|
|
267
292
|
### `@sjolystinnovation/app-kit/server`
|
|
268
293
|
|
|
269
294
|
For server components and route handlers only. It imports `next/cache` and `next/server`.
|
package/package.json
CHANGED
package/src/api/index.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
export { createApiClient } from "./createApiClient";
|
|
2
2
|
export { request } from "./request";
|
|
3
3
|
export { requestRevalidate } from "./requestRevalidate";
|
|
4
|
+
export {
|
|
5
|
+
createPublicClient,
|
|
6
|
+
createUserService,
|
|
7
|
+
createSettingsService,
|
|
8
|
+
createBrandingService,
|
|
9
|
+
createLegalService,
|
|
10
|
+
createAdminUsersService,
|
|
11
|
+
} from "./services";
|
|
12
|
+
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
|
+
}
|
|
@@ -73,6 +73,10 @@ const REMEMBER_ROTATION_MS = 60 * 1000;
|
|
|
73
73
|
// Sign-in and session reads wait for the API, so a hung connection must not hold them forever.
|
|
74
74
|
const API_TIMEOUT_MS = 10_000;
|
|
75
75
|
|
|
76
|
+
// Sign-out waits for the API to drop the refresh token, but only briefly, because the cookie is
|
|
77
|
+
// cleared either way.
|
|
78
|
+
const SIGN_OUT_TIMEOUT_MS = 3_000;
|
|
79
|
+
|
|
76
80
|
function rolesOf(claims: ApiClaims): string[] {
|
|
77
81
|
const raw = claims.role;
|
|
78
82
|
return Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
@@ -249,6 +253,19 @@ export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSe
|
|
|
249
253
|
session: {
|
|
250
254
|
maxAge: ABSOLUTE_SESSION_MAX_AGE / 1000,
|
|
251
255
|
},
|
|
256
|
+
events: {
|
|
257
|
+
// Sign-out also asks the API to delete the refresh token, so a copy of it cannot be used again.
|
|
258
|
+
// A failure leaves sign-out to the cookie alone.
|
|
259
|
+
async signOut({ token }) {
|
|
260
|
+
const refreshToken = (token as AppToken | null)?.refreshToken;
|
|
261
|
+
if (!refreshToken) return;
|
|
262
|
+
try {
|
|
263
|
+
await http.post("/auth/logout", { refreshToken }, { timeout: SIGN_OUT_TIMEOUT_MS });
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error("Sign-out could not delete the refresh token at the API", failureShape(error));
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
},
|
|
252
269
|
callbacks: {
|
|
253
270
|
async jwt({ token: current, user }) {
|
|
254
271
|
let token = current as AppToken;
|