@manablox/api-rpc 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 +20 -0
- package/package.json +9 -7
- package/src/base.ts +6 -15
- package/src/context.ts +55 -3
- package/src/index.ts +6 -0
- package/src/routers/asset.ts +46 -17
- package/src/routers/content-type.ts +4 -3
- package/src/routers/content.ts +114 -48
- package/src/routers/menu.ts +77 -0
- package/src/routers/role.ts +51 -0
- package/src/routers/space.ts +76 -194
- package/src/routers/user.ts +117 -10
- package/src/routers/workflow.ts +253 -0
- package/src/schemas.ts +40 -0
- package/test/asset.test.ts +144 -0
- package/test/content.test.ts +256 -0
- package/test/helpers.ts +163 -0
- package/test/menu.test.ts +192 -0
- package/test/role.test.ts +205 -0
- package/test/space.test.ts +170 -0
- package/test/user.test.ts +212 -0
- package/test/workflow.test.ts +259 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { authed, scoped } from '../base.js';
|
|
3
|
+
import { machineName, uuid } from '../schemas.js';
|
|
4
|
+
|
|
5
|
+
const roleSchema = z.object({
|
|
6
|
+
spaceId: uuid,
|
|
7
|
+
name: z.string().trim().min(1).max(100),
|
|
8
|
+
machineName,
|
|
9
|
+
description: z.string().max(500).nullable().optional(),
|
|
10
|
+
/** `space:write`, `content:read` for every type, `content:read:<typeId>` for one. */
|
|
11
|
+
permissions: z.array(z.string().max(120)).max(500),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The roles of a space. The rules — reserved names, grants that exist, a role nobody
|
|
16
|
+
* holds before it goes — live in `RoleService`; a procedure here is an input schema, a
|
|
17
|
+
* permission and one call.
|
|
18
|
+
*/
|
|
19
|
+
export const roleRouter = {
|
|
20
|
+
/** The permission catalogue, grouped the way the role editor lays it out. */
|
|
21
|
+
catalog: authed.handler(async ({ context }) => context.roles.catalog()),
|
|
22
|
+
|
|
23
|
+
list: scoped('role:read')
|
|
24
|
+
.input(z.object({ spaceId: uuid }))
|
|
25
|
+
.handler(async ({ input, context }) => context.roles.list(input.spaceId)),
|
|
26
|
+
|
|
27
|
+
get: scoped('role:read')
|
|
28
|
+
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
29
|
+
.handler(async ({ input, context }) => context.roles.get(input.spaceId, input.id)),
|
|
30
|
+
|
|
31
|
+
create: scoped('role:write')
|
|
32
|
+
.input(roleSchema)
|
|
33
|
+
.handler(async ({ input, context }) => {
|
|
34
|
+
const { spaceId, ...data } = input;
|
|
35
|
+
return context.roles.create(spaceId, data);
|
|
36
|
+
}),
|
|
37
|
+
|
|
38
|
+
update: scoped('role:write')
|
|
39
|
+
.input(roleSchema.extend({ id: uuid }))
|
|
40
|
+
.handler(async ({ input, context }) => {
|
|
41
|
+
const { spaceId, id, ...data } = input;
|
|
42
|
+
return context.roles.update(spaceId, id, data);
|
|
43
|
+
}),
|
|
44
|
+
|
|
45
|
+
delete: scoped('role:write')
|
|
46
|
+
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
47
|
+
.handler(async ({ input, context }) => {
|
|
48
|
+
await context.roles.delete(input.spaceId, input.id);
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}),
|
|
51
|
+
};
|
package/src/routers/space.ts
CHANGED
|
@@ -1,230 +1,125 @@
|
|
|
1
|
-
import { ManabloxError } from '@manablox/core';
|
|
2
|
-
import { SpaceTransferService } from '@manablox/services';
|
|
3
1
|
import { z } from 'zod';
|
|
4
2
|
import { authed, base, scoped, superadmin } from '../base.js';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import {
|
|
4
|
+
locale,
|
|
5
|
+
localeList,
|
|
6
|
+
machineName,
|
|
7
|
+
mimeTypePattern,
|
|
8
|
+
searchTerm,
|
|
9
|
+
spaceRole,
|
|
10
|
+
uuid,
|
|
11
|
+
} from '../schemas.js';
|
|
8
12
|
|
|
9
13
|
const spaceSchema = z.object({
|
|
10
14
|
name: z.string().min(1).max(200),
|
|
11
|
-
machineName
|
|
12
|
-
.string()
|
|
13
|
-
.regex(/^[a-z][a-z0-9_-]*$/)
|
|
14
|
-
.max(64),
|
|
15
|
+
machineName,
|
|
15
16
|
description: z.string().nullable().optional(),
|
|
16
17
|
url: z.string().url(),
|
|
17
|
-
defaultLocale:
|
|
18
|
-
locales:
|
|
18
|
+
defaultLocale: locale.default('en'),
|
|
19
|
+
locales: localeList.default(['en']),
|
|
19
20
|
settings: z.record(z.string(), z.unknown()).optional(),
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Spaces and membership. Every rule — the locale invariant, the last-owner guard, the
|
|
25
|
+
* creator-owns-it grant — lives in `SpaceService`; a procedure here is an input schema,
|
|
26
|
+
* a permission and one call.
|
|
26
27
|
*/
|
|
27
|
-
function rethrowMachineNameConflict(error: unknown, machineName: string | undefined): never {
|
|
28
|
-
const cause = (error as { cause?: { code?: string; message?: string } }).cause;
|
|
29
|
-
const code = (error as { code?: string }).code ?? cause?.code;
|
|
30
|
-
const detail = `${cause?.message ?? ''} ${(error as { message?: string }).message ?? ''}`;
|
|
31
|
-
|
|
32
|
-
if (code === '23505' && detail.includes('machine_name')) {
|
|
33
|
-
throw ManabloxError.validation(
|
|
34
|
-
[
|
|
35
|
-
{
|
|
36
|
-
key: 'space.machineName.taken',
|
|
37
|
-
path: ['machineName'],
|
|
38
|
-
params: { machineName: machineName ?? '' },
|
|
39
|
-
},
|
|
40
|
-
],
|
|
41
|
-
'space.validation.failed',
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* A space with no owner can never be granted one again: `user:write` is an owner's and an
|
|
49
|
-
* admin's permission, and an admin cannot promote themselves past their own role.
|
|
50
|
-
*/
|
|
51
|
-
async function assertNotLastOwner(
|
|
52
|
-
context: Pick<RpcContext, 'repos'>,
|
|
53
|
-
spaceId: string,
|
|
54
|
-
userId: string,
|
|
55
|
-
): Promise<void> {
|
|
56
|
-
if ((await context.repos.users.roleIn(userId, spaceId)) !== 'owner') return;
|
|
57
|
-
const owners = (await context.repos.users.membersOf(spaceId)).filter(
|
|
58
|
-
(member) => member.role === 'owner',
|
|
59
|
-
);
|
|
60
|
-
if (owners.length <= 1) throw ManabloxError.badRequest('space.member.lastOwner', { spaceId });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The default locale names the column content falls back to, so a default outside the
|
|
65
|
-
* space's own locale set would leave every document with no readable fallback.
|
|
66
|
-
*/
|
|
67
|
-
function assertDefaultIsALocale(defaultLocale: string, locales: readonly string[]): void {
|
|
68
|
-
if (locales.includes(defaultLocale)) return;
|
|
69
|
-
throw ManabloxError.validation(
|
|
70
|
-
[
|
|
71
|
-
{
|
|
72
|
-
key: 'space.defaultLocale.notInLocales',
|
|
73
|
-
path: ['defaultLocale'],
|
|
74
|
-
params: { defaultLocale, locales: locales.join(', ') },
|
|
75
|
-
},
|
|
76
|
-
],
|
|
77
|
-
'space.validation.failed',
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
28
|
export const spaceRouter = {
|
|
82
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Only the spaces the caller is a member of — a superadmin sees all. A member's query
|
|
31
|
+
* is bounded by their memberships rather than by the instance, so a large multi-tenant
|
|
32
|
+
* install does not load every space to show someone their two.
|
|
33
|
+
*/
|
|
83
34
|
list: authed.handler(async ({ context }) => {
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
35
|
+
const { principal } = context;
|
|
36
|
+
const all =
|
|
37
|
+
principal.role === 'superadmin'
|
|
38
|
+
? await context.repos.spaces.all()
|
|
39
|
+
: await context.repos.spaces.findManyByIds(Object.keys(principal.spaces));
|
|
40
|
+
const allowed = principal.allowedSpaceIds;
|
|
41
|
+
return allowed ? all.filter((space) => allowed.includes(space.id)) : all;
|
|
89
42
|
}),
|
|
90
43
|
|
|
91
44
|
get: scoped('space:read')
|
|
92
45
|
.input(z.object({ spaceId: uuid }))
|
|
93
46
|
.handler(async ({ input, context }) => context.repos.spaces.findById(input.spaceId)),
|
|
94
47
|
|
|
95
|
-
create: superadmin
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.create(input)
|
|
99
|
-
|
|
100
|
-
// Whoever creates a space owns it, or they would immediately lock themselves out.
|
|
101
|
-
if (context.principal) {
|
|
102
|
-
await context.repos.users.grant(context.principal.userId, space.id, 'owner');
|
|
103
|
-
}
|
|
104
|
-
return space;
|
|
105
|
-
}),
|
|
48
|
+
create: superadmin
|
|
49
|
+
.input(spaceSchema)
|
|
50
|
+
.handler(async ({ input, context }) =>
|
|
51
|
+
context.spaces.create(input, context.principal?.userId ?? null),
|
|
52
|
+
),
|
|
106
53
|
|
|
107
54
|
update: scoped('space:write')
|
|
108
55
|
.input(spaceSchema.partial().extend({ spaceId: uuid }))
|
|
109
56
|
.handler(async ({ input, context }) => {
|
|
110
57
|
const { spaceId, ...data } = input;
|
|
111
|
-
|
|
112
|
-
// A partial update may move either half of the pair, so the check runs against the
|
|
113
|
-
// stored values for whichever half this call leaves alone.
|
|
114
|
-
if (data.defaultLocale || data.locales) {
|
|
115
|
-
const current = await context.repos.spaces.findById(spaceId);
|
|
116
|
-
if (!current) throw ManabloxError.notFound('space.notFound', { spaceId });
|
|
117
|
-
assertDefaultIsALocale(
|
|
118
|
-
data.defaultLocale ?? current.defaultLocale,
|
|
119
|
-
data.locales ?? current.locales,
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return context.repos.spaces
|
|
124
|
-
.update(spaceId, data)
|
|
125
|
-
.catch((error) => rethrowMachineNameConflict(error, data.machineName));
|
|
58
|
+
return context.spaces.update(spaceId, data);
|
|
126
59
|
}),
|
|
127
60
|
|
|
128
61
|
delete: scoped('space:delete')
|
|
129
62
|
.input(z.object({ spaceId: uuid }))
|
|
130
63
|
.handler(async ({ input, context }) => {
|
|
131
|
-
await context.
|
|
64
|
+
await context.spaces.delete(input.spaceId);
|
|
132
65
|
return { ok: true };
|
|
133
66
|
}),
|
|
134
67
|
|
|
135
|
-
/**
|
|
136
|
-
* Nominates one document as the space's root. Stored in `settings` rather than on the
|
|
137
|
-
* content row so it survives the document being renamed, re-slugged or moved, and so a
|
|
138
|
-
* space has exactly one by construction.
|
|
139
|
-
*
|
|
140
|
-
* The delivery APIs resolve the empty path through it, matching the requested locale by
|
|
141
|
-
* localization group — pass `null` to clear.
|
|
142
|
-
*/
|
|
68
|
+
/** Nominates one document as the space's root; `null` clears it. */
|
|
143
69
|
setHome: scoped('space:write')
|
|
144
70
|
.input(z.object({ spaceId: uuid, contentId: uuid.nullable() }))
|
|
145
|
-
.handler(async ({ input, context }) =>
|
|
146
|
-
const space = await context.repos.spaces.findById(input.spaceId);
|
|
147
|
-
if (!space) throw ManabloxError.notFound('space.notFound', { spaceId: input.spaceId });
|
|
148
|
-
|
|
149
|
-
if (input.contentId) {
|
|
150
|
-
const row = await context.repos.content.findById(input.contentId);
|
|
151
|
-
if (!row || row.spaceId !== input.spaceId) {
|
|
152
|
-
throw ManabloxError.badRequest('content.notInSpace', { id: input.contentId });
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const settings = { ...space.settings };
|
|
157
|
-
if (input.contentId) settings.homeContentId = input.contentId;
|
|
158
|
-
else delete settings.homeContentId;
|
|
159
|
-
|
|
160
|
-
return context.repos.spaces.update(input.spaceId, { settings });
|
|
161
|
-
}),
|
|
71
|
+
.handler(async ({ input, context }) => context.spaces.setHome(input.spaceId, input.contentId)),
|
|
162
72
|
|
|
163
73
|
/**
|
|
164
|
-
* The
|
|
165
|
-
*
|
|
166
|
-
* export is every field of every document in one file, regardless of who may read what.
|
|
74
|
+
* The space's upload limits, each narrowing the instance's. `allowedMimeTypes` absent
|
|
75
|
+
* means the instance's list; empty means the same thing rather than "nothing".
|
|
167
76
|
*/
|
|
168
|
-
|
|
169
|
-
.input(
|
|
77
|
+
setAssetSettings: scoped('space:write')
|
|
78
|
+
.input(
|
|
79
|
+
z.object({
|
|
80
|
+
spaceId: uuid,
|
|
81
|
+
allowedMimeTypes: z.array(mimeTypePattern).max(50).optional(),
|
|
82
|
+
maxFileSize: z.number().int().positive().nullable().optional(),
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
170
85
|
.handler(async ({ input, context }) =>
|
|
171
|
-
|
|
86
|
+
context.spaces.setAssetSettings(input.spaceId, {
|
|
87
|
+
...(input.allowedMimeTypes?.length ? { allowedMimeTypes: input.allowedMimeTypes } : {}),
|
|
88
|
+
...(input.maxFileSize ? { maxFileSize: input.maxFileSize } : {}),
|
|
89
|
+
}),
|
|
172
90
|
),
|
|
173
91
|
|
|
174
92
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
93
|
+
* The whole space as one JSON document. `space:write` rather than `space:read` because
|
|
94
|
+
* an export is every field of every document in one file, regardless of who may read
|
|
95
|
+
* what.
|
|
177
96
|
*/
|
|
97
|
+
export: scoped('space:write')
|
|
98
|
+
.input(z.object({ spaceId: uuid }))
|
|
99
|
+
.handler(async ({ input, context }) => context.spaces.export(input.spaceId)),
|
|
100
|
+
|
|
101
|
+
/** Restores such a document into an instance that does not hold the space yet. Superadmin, because it creates a space. */
|
|
178
102
|
import: superadmin
|
|
179
103
|
.input(z.object({ payload: z.unknown() }))
|
|
180
|
-
.handler(async ({ input, context }) =>
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
context.principal.userId,
|
|
184
|
-
);
|
|
185
|
-
// Whoever imports a space owns it, exactly as if they had created it by hand.
|
|
186
|
-
await context.repos.users.grant(context.principal.userId, result.spaceId, 'owner');
|
|
187
|
-
return result;
|
|
188
|
-
}),
|
|
104
|
+
.handler(async ({ input, context }) =>
|
|
105
|
+
context.spaces.import(input.payload, context.principal.userId),
|
|
106
|
+
),
|
|
189
107
|
|
|
190
108
|
members: scoped('user:read')
|
|
191
109
|
.input(z.object({ spaceId: uuid }))
|
|
192
|
-
.handler(async ({ input, context }) => context.
|
|
110
|
+
.handler(async ({ input, context }) => context.spaces.members(input.spaceId)),
|
|
193
111
|
|
|
194
112
|
grant: scoped('user:write')
|
|
195
|
-
.input(
|
|
196
|
-
z.object({
|
|
197
|
-
spaceId: uuid,
|
|
198
|
-
userId: uuid,
|
|
199
|
-
role: z.enum(['owner', 'admin', 'editor', 'author', 'viewer']),
|
|
200
|
-
}),
|
|
201
|
-
)
|
|
113
|
+
.input(z.object({ spaceId: uuid, userId: uuid, role: spaceRole }))
|
|
202
114
|
.handler(async ({ input, context }) => {
|
|
203
|
-
|
|
204
|
-
if (input.role !== 'owner') {
|
|
205
|
-
await assertNotLastOwner(context, input.spaceId, input.userId);
|
|
206
|
-
}
|
|
207
|
-
await context.repos.users.grant(input.userId, input.spaceId, input.role);
|
|
115
|
+
await context.spaces.grant(input.spaceId, input.userId, input.role);
|
|
208
116
|
return { ok: true };
|
|
209
117
|
}),
|
|
210
118
|
|
|
211
|
-
/**
|
|
212
|
-
* Users who are not yet members, for the add-member picker. Scoped to `user:write` and
|
|
213
|
-
* returning only a name, an email and an id: a space admin needs to pick a colleague
|
|
214
|
-
* without being handed the instance's user directory, which is superadmin-only.
|
|
215
|
-
*/
|
|
119
|
+
/** Users who are not yet members, for the add-member picker. */
|
|
216
120
|
candidates: scoped('user:write')
|
|
217
|
-
.input(z.object({ spaceId: uuid, search:
|
|
218
|
-
.handler(async ({ input, context }) =>
|
|
219
|
-
const [members, all] = await Promise.all([
|
|
220
|
-
context.repos.users.membersOf(input.spaceId),
|
|
221
|
-
context.repos.users.list({ limit: 100, offset: 0 }, input.search),
|
|
222
|
-
]);
|
|
223
|
-
const taken = new Set(members.map((member) => member.userId));
|
|
224
|
-
return all.items
|
|
225
|
-
.filter((user) => !taken.has(user.id))
|
|
226
|
-
.map((user) => ({ id: user.id, name: user.name, email: user.email }));
|
|
227
|
-
}),
|
|
121
|
+
.input(z.object({ spaceId: uuid, search: searchTerm.optional() }))
|
|
122
|
+
.handler(async ({ input, context }) => context.spaces.candidates(input.spaceId, input.search)),
|
|
228
123
|
|
|
229
124
|
/** Grants the same role to several users at once, as the picker hands them over. */
|
|
230
125
|
addMembers: scoped('user:write')
|
|
@@ -232,36 +127,23 @@ export const spaceRouter = {
|
|
|
232
127
|
z.object({
|
|
233
128
|
spaceId: uuid,
|
|
234
129
|
userIds: z.array(uuid).min(1).max(100),
|
|
235
|
-
role:
|
|
130
|
+
role: spaceRole.default('editor'),
|
|
236
131
|
}),
|
|
237
132
|
)
|
|
238
|
-
.handler(async ({ input, context }) => {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const taken = new Set(members.map((member) => member.userId));
|
|
243
|
-
const added = input.userIds.filter((userId) => !taken.has(userId));
|
|
244
|
-
|
|
245
|
-
for (const userId of added) {
|
|
246
|
-
await context.repos.users.grant(userId, input.spaceId, input.role);
|
|
247
|
-
}
|
|
248
|
-
return { ok: true, added: added.length };
|
|
249
|
-
}),
|
|
133
|
+
.handler(async ({ input, context }) => ({
|
|
134
|
+
ok: true,
|
|
135
|
+
added: await context.spaces.addMembers(input.spaceId, input.userIds, input.role),
|
|
136
|
+
})),
|
|
250
137
|
|
|
251
138
|
revoke: scoped('user:write')
|
|
252
139
|
.input(z.object({ spaceId: uuid, userId: uuid }))
|
|
253
140
|
.handler(async ({ input, context }) => {
|
|
254
|
-
await
|
|
255
|
-
await context.repos.users.revoke(input.userId, input.spaceId);
|
|
141
|
+
await context.spaces.revoke(input.spaceId, input.userId);
|
|
256
142
|
return { ok: true };
|
|
257
143
|
}),
|
|
258
144
|
|
|
259
145
|
/** Locales available for a space, for the editor's language switcher. */
|
|
260
|
-
locales: base
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
available: space?.locales ?? context.manablox.config.locales.available,
|
|
264
|
-
default: space?.defaultLocale ?? context.manablox.config.locales.default,
|
|
265
|
-
};
|
|
266
|
-
}),
|
|
146
|
+
locales: base
|
|
147
|
+
.input(z.object({ spaceId: uuid }))
|
|
148
|
+
.handler(async ({ input, context }) => context.spaces.locales(input.spaceId)),
|
|
267
149
|
};
|
package/src/routers/user.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import { assertCan } from '@manablox/auth';
|
|
1
|
+
import { assertCan, effectiveGrants, MIN_PASSWORD_LENGTH, normaliseGrants } from '@manablox/auth';
|
|
2
|
+
import { ManabloxError } from '@manablox/core';
|
|
2
3
|
import { z } from 'zod';
|
|
3
|
-
import { authed, superadmin } from '../base.js';
|
|
4
|
+
import { authed, base, superadmin } from '../base.js';
|
|
5
|
+
import { pagination, searchTerm, uuid } from '../schemas.js';
|
|
4
6
|
|
|
5
|
-
const
|
|
7
|
+
const instanceRole = z.enum(['superadmin', 'editor']);
|
|
8
|
+
const password = z.string().min(MIN_PASSWORD_LENGTH).max(200);
|
|
9
|
+
const email = z.string().email().max(320);
|
|
10
|
+
const displayName = z.string().trim().min(1).max(200);
|
|
6
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The caller's own account and keys, and — for a superadmin — every account on the
|
|
14
|
+
* instance. The rules (no locking yourself out, one superadmin always remains) live in
|
|
15
|
+
* `UserService`; a procedure here is an input schema, a permission and one call.
|
|
16
|
+
*/
|
|
7
17
|
export const userRouter = {
|
|
8
18
|
me: authed.handler(async ({ context }) => {
|
|
9
19
|
const user = await context.repos.users.findById(context.principal.userId);
|
|
@@ -15,25 +25,88 @@ export const userRouter = {
|
|
|
15
25
|
image: user.image,
|
|
16
26
|
role: user.role,
|
|
17
27
|
spaces: context.principal.spaces,
|
|
28
|
+
// Space id → every grant held there, built-in roles resolved too, so the admin
|
|
29
|
+
// shows and hides controls from the same table the server enforces.
|
|
30
|
+
permissions: Object.fromEntries(
|
|
31
|
+
Object.keys(context.principal.spaces).map((spaceId) => [
|
|
32
|
+
spaceId,
|
|
33
|
+
effectiveGrants(context.principal, spaceId),
|
|
34
|
+
]),
|
|
35
|
+
),
|
|
18
36
|
}
|
|
19
37
|
: null;
|
|
20
38
|
}),
|
|
21
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Whether the instance still has no account at all. Public, because the login page
|
|
42
|
+
* needs it before anyone is signed in: it decides whether to offer "create the first
|
|
43
|
+
* account". Once one exists, sign-up is closed and every account is created here.
|
|
44
|
+
*/
|
|
45
|
+
setupNeeded: base.handler(async ({ context }) => ({
|
|
46
|
+
setupNeeded: (await context.repos.users.count()) === 0,
|
|
47
|
+
})),
|
|
48
|
+
|
|
22
49
|
list: superadmin
|
|
50
|
+
.input(pagination({ limit: 25, max: 100 }).extend({ search: searchTerm.optional() }))
|
|
51
|
+
.handler(async ({ input, context }) =>
|
|
52
|
+
context.users.list({ limit: input.limit, offset: input.offset }, input.search),
|
|
53
|
+
),
|
|
54
|
+
|
|
55
|
+
get: superadmin
|
|
56
|
+
.input(z.object({ userId: uuid }))
|
|
57
|
+
.handler(async ({ input, context }) => context.users.get(input.userId)),
|
|
58
|
+
|
|
59
|
+
create: superadmin
|
|
23
60
|
.input(
|
|
24
61
|
z.object({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
62
|
+
name: displayName,
|
|
63
|
+
email,
|
|
64
|
+
password,
|
|
65
|
+
role: instanceRole.default('editor'),
|
|
28
66
|
}),
|
|
29
67
|
)
|
|
68
|
+
.handler(async ({ input, context }) => context.users.create(input)),
|
|
69
|
+
|
|
70
|
+
update: superadmin
|
|
71
|
+
.input(z.object({ userId: uuid, name: displayName.optional(), email: email.optional() }))
|
|
72
|
+
.handler(async ({ input, context }) => {
|
|
73
|
+
const { userId, ...data } = input;
|
|
74
|
+
return context.users.update(userId, data);
|
|
75
|
+
}),
|
|
76
|
+
|
|
77
|
+
setRole: superadmin
|
|
78
|
+
.input(z.object({ userId: uuid, role: instanceRole }))
|
|
79
|
+
.handler(async ({ input, context }) => context.users.setRole(input.userId, input.role)),
|
|
80
|
+
|
|
81
|
+
/** Resets a password and signs the account out everywhere. */
|
|
82
|
+
setPassword: superadmin
|
|
83
|
+
.input(z.object({ userId: uuid, password }))
|
|
84
|
+
.handler(async ({ input, context }) => {
|
|
85
|
+
await context.users.setPassword(input.userId, input.password);
|
|
86
|
+
return { ok: true };
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
ban: superadmin
|
|
90
|
+
.input(z.object({ userId: uuid, reason: z.string().trim().max(500).optional() }))
|
|
30
91
|
.handler(async ({ input, context }) =>
|
|
31
|
-
context.
|
|
92
|
+
context.users.ban(context.principal.userId, input.userId, input.reason || null),
|
|
32
93
|
),
|
|
33
94
|
|
|
34
|
-
|
|
35
|
-
.input(z.object({ userId: uuid
|
|
36
|
-
.handler(async ({ input, context }) => context.
|
|
95
|
+
unban: superadmin
|
|
96
|
+
.input(z.object({ userId: uuid }))
|
|
97
|
+
.handler(async ({ input, context }) => context.users.unban(input.userId)),
|
|
98
|
+
|
|
99
|
+
revokeSessions: superadmin
|
|
100
|
+
.input(z.object({ userId: uuid }))
|
|
101
|
+
.handler(async ({ input, context }) => {
|
|
102
|
+
await context.users.revokeSessions(input.userId);
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}),
|
|
105
|
+
|
|
106
|
+
delete: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => {
|
|
107
|
+
await context.users.delete(context.principal.userId, input.userId);
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}),
|
|
37
110
|
|
|
38
111
|
apiKeys: authed.handler(async ({ context }) => context.apiKeys.list(context.principal.userId)),
|
|
39
112
|
|
|
@@ -44,6 +117,12 @@ export const userRouter = {
|
|
|
44
117
|
expiresAt: z.coerce.date().optional(),
|
|
45
118
|
/** Empty or omitted issues an unrestricted key. */
|
|
46
119
|
spaceIds: z.array(uuid).optional(),
|
|
120
|
+
/**
|
|
121
|
+
* Grants the key is confined to, in the roles' vocabulary; omitted leaves the
|
|
122
|
+
* owner's role as the limit. At use the two are intersected, so a grant here
|
|
123
|
+
* never widens the key beyond its owner.
|
|
124
|
+
*/
|
|
125
|
+
permissions: z.array(z.string().max(120)).max(500).optional(),
|
|
47
126
|
}),
|
|
48
127
|
)
|
|
49
128
|
.handler(async ({ input, context }) => {
|
|
@@ -52,9 +131,37 @@ export const userRouter = {
|
|
|
52
131
|
for (const spaceId of input.spaceIds ?? []) {
|
|
53
132
|
assertCan(context.principal, spaceId, 'space:read');
|
|
54
133
|
}
|
|
134
|
+
// A typed grant may name a type of any space the key will reach.
|
|
135
|
+
const reachable = input.spaceIds?.length
|
|
136
|
+
? input.spaceIds
|
|
137
|
+
: context.principal.role === 'superadmin'
|
|
138
|
+
? null
|
|
139
|
+
: Object.keys(context.principal.spaces);
|
|
140
|
+
const typeIds = new Set(
|
|
141
|
+
(reachable
|
|
142
|
+
? reachable.flatMap((spaceId) => context.manablox.contentTypes.forSpace(spaceId))
|
|
143
|
+
: context.manablox.contentTypes.all
|
|
144
|
+
).map((type) => type.id),
|
|
145
|
+
);
|
|
146
|
+
let permissions: string[] | null = null;
|
|
147
|
+
if (input.permissions) {
|
|
148
|
+
const checked = normaliseGrants(input.permissions, typeIds);
|
|
149
|
+
if (checked.unknown.length) {
|
|
150
|
+
throw ManabloxError.validation(
|
|
151
|
+
checked.unknown.map(({ index, grant }) => ({
|
|
152
|
+
key: 'role.permission.unknown' as const,
|
|
153
|
+
path: ['permissions', index],
|
|
154
|
+
params: { permission: grant },
|
|
155
|
+
})),
|
|
156
|
+
'apiKey.validation.failed',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
permissions = checked.permissions;
|
|
160
|
+
}
|
|
55
161
|
return context.apiKeys.issue(context.principal.userId, input.name, {
|
|
56
162
|
expiresAt: input.expiresAt,
|
|
57
163
|
spaceIds: input.spaceIds,
|
|
164
|
+
permissions,
|
|
58
165
|
});
|
|
59
166
|
}),
|
|
60
167
|
|