@crombolo/server 0.0.0-stage → 0.9.1
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +432 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +62 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +297 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1579 -0
- package/src/status.ts +246 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Users, user groups and user data over the identity tables, with Umbraco's
|
|
3
|
+
* rules about who may see and change whom: only admins see or touch admins,
|
|
4
|
+
* a non-admin may only hand out groups and start nodes they hold themselves,
|
|
5
|
+
* and nobody deletes or disables their own account.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
NodeLookup,
|
|
9
|
+
Principal,
|
|
10
|
+
Result,
|
|
11
|
+
UserDataPort,
|
|
12
|
+
UserGroupInput,
|
|
13
|
+
UserGroupPort,
|
|
14
|
+
UserGroupResponse,
|
|
15
|
+
UserGroupStatus,
|
|
16
|
+
UserPort,
|
|
17
|
+
UserResponse,
|
|
18
|
+
UserStatus,
|
|
19
|
+
} from '@crombolo/api-management'
|
|
20
|
+
import {
|
|
21
|
+
generateToken,
|
|
22
|
+
hashPassword,
|
|
23
|
+
hashToken,
|
|
24
|
+
passwordConfigJson,
|
|
25
|
+
verifyPassword,
|
|
26
|
+
} from '@crombolo/auth'
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_PASSWORD_CONFIGURATION,
|
|
29
|
+
generateUserPassword,
|
|
30
|
+
hasPathAccess,
|
|
31
|
+
isValidEmail,
|
|
32
|
+
isValidPassword,
|
|
33
|
+
normaliseUuid,
|
|
34
|
+
type Page,
|
|
35
|
+
permissionsForPath,
|
|
36
|
+
type StartNodes,
|
|
37
|
+
sectionAppAlias,
|
|
38
|
+
sectionKey,
|
|
39
|
+
sectionName,
|
|
40
|
+
} from '@crombolo/core'
|
|
41
|
+
import {
|
|
42
|
+
type Db,
|
|
43
|
+
DbDate,
|
|
44
|
+
type GranularPermission,
|
|
45
|
+
SUPER_USER_KEY,
|
|
46
|
+
UserDataRepository,
|
|
47
|
+
UserGroupRepository,
|
|
48
|
+
type UserRecord,
|
|
49
|
+
UserRepository,
|
|
50
|
+
userState,
|
|
51
|
+
} from '@crombolo/data'
|
|
52
|
+
import { loadAccess } from '../access.ts'
|
|
53
|
+
import type { CromboloConfig } from '../config.ts'
|
|
54
|
+
import type { MediaFileStore } from '../media-files.ts'
|
|
55
|
+
|
|
56
|
+
const INVITE_HOURS = 72
|
|
57
|
+
const RESET_HOURS = 24
|
|
58
|
+
const AVATAR_SIZES = [30, 60, 90, 150, 300]
|
|
59
|
+
const AVATAR_EXTENSIONS = new Set(['jpeg', 'jpg', 'gif', 'bmp', 'png', 'tiff', 'tif', 'webp'])
|
|
60
|
+
/** Umbraco's system groups: their alias is fixed and they cannot be deleted. */
|
|
61
|
+
const SYSTEM_GROUPS = new Set(['admin', 'sensitiveData', 'translator'])
|
|
62
|
+
/**
|
|
63
|
+
* Namespaces an API user's client ID away from the backoffice client's own,
|
|
64
|
+
* which is `umbraco-back-office` and stays as it is — the vendored client sends
|
|
65
|
+
* it. Migration 020 renamed the credentials that carried the old prefix.
|
|
66
|
+
*/
|
|
67
|
+
const CLIENT_ID_PREFIX = 'crombolo-back-office-'
|
|
68
|
+
const ISO_CODE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/i
|
|
69
|
+
|
|
70
|
+
export function avatarUrls(avatar: string | null): string[] {
|
|
71
|
+
return avatar ? AVATAR_SIZES.map((s) => `${avatar}?rmode=crop&width=${s}&height=${s}`) : []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const iso = (date: Date | undefined) => (date ? date.toISOString() : null)
|
|
75
|
+
const refs = (keys: readonly string[]) => keys.map((id) => ({ id }))
|
|
76
|
+
|
|
77
|
+
function toUserResponse(user: UserRecord, adminKey: string | undefined): UserResponse {
|
|
78
|
+
return {
|
|
79
|
+
id: user.key,
|
|
80
|
+
languageIsoCode: user.languageIsoCode,
|
|
81
|
+
documentStartNodeIds: refs(user.startNodes.document.keys),
|
|
82
|
+
hasDocumentRootAccess: user.startNodes.document.root,
|
|
83
|
+
mediaStartNodeIds: refs(user.startNodes.media.keys),
|
|
84
|
+
hasMediaRootAccess: user.startNodes.media.root,
|
|
85
|
+
elementStartNodeIds: refs(user.startNodes.element.keys),
|
|
86
|
+
hasElementRootAccess: user.startNodes.element.root,
|
|
87
|
+
avatarUrls: avatarUrls(user.avatar),
|
|
88
|
+
state: userState(user),
|
|
89
|
+
failedLoginAttempts: user.failedLoginAttempts,
|
|
90
|
+
createDate: user.createDate.toISOString(),
|
|
91
|
+
updateDate: user.updateDate.toISOString(),
|
|
92
|
+
lastLoginDate: iso(user.lastLoginDate),
|
|
93
|
+
lastLockoutDate: iso(user.lastLockoutDate),
|
|
94
|
+
lastPasswordChangeDate: iso(user.lastPasswordChangeDate),
|
|
95
|
+
isAdmin: adminKey !== undefined && user.groupKeys.includes(adminKey),
|
|
96
|
+
kind: user.kind,
|
|
97
|
+
email: user.email,
|
|
98
|
+
userName: user.userName,
|
|
99
|
+
name: user.name,
|
|
100
|
+
userGroupIds: refs(user.groupKeys),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type Tree = 'document' | 'media' | 'element'
|
|
105
|
+
|
|
106
|
+
export interface UserPortOptions {
|
|
107
|
+
config: Pick<
|
|
108
|
+
CromboloConfig,
|
|
109
|
+
'usernameIsEmail' | 'applicationUrl' | 'sendUserLink' | 'allowPasswordReset' | 'backOfficePath'
|
|
110
|
+
>
|
|
111
|
+
mediaFiles: MediaFileStore
|
|
112
|
+
nodes: NodeLookup
|
|
113
|
+
/** Signs a user out everywhere, as a password change set by someone else does. */
|
|
114
|
+
endSessions: (userId: number) => Promise<void>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createUserPorts(
|
|
118
|
+
db: Db,
|
|
119
|
+
options: UserPortOptions,
|
|
120
|
+
): { users: UserPort; groups: UserGroupPort; userData: UserDataPort } {
|
|
121
|
+
const users = new UserRepository(db)
|
|
122
|
+
const groups = new UserGroupRepository(db)
|
|
123
|
+
const { config, nodes } = options
|
|
124
|
+
const adminKey = async () => (await groups.byAlias('admin'))?.key
|
|
125
|
+
const isAdmin = (user: UserRecord, admin: string | undefined) =>
|
|
126
|
+
admin !== undefined && user.groupKeys.includes(admin)
|
|
127
|
+
|
|
128
|
+
/** Umbraco's `UserPermissionByResource`: only an admin may act on an admin. */
|
|
129
|
+
const mayActOn = async (principal: Principal, targets: readonly UserRecord[]) => {
|
|
130
|
+
if (principal.isAdmin) return true
|
|
131
|
+
const admin = await adminKey()
|
|
132
|
+
return !targets.some((t) => isAdmin(t, admin))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const chainsFor = async (tree: Tree, keys: readonly string[]) =>
|
|
136
|
+
tree === 'media' ? nodes.media(keys) : nodes.documents(keys)
|
|
137
|
+
|
|
138
|
+
/** Newly granted start nodes must lie where the performing user may work; root needs root. */
|
|
139
|
+
const mayGrantStartNodes = async (
|
|
140
|
+
performing: StartNodes,
|
|
141
|
+
tree: Tree,
|
|
142
|
+
granted: { root: boolean; keys: readonly string[] },
|
|
143
|
+
had: { root: boolean; keys: readonly string[] },
|
|
144
|
+
) => {
|
|
145
|
+
if (granted.root && !had.root && !performing.root) return false
|
|
146
|
+
const added = granted.keys.filter((k) => !had.keys.includes(normaliseUuid(k)))
|
|
147
|
+
if (added.length === 0 || tree === 'element') return true
|
|
148
|
+
const found = await chainsFor(tree, added)
|
|
149
|
+
return added.every((key) => {
|
|
150
|
+
const node = found.get(normaliseUuid(key))
|
|
151
|
+
return !node || hasPathAccess(performing, node.chain, node.trashed)
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Umbraco's `UserEditorAuthorizationHelper`: who may give which groups to whom. */
|
|
156
|
+
const mayAssignGroups = async (
|
|
157
|
+
principal: Principal,
|
|
158
|
+
target: UserRecord | undefined,
|
|
159
|
+
groupKeys: readonly string[],
|
|
160
|
+
) => {
|
|
161
|
+
if (principal.isAdmin) return true
|
|
162
|
+
const admin = await adminKey()
|
|
163
|
+
if (target && isAdmin(target, admin)) return false
|
|
164
|
+
const wanted = groupKeys.map(normaliseUuid)
|
|
165
|
+
if (admin && wanted.includes(admin)) return false
|
|
166
|
+
const had = new Set(target?.groupKeys ?? [])
|
|
167
|
+
const mine = new Set(principal.groupKeys.map(normaliseUuid))
|
|
168
|
+
return wanted.every((k) => had.has(k) || mine.has(k))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const visibleTo = async (principal: Principal) => {
|
|
172
|
+
const admin = await adminKey()
|
|
173
|
+
return (user: UserRecord) =>
|
|
174
|
+
(normaliseUuid(principal.id) === SUPER_USER_KEY || user.key !== SUPER_USER_KEY) &&
|
|
175
|
+
(principal.isAdmin || !isAdmin(user, admin))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const validateIdentity = async (
|
|
179
|
+
input: { email: string; userName: string; name: string },
|
|
180
|
+
self?: UserRecord,
|
|
181
|
+
): Promise<UserStatus> => {
|
|
182
|
+
if (!isValidEmail(input.email)) return 'InvalidEmail'
|
|
183
|
+
if (!input.userName) return 'InvalidUserName'
|
|
184
|
+
if (config.usernameIsEmail && input.userName.toLowerCase() !== input.email.toLowerCase())
|
|
185
|
+
return 'UserNameIsNotEmail'
|
|
186
|
+
const byName = await users.byUserName(input.userName)
|
|
187
|
+
if (byName && byName.key !== self?.key) return 'DuplicateUserName'
|
|
188
|
+
const byEmail = await users.byEmail(input.email)
|
|
189
|
+
if (byEmail && byEmail.key !== self?.key) return 'DuplicateEmail'
|
|
190
|
+
return 'Success'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const sendLink = async (user: UserRecord, kind: 'invite' | 'reset', message: string | null) => {
|
|
194
|
+
if (!config.sendUserLink) return false
|
|
195
|
+
const token = generateToken(32)
|
|
196
|
+
const hours = kind === 'invite' ? INVITE_HOURS : RESET_HOURS
|
|
197
|
+
await users.saveToken(user.id, kind, hashToken(token), new Date(Date.now() + hours * 3_600_000))
|
|
198
|
+
const flow =
|
|
199
|
+
kind === 'invite'
|
|
200
|
+
? `flow=invite-user&userId=${user.key}&inviteCode=${encodeURIComponent(token)}`
|
|
201
|
+
: `flow=reset-password&userId=${user.key}&resetCode=${encodeURIComponent(token)}`
|
|
202
|
+
try {
|
|
203
|
+
await config.sendUserLink({
|
|
204
|
+
kind,
|
|
205
|
+
to: { name: user.name, email: user.email },
|
|
206
|
+
link: `${config.applicationUrl}${config.backOfficePath}/login?${flow}`,
|
|
207
|
+
message,
|
|
208
|
+
})
|
|
209
|
+
} catch {
|
|
210
|
+
// The provider rejected it or could not be reached. The sender has already
|
|
211
|
+
// logged why; here it is only the difference between `Success` and
|
|
212
|
+
// `CannotInvite`, and the saved token stays valid for a resend.
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const setPassword = async (user: UserRecord, password: string) => {
|
|
219
|
+
await db.exec(
|
|
220
|
+
`UPDATE user_account SET password_hash = ?, password_config = ?, security_stamp = ?,
|
|
221
|
+
last_password_change_date = ?, failed_login_attempts = 0, update_date = ? WHERE id = ?`,
|
|
222
|
+
[
|
|
223
|
+
await hashPassword(password),
|
|
224
|
+
passwordConfigJson(),
|
|
225
|
+
crypto.randomUUID(),
|
|
226
|
+
DbDate.toDb(new Date()),
|
|
227
|
+
DbDate.toDb(new Date()),
|
|
228
|
+
user.id,
|
|
229
|
+
],
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function create(
|
|
234
|
+
principal: Principal,
|
|
235
|
+
input: Parameters<UserPort['create']>[1],
|
|
236
|
+
invited: boolean,
|
|
237
|
+
): Promise<Result<UserRecord>> {
|
|
238
|
+
const valid = await validateIdentity(input)
|
|
239
|
+
if (valid !== 'Success') return { status: valid }
|
|
240
|
+
if (input.groupKeys.length === 0) return { status: 'NoUserGroup' }
|
|
241
|
+
if ((await groups.byKeys(input.groupKeys)).length !== new Set(input.groupKeys).size)
|
|
242
|
+
return { status: 'MissingUserGroup' }
|
|
243
|
+
if (!(await mayAssignGroups(principal, undefined, input.groupKeys)))
|
|
244
|
+
return { status: 'Unauthorized' }
|
|
245
|
+
const created = await users.create({
|
|
246
|
+
key: input.key ?? crypto.randomUUID(),
|
|
247
|
+
name: input.name || input.userName,
|
|
248
|
+
userName: input.userName,
|
|
249
|
+
email: input.email,
|
|
250
|
+
kind: input.kind,
|
|
251
|
+
groupKeys: input.groupKeys,
|
|
252
|
+
languageIsoCode: null,
|
|
253
|
+
invited,
|
|
254
|
+
})
|
|
255
|
+
return created ? { status: 'Success', value: created } : { status: 'MissingUserGroup' }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const findAll = async (keys: readonly string[]) => {
|
|
259
|
+
const found = await users.byKeys(keys)
|
|
260
|
+
return found.length === new Set(keys.map(normaliseUuid)).size ? found : undefined
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const userPort: UserPort = {
|
|
264
|
+
configuration: () => ({
|
|
265
|
+
canInviteUsers: Boolean(config.sendUserLink),
|
|
266
|
+
usernameIsEmail: config.usernameIsEmail,
|
|
267
|
+
passwordConfiguration: DEFAULT_PASSWORD_CONFIGURATION,
|
|
268
|
+
allowChangePassword: true,
|
|
269
|
+
allowTwoFactor: false,
|
|
270
|
+
}),
|
|
271
|
+
currentConfiguration: () => ({
|
|
272
|
+
keepUserLoggedIn: false,
|
|
273
|
+
passwordConfiguration: DEFAULT_PASSWORD_CONFIGURATION,
|
|
274
|
+
allowChangePassword: true,
|
|
275
|
+
allowTwoFactor: false,
|
|
276
|
+
}),
|
|
277
|
+
passwordConfiguration: () => DEFAULT_PASSWORD_CONFIGURATION,
|
|
278
|
+
|
|
279
|
+
async list(principal, skip, take) {
|
|
280
|
+
const admin = await adminKey()
|
|
281
|
+
const visible = (await users.all()).filter(await visibleTo(principal))
|
|
282
|
+
visible.sort((a, b) => a.userName.localeCompare(b.userName))
|
|
283
|
+
return {
|
|
284
|
+
total: visible.length,
|
|
285
|
+
items: visible.slice(skip, skip + take).map((u) => toUserResponse(u, admin)),
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
async filter(principal, query) {
|
|
290
|
+
const admin = await adminKey()
|
|
291
|
+
const text = query.filter?.toLowerCase()
|
|
292
|
+
const wantedGroups = query.groupKeys.map(normaliseUuid)
|
|
293
|
+
const states = query.states.filter((s) => s !== 'All')
|
|
294
|
+
const visible = (await users.all())
|
|
295
|
+
.filter(await visibleTo(principal))
|
|
296
|
+
.filter(
|
|
297
|
+
(u) =>
|
|
298
|
+
!text || u.name.toLowerCase().includes(text) || u.userName.toLowerCase().includes(text),
|
|
299
|
+
)
|
|
300
|
+
.filter(
|
|
301
|
+
(u) => wantedGroups.length === 0 || u.groupKeys.some((g) => wantedGroups.includes(g)),
|
|
302
|
+
)
|
|
303
|
+
.filter((u) => states.length === 0 || states.includes(userState(u)))
|
|
304
|
+
const field = (u: UserRecord): string | number => {
|
|
305
|
+
switch (query.orderBy) {
|
|
306
|
+
case 'Name':
|
|
307
|
+
return u.name.toLowerCase()
|
|
308
|
+
case 'Email':
|
|
309
|
+
return u.email.toLowerCase()
|
|
310
|
+
case 'Language':
|
|
311
|
+
return u.languageIsoCode ?? ''
|
|
312
|
+
case 'Id':
|
|
313
|
+
return u.id
|
|
314
|
+
case 'CreateDate':
|
|
315
|
+
return u.createDate.getTime()
|
|
316
|
+
case 'UpdateDate':
|
|
317
|
+
return u.updateDate.getTime()
|
|
318
|
+
case 'IsApproved':
|
|
319
|
+
return u.isApproved ? 1 : 0
|
|
320
|
+
case 'IsLockedOut':
|
|
321
|
+
return u.isLockedOut ? 1 : 0
|
|
322
|
+
case 'LastLoginDate':
|
|
323
|
+
return u.lastLoginDate?.getTime() ?? 0
|
|
324
|
+
default:
|
|
325
|
+
return u.userName.toLowerCase()
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const sign = query.direction === 'Descending' ? -1 : 1
|
|
329
|
+
visible.sort((a, b) => {
|
|
330
|
+
const x = field(a)
|
|
331
|
+
const y = field(b)
|
|
332
|
+
return (x < y ? -1 : x > y ? 1 : 0) * sign
|
|
333
|
+
})
|
|
334
|
+
return {
|
|
335
|
+
total: visible.length,
|
|
336
|
+
items: visible
|
|
337
|
+
.slice(query.skip, query.skip + query.take)
|
|
338
|
+
.map((u) => toUserResponse(u, admin)),
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
async get(principal, key) {
|
|
343
|
+
const user = await users.byKey(key)
|
|
344
|
+
if (!user) return { status: 'UserNotFound' }
|
|
345
|
+
if (!(await mayActOn(principal, [user]))) return { status: 'Forbidden' }
|
|
346
|
+
return { status: 'Success', value: toUserResponse(user, await adminKey()) }
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
async batch(principal, keys) {
|
|
350
|
+
const admin = await adminKey()
|
|
351
|
+
const visible = await visibleTo(principal)
|
|
352
|
+
return (await users.byKeys(keys)).filter(visible).map((u) => toUserResponse(u, admin))
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
async create(principal, input) {
|
|
356
|
+
const result = await create(principal, input, false)
|
|
357
|
+
return result.status === 'Success' ? { status: 'Success', value: result.value.key } : result
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
async invite(principal, input, message) {
|
|
361
|
+
if (!config.sendUserLink) return { status: 'CannotInvite' }
|
|
362
|
+
const result = await create(principal, { ...input, kind: 'Default' }, true)
|
|
363
|
+
if (result.status !== 'Success') return result
|
|
364
|
+
await sendLink(result.value, 'invite', message)
|
|
365
|
+
return { status: 'Success', value: result.value.key }
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
async resendInvite(principal, key, message) {
|
|
369
|
+
const user = await users.byKey(key)
|
|
370
|
+
if (!user) return 'UserNotFound'
|
|
371
|
+
if (!(await mayActOn(principal, [user]))) return 'Forbidden'
|
|
372
|
+
if (userState(user) !== 'Invited') return 'NotInInviteState'
|
|
373
|
+
return (await sendLink(user, 'invite', message)) ? 'Success' : 'CannotInvite'
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
async verifyInvite(key, token) {
|
|
377
|
+
const user = await users.byKey(key)
|
|
378
|
+
if (!user) return 'UserNotFound'
|
|
379
|
+
if (userState(user) !== 'Invited') return 'NotInInviteState'
|
|
380
|
+
return (await users.checkToken(user.id, 'invite', hashToken(token), false))
|
|
381
|
+
? 'Success'
|
|
382
|
+
: 'InvalidInviteToken'
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
async createInitialPassword(key, token, password) {
|
|
386
|
+
const user = await users.byKey(key)
|
|
387
|
+
if (!user) return 'UserNotFound'
|
|
388
|
+
if (userState(user) !== 'Invited') return 'NotInInviteState'
|
|
389
|
+
if (!isValidPassword(password)) return 'InvalidPassword'
|
|
390
|
+
if (!(await users.checkToken(user.id, 'invite', hashToken(token), true)))
|
|
391
|
+
return 'InvalidInviteToken'
|
|
392
|
+
await setPassword(user, password)
|
|
393
|
+
await users.approve(user.key)
|
|
394
|
+
return 'Success'
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
async update(principal, key, input) {
|
|
398
|
+
const user = await users.byKey(key)
|
|
399
|
+
if (!user) return 'UserNotFound'
|
|
400
|
+
if (!(await mayActOn(principal, [user]))) return 'Forbidden'
|
|
401
|
+
const valid = await validateIdentity(input, user)
|
|
402
|
+
if (valid !== 'Success') return valid
|
|
403
|
+
if (input.languageIsoCode && !ISO_CODE.test(input.languageIsoCode)) return 'InvalidIsoCode'
|
|
404
|
+
if (input.groupKeys.length === 0) return 'NoUserGroup'
|
|
405
|
+
if ((await groups.byKeys(input.groupKeys)).length !== new Set(input.groupKeys).size)
|
|
406
|
+
return 'MissingUserGroup'
|
|
407
|
+
if (!(await mayAssignGroups(principal, user, input.groupKeys))) return 'Unauthorized'
|
|
408
|
+
const startNodes = {
|
|
409
|
+
document: { root: input.hasDocumentRootAccess, keys: input.documentStartNodeKeys },
|
|
410
|
+
media: { root: input.hasMediaRootAccess, keys: input.mediaStartNodeKeys },
|
|
411
|
+
element: { root: input.hasElementRootAccess, keys: input.elementStartNodeKeys },
|
|
412
|
+
}
|
|
413
|
+
for (const tree of ['document', 'media', 'element'] as const)
|
|
414
|
+
if (
|
|
415
|
+
!(await mayGrantStartNodes(
|
|
416
|
+
principal.startNodes[tree],
|
|
417
|
+
tree,
|
|
418
|
+
startNodes[tree],
|
|
419
|
+
user.startNodes[tree],
|
|
420
|
+
))
|
|
421
|
+
)
|
|
422
|
+
return 'Unauthorized'
|
|
423
|
+
const result = await users.update(key, {
|
|
424
|
+
name: input.name,
|
|
425
|
+
userName: input.userName,
|
|
426
|
+
email: input.email,
|
|
427
|
+
languageIsoCode: input.languageIsoCode || null,
|
|
428
|
+
groupKeys: input.groupKeys,
|
|
429
|
+
startNodes,
|
|
430
|
+
})
|
|
431
|
+
if (result === 'not-found') return 'UserNotFound'
|
|
432
|
+
if (result === 'missing-group') return 'MissingUserGroup'
|
|
433
|
+
if (result === 'document') return 'ContentStartNodeNotFound'
|
|
434
|
+
if (result === 'media') return 'MediaStartNodeNotFound'
|
|
435
|
+
if (result === 'element') return 'ElementStartNodeNotFound'
|
|
436
|
+
return 'Success'
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
async delete(principal, keys) {
|
|
440
|
+
if (keys.map(normaliseUuid).includes(normaliseUuid(principal.id))) return 'CannotDeleteSelf'
|
|
441
|
+
const found = await findAll(keys)
|
|
442
|
+
if (!found) return 'UserNotFound'
|
|
443
|
+
if (!(await mayActOn(principal, found))) return 'Forbidden'
|
|
444
|
+
if (found.some((u) => u.lastLoginDate)) return 'CannotDeleteUserWithLoginHistory'
|
|
445
|
+
for (const user of found) {
|
|
446
|
+
if (user.avatar) await options.mediaFiles.remove(user.avatar)
|
|
447
|
+
await users.delete(user.key)
|
|
448
|
+
}
|
|
449
|
+
return 'Success'
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
async setApproved(principal, keys, approved) {
|
|
453
|
+
if (!approved && keys.map(normaliseUuid).includes(normaliseUuid(principal.id)))
|
|
454
|
+
return 'CannotDisableSelf'
|
|
455
|
+
const found = await findAll(keys)
|
|
456
|
+
if (!found) return 'UserNotFound'
|
|
457
|
+
if (!(await mayActOn(principal, found))) return 'Forbidden'
|
|
458
|
+
if (!approved && found.some((u) => userState(u) === 'Invited'))
|
|
459
|
+
return 'CannotDisableInvitedUser'
|
|
460
|
+
await users.setApproved(keys, approved)
|
|
461
|
+
if (!approved) for (const user of found) await options.endSessions(user.id)
|
|
462
|
+
return 'Success'
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
async unlock(principal, keys) {
|
|
466
|
+
const found = await findAll(keys)
|
|
467
|
+
if (!found) return 'UserNotFound'
|
|
468
|
+
if (!(await mayActOn(principal, found))) return 'Forbidden'
|
|
469
|
+
await users.unlock(keys)
|
|
470
|
+
return 'Success'
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
async setGroups(principal, userKeys, groupKeys) {
|
|
474
|
+
const found = await findAll(userKeys)
|
|
475
|
+
if (!found) return 'UserNotFound'
|
|
476
|
+
if (!(await mayActOn(principal, found))) return 'Forbidden'
|
|
477
|
+
for (const user of found)
|
|
478
|
+
if (!(await mayAssignGroups(principal, user, groupKeys))) return 'Unauthorized'
|
|
479
|
+
return (await users.setGroups(userKeys, groupKeys)) ? 'Success' : 'MissingUserGroup'
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
async changePassword(principal, key, newPassword, oldPassword) {
|
|
483
|
+
const user = await users.byKey(key)
|
|
484
|
+
if (!user) return 'UserNotFound'
|
|
485
|
+
if (!(await mayActOn(principal, [user]))) return 'Forbidden'
|
|
486
|
+
const self = user.key === normaliseUuid(principal.id)
|
|
487
|
+
if (self) {
|
|
488
|
+
if (!oldPassword) return 'SelfOldPasswordRequired'
|
|
489
|
+
if (!(await verifyPassword(oldPassword, await users.passwordHash(user.key))))
|
|
490
|
+
return 'InvalidPassword'
|
|
491
|
+
}
|
|
492
|
+
if (!isValidPassword(newPassword)) return 'InvalidPassword'
|
|
493
|
+
await setPassword(user, newPassword)
|
|
494
|
+
if (!self) await options.endSessions(user.id)
|
|
495
|
+
return 'Success'
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
async resetPassword(principal, key) {
|
|
499
|
+
const user = await users.byKey(key)
|
|
500
|
+
if (!user) return { status: 'UserNotFound' }
|
|
501
|
+
if (!(await mayActOn(principal, [user]))) return { status: 'Forbidden' }
|
|
502
|
+
const password = generateUserPassword()
|
|
503
|
+
await setPassword(user, password)
|
|
504
|
+
if (user.key !== normaliseUuid(principal.id)) await options.endSessions(user.id)
|
|
505
|
+
return { status: 'Success', value: password }
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
async setAvatar(principal, key, temporaryFileId) {
|
|
509
|
+
const user = await users.byKey(key)
|
|
510
|
+
if (!user) return 'UserNotFound'
|
|
511
|
+
if (!(await mayActOn(principal, [user]))) return 'Forbidden'
|
|
512
|
+
const upload = await options.mediaFiles.temporary(temporaryFileId)
|
|
513
|
+
if (!upload) return 'AvatarFileNotFound'
|
|
514
|
+
if (!AVATAR_EXTENSIONS.has(upload.fileName.split('.').pop()?.toLowerCase() ?? ''))
|
|
515
|
+
return 'InvalidAvatar'
|
|
516
|
+
const placed = await options.mediaFiles.place(temporaryFileId)
|
|
517
|
+
if (!placed) return 'AvatarFileNotFound'
|
|
518
|
+
if (user.avatar) await options.mediaFiles.remove(user.avatar)
|
|
519
|
+
await users.setAvatar(user.key, placed.src)
|
|
520
|
+
return 'Success'
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
async removeAvatar(principal, key) {
|
|
524
|
+
const user = await users.byKey(key)
|
|
525
|
+
if (!user) return 'UserNotFound'
|
|
526
|
+
if (!(await mayActOn(principal, [user]))) return 'Forbidden'
|
|
527
|
+
if (user.avatar) await options.mediaFiles.remove(user.avatar)
|
|
528
|
+
await users.setAvatar(user.key, null)
|
|
529
|
+
return 'Success'
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
async calculateStartNodes(principal, key) {
|
|
533
|
+
const user = await users.byKey(key)
|
|
534
|
+
if (!user) return { status: 'UserNotFound' }
|
|
535
|
+
if (!(await mayActOn(principal, [user]))) return { status: 'Forbidden' }
|
|
536
|
+
const { startNodes } = await loadAccess(db, user.id)
|
|
537
|
+
return {
|
|
538
|
+
status: 'Success',
|
|
539
|
+
value: {
|
|
540
|
+
id: user.key,
|
|
541
|
+
documentStartNodeIds: refs(startNodes.document.keys),
|
|
542
|
+
hasDocumentRootAccess: startNodes.document.root,
|
|
543
|
+
mediaStartNodeIds: refs(startNodes.media.keys),
|
|
544
|
+
hasMediaRootAccess: startNodes.media.root,
|
|
545
|
+
elementStartNodeIds: refs(startNodes.element.keys),
|
|
546
|
+
hasElementRootAccess: startNodes.element.root,
|
|
547
|
+
},
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
async clientCredentials(principal, key) {
|
|
552
|
+
const user = await users.byKey(key)
|
|
553
|
+
if (!user) return { status: 'UserNotFound' }
|
|
554
|
+
if (!(await mayActOn(principal, [user]))) return { status: 'Forbidden' }
|
|
555
|
+
return { status: 'Success', value: await users.clientCredentials(user.id) }
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
async addClientCredential(principal, key, clientId, secret) {
|
|
559
|
+
const user = await users.byKey(key)
|
|
560
|
+
if (!user) return 'UserNotFound'
|
|
561
|
+
if (!(await mayActOn(principal, [user]))) return 'Forbidden'
|
|
562
|
+
if (user.kind !== 'Api') return 'InvalidUserType'
|
|
563
|
+
if (!clientId.startsWith(CLIENT_ID_PREFIX) || clientId.length <= CLIENT_ID_PREFIX.length)
|
|
564
|
+
return 'InvalidClientId'
|
|
565
|
+
if (!isValidPassword(secret)) return 'InvalidPassword'
|
|
566
|
+
return (await users.addClientCredential(user.id, clientId, await hashPassword(secret)))
|
|
567
|
+
? 'Success'
|
|
568
|
+
: 'DuplicateClientId'
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
async removeClientCredential(principal, key, clientId) {
|
|
572
|
+
const user = await users.byKey(key)
|
|
573
|
+
if (!user) return 'UserNotFound'
|
|
574
|
+
if (!(await mayActOn(principal, [user]))) return 'Forbidden'
|
|
575
|
+
await users.removeClientCredential(user.id, clientId)
|
|
576
|
+
return 'Success'
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
async setLanguage(principal, isoCode) {
|
|
580
|
+
if (!ISO_CODE.test(isoCode)) return 'InvalidIsoCode'
|
|
581
|
+
await users.setLanguage(principal.id, isoCode)
|
|
582
|
+
return 'Success'
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
async permissions(principal, tree, keys) {
|
|
586
|
+
if (tree !== 'document') {
|
|
587
|
+
// Media carries no verbs; element verbs are the groups' own until the Library exists
|
|
588
|
+
const verbs =
|
|
589
|
+
tree === 'element'
|
|
590
|
+
? [
|
|
591
|
+
...new Set(
|
|
592
|
+
principal.groups.flatMap((g) =>
|
|
593
|
+
g.defaults.filter((p) => p.startsWith('Umb.Element')),
|
|
594
|
+
),
|
|
595
|
+
),
|
|
596
|
+
]
|
|
597
|
+
: []
|
|
598
|
+
return keys.map((nodeKey) => ({ nodeKey: normaliseUuid(nodeKey), permissions: verbs }))
|
|
599
|
+
}
|
|
600
|
+
const found = await nodes.documents(keys)
|
|
601
|
+
return keys.flatMap((key) => {
|
|
602
|
+
const node = found.get(normaliseUuid(key))
|
|
603
|
+
if (!node) return []
|
|
604
|
+
return [
|
|
605
|
+
{
|
|
606
|
+
nodeKey: normaliseUuid(key),
|
|
607
|
+
permissions: [...permissionsForPath(principal.groups, node.chain)],
|
|
608
|
+
},
|
|
609
|
+
]
|
|
610
|
+
})
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
async forgotPassword(email) {
|
|
614
|
+
if (!config.allowPasswordReset || !isValidEmail(email)) return
|
|
615
|
+
const user = await users.byEmail(email)
|
|
616
|
+
if (!user?.isApproved || user.isLockedOut) return
|
|
617
|
+
await sendLink(user, 'reset', null)
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
async verifyResetCode(key, code) {
|
|
621
|
+
const user = await users.byKey(key)
|
|
622
|
+
if (!user) return 'UserNotFound'
|
|
623
|
+
return (await users.checkToken(user.id, 'reset', hashToken(code), false))
|
|
624
|
+
? 'Success'
|
|
625
|
+
: 'InvalidResetCode'
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
async resetWithCode(key, code, password) {
|
|
629
|
+
const user = await users.byKey(key)
|
|
630
|
+
if (!user) return 'UserNotFound'
|
|
631
|
+
if (!isValidPassword(password)) return 'InvalidPassword'
|
|
632
|
+
if (!(await users.checkToken(user.id, 'reset', hashToken(code), true)))
|
|
633
|
+
return 'InvalidResetCode'
|
|
634
|
+
await setPassword(user, password)
|
|
635
|
+
await options.endSessions(user.id)
|
|
636
|
+
return 'Success'
|
|
637
|
+
},
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
users: userPort,
|
|
642
|
+
groups: createUserGroupPort(db, { adminKey, nodes }),
|
|
643
|
+
userData: createUserDataPort(db),
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function toPermissions(rows: readonly GranularPermission[]): UserGroupResponse['permissions'] {
|
|
648
|
+
const out: UserGroupResponse['permissions'] = []
|
|
649
|
+
const byKey = new Map<string, Set<string>>()
|
|
650
|
+
const properties = new Map<
|
|
651
|
+
string,
|
|
652
|
+
{ documentType: string; propertyType: string; verbs: Set<string> }
|
|
653
|
+
>()
|
|
654
|
+
const unknown = new Map<string, Set<string>>()
|
|
655
|
+
for (const row of rows) {
|
|
656
|
+
if (row.context === 'Document' && row.key) {
|
|
657
|
+
const verbs = byKey.get(row.key) ?? new Set<string>()
|
|
658
|
+
if (row.permission) verbs.add(row.permission)
|
|
659
|
+
byKey.set(row.key, verbs)
|
|
660
|
+
} else if (row.context === 'DocumentTypeProperty' && row.key) {
|
|
661
|
+
const [propertyType, verb = ''] = row.permission.split('|')
|
|
662
|
+
if (!propertyType) continue
|
|
663
|
+
const id = `${row.key}|${propertyType}`
|
|
664
|
+
const entry = properties.get(id) ?? {
|
|
665
|
+
documentType: row.key,
|
|
666
|
+
propertyType: normaliseUuid(propertyType),
|
|
667
|
+
verbs: new Set<string>(),
|
|
668
|
+
}
|
|
669
|
+
if (verb) entry.verbs.add(verb)
|
|
670
|
+
properties.set(id, entry)
|
|
671
|
+
} else if (!row.key) {
|
|
672
|
+
const verbs = unknown.get(row.context) ?? new Set<string>()
|
|
673
|
+
if (row.permission) verbs.add(row.permission)
|
|
674
|
+
unknown.set(row.context, verbs)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
for (const [document, verbs] of byKey)
|
|
678
|
+
out.push({
|
|
679
|
+
$type: 'DocumentPermissionPresentationModel',
|
|
680
|
+
document: { id: document },
|
|
681
|
+
verbs: [...verbs],
|
|
682
|
+
})
|
|
683
|
+
for (const entry of properties.values())
|
|
684
|
+
out.push({
|
|
685
|
+
$type: 'DocumentPropertyValuePermissionPresentationModel',
|
|
686
|
+
documentType: { id: entry.documentType },
|
|
687
|
+
propertyType: { id: entry.propertyType },
|
|
688
|
+
verbs: [...entry.verbs],
|
|
689
|
+
})
|
|
690
|
+
for (const [context, verbs] of unknown)
|
|
691
|
+
out.push({ $type: 'UnknownTypePermissionPresentationModel', context, verbs: [...verbs] })
|
|
692
|
+
return out
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function fromPermissions(permissions: UserGroupInput['permissions']): GranularPermission[] {
|
|
696
|
+
const rows: GranularPermission[] = []
|
|
697
|
+
for (const raw of permissions as unknown as Array<Record<string, unknown>>) {
|
|
698
|
+
const verbs = Array.isArray(raw.verbs)
|
|
699
|
+
? [...new Set((raw.verbs as unknown[]).filter((v): v is string => typeof v === 'string'))]
|
|
700
|
+
: []
|
|
701
|
+
const idOf = (ref: unknown) => {
|
|
702
|
+
const id = (ref as { id?: unknown } | null | undefined)?.id
|
|
703
|
+
return typeof id === 'string' ? normaliseUuid(id) : null
|
|
704
|
+
}
|
|
705
|
+
switch (raw.$type) {
|
|
706
|
+
case 'DocumentPermissionPresentationModel': {
|
|
707
|
+
const key = idOf(raw.document)
|
|
708
|
+
// No verbs is an explicit "nothing here", stored as one empty verb, as Umbraco does
|
|
709
|
+
if (key)
|
|
710
|
+
for (const verb of verbs.length ? verbs : [''])
|
|
711
|
+
rows.push({ key, permission: verb, context: 'Document' })
|
|
712
|
+
break
|
|
713
|
+
}
|
|
714
|
+
case 'ElementPermissionPresentationModel': {
|
|
715
|
+
const key = idOf(raw.element)
|
|
716
|
+
if (key)
|
|
717
|
+
for (const verb of verbs.length ? verbs : [''])
|
|
718
|
+
rows.push({ key, permission: verb, context: 'Element' })
|
|
719
|
+
break
|
|
720
|
+
}
|
|
721
|
+
case 'DocumentPropertyValuePermissionPresentationModel': {
|
|
722
|
+
const documentType = idOf(raw.documentType)
|
|
723
|
+
const propertyType = idOf(raw.propertyType)
|
|
724
|
+
if (documentType && propertyType)
|
|
725
|
+
for (const verb of verbs.length ? verbs : [''])
|
|
726
|
+
rows.push({
|
|
727
|
+
key: documentType,
|
|
728
|
+
permission: `${propertyType}|${verb}`,
|
|
729
|
+
context: 'DocumentTypeProperty',
|
|
730
|
+
})
|
|
731
|
+
break
|
|
732
|
+
}
|
|
733
|
+
case 'UnknownTypePermissionPresentationModel':
|
|
734
|
+
if (typeof raw.context === 'string')
|
|
735
|
+
for (const verb of verbs) rows.push({ key: null, permission: verb, context: raw.context })
|
|
736
|
+
break
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return rows
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function createUserGroupPort(
|
|
743
|
+
db: Db,
|
|
744
|
+
deps: { adminKey: () => Promise<string | undefined>; nodes: NodeLookup },
|
|
745
|
+
): UserGroupPort {
|
|
746
|
+
const groups = new UserGroupRepository(db)
|
|
747
|
+
const users = new UserRepository(db)
|
|
748
|
+
|
|
749
|
+
const toResponse = async (group: Awaited<ReturnType<UserGroupRepository['byKey']>> & object) => ({
|
|
750
|
+
id: group.key,
|
|
751
|
+
isDeletable: !SYSTEM_GROUPS.has(group.alias),
|
|
752
|
+
aliasCanBeChanged: !SYSTEM_GROUPS.has(group.alias),
|
|
753
|
+
name: group.name,
|
|
754
|
+
alias: group.alias,
|
|
755
|
+
description: group.description,
|
|
756
|
+
icon: group.icon,
|
|
757
|
+
sections: group.sections.map(sectionName),
|
|
758
|
+
languages: group.languages,
|
|
759
|
+
hasAccessToAllLanguages: group.hasAccessToAllLanguages,
|
|
760
|
+
documentStartNode: group.startNodes.document.key ? { id: group.startNodes.document.key } : null,
|
|
761
|
+
documentRootAccess: group.startNodes.document.root,
|
|
762
|
+
mediaStartNode: group.startNodes.media.key ? { id: group.startNodes.media.key } : null,
|
|
763
|
+
mediaRootAccess: group.startNodes.media.root,
|
|
764
|
+
elementStartNode: group.startNodes.element.key ? { id: group.startNodes.element.key } : null,
|
|
765
|
+
elementRootAccess: group.startNodes.element.root,
|
|
766
|
+
fallbackPermissions: group.permissions,
|
|
767
|
+
permissions: toPermissions(group.granular),
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
const isMember = (principal: Principal, key: string) =>
|
|
771
|
+
principal.isAdmin || principal.groupKeys.map(normaliseUuid).includes(normaliseUuid(key))
|
|
772
|
+
|
|
773
|
+
/** Umbraco's `UserGroupService.ValidateAccess` and input rules for create and update. */
|
|
774
|
+
const validate = async (
|
|
775
|
+
principal: Principal,
|
|
776
|
+
input: UserGroupInput,
|
|
777
|
+
existing?: { key: string; alias: string },
|
|
778
|
+
): Promise<UserGroupStatus> => {
|
|
779
|
+
if (!input.name) return 'MissingName'
|
|
780
|
+
if (input.name.length > 200) return 'NameTooLong'
|
|
781
|
+
if (input.alias.length > 200) return 'AliasTooLong'
|
|
782
|
+
if (existing && SYSTEM_GROUPS.has(existing.alias) && input.alias !== existing.alias)
|
|
783
|
+
return 'CanNotUpdateAliasIsSystemUserGroup'
|
|
784
|
+
const clash = await groups.byAlias(input.alias)
|
|
785
|
+
if (clash && clash.key !== existing?.key) return 'DuplicateAlias'
|
|
786
|
+
const mine = new Set(principal.allowedSections.map(sectionKey))
|
|
787
|
+
if (!mine.has('users')) return 'Unauthorized'
|
|
788
|
+
if (!principal.isAdmin) {
|
|
789
|
+
// A grant is compared by section, not by spelling, so a group may hold an
|
|
790
|
+
// extension's section only if the caller holds it too.
|
|
791
|
+
if (!input.sections.every((s) => mine.has(sectionKey(s)))) return 'Unauthorized'
|
|
792
|
+
for (const [tree, key] of [
|
|
793
|
+
['document', input.documentStartNode],
|
|
794
|
+
['media', input.mediaStartNode],
|
|
795
|
+
] as const) {
|
|
796
|
+
if (!key) continue
|
|
797
|
+
const found = await (tree === 'media'
|
|
798
|
+
? deps.nodes.media([key])
|
|
799
|
+
: deps.nodes.documents([key]))
|
|
800
|
+
const node = found.get(normaliseUuid(key))
|
|
801
|
+
if (node && !hasPathAccess(principal.startNodes[tree], node.chain, node.trashed))
|
|
802
|
+
return 'Unauthorized'
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return 'Success'
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const save = async (input: UserGroupInput, key: string): Promise<UserGroupStatus> => {
|
|
809
|
+
const result = await groups.save({
|
|
810
|
+
key,
|
|
811
|
+
alias: input.alias,
|
|
812
|
+
name: input.name,
|
|
813
|
+
description: input.description,
|
|
814
|
+
icon: input.icon,
|
|
815
|
+
sections: input.sections.map(sectionAppAlias),
|
|
816
|
+
languages: input.languages,
|
|
817
|
+
hasAccessToAllLanguages: input.hasAccessToAllLanguages,
|
|
818
|
+
startNodes: {
|
|
819
|
+
document: { root: input.documentRootAccess, key: input.documentStartNode },
|
|
820
|
+
media: { root: input.mediaRootAccess, key: input.mediaStartNode },
|
|
821
|
+
element: { root: input.elementRootAccess, key: input.elementStartNode },
|
|
822
|
+
},
|
|
823
|
+
permissions: input.fallbackPermissions,
|
|
824
|
+
granular: fromPermissions(input.permissions),
|
|
825
|
+
})
|
|
826
|
+
if (result === 'saved') return 'Success'
|
|
827
|
+
if (result === 'missing-language') return 'LanguageNotFound'
|
|
828
|
+
return result.missingNode === 'document'
|
|
829
|
+
? 'DocumentStartNodeKeyNotFound'
|
|
830
|
+
: result.missingNode === 'media'
|
|
831
|
+
? 'MediaStartNodeKeyNotFound'
|
|
832
|
+
: 'ElementStartNodeKeyNotFound'
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const page = async (
|
|
836
|
+
list: Awaited<ReturnType<UserGroupRepository['all']>>,
|
|
837
|
+
skip: number,
|
|
838
|
+
take: number,
|
|
839
|
+
) => ({
|
|
840
|
+
total: list.length,
|
|
841
|
+
items: await Promise.all(list.slice(skip, skip + take).map(toResponse)),
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
const adminWouldBeEmpty = async (groupKey: string, removing: readonly string[]) => {
|
|
845
|
+
if (normaliseUuid(groupKey) !== (await deps.adminKey())) return false
|
|
846
|
+
const members = (await users.all()).filter((u) => u.groupKeys.includes(normaliseUuid(groupKey)))
|
|
847
|
+
const gone = new Set(removing.map(normaliseUuid))
|
|
848
|
+
return members.every((m) => gone.has(m.key))
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
list: async (skip, take) => page(await groups.all(), skip, take),
|
|
853
|
+
|
|
854
|
+
async filter(principal, filter, skip, take) {
|
|
855
|
+
const admin = await deps.adminKey()
|
|
856
|
+
const text = filter.toLowerCase()
|
|
857
|
+
const list = (await groups.all())
|
|
858
|
+
.filter((g) => principal.isAdmin || (g.key !== admin && isMember(principal, g.key)))
|
|
859
|
+
.filter((g) => !text || g.name.toLowerCase().includes(text))
|
|
860
|
+
return page(list, skip, take)
|
|
861
|
+
},
|
|
862
|
+
|
|
863
|
+
async get(principal, key) {
|
|
864
|
+
const group = await groups.byKey(key)
|
|
865
|
+
if (!group) return 'NotFound'
|
|
866
|
+
if (!isMember(principal, group.key)) return 'Forbidden'
|
|
867
|
+
return toResponse(group)
|
|
868
|
+
},
|
|
869
|
+
|
|
870
|
+
async items(keys) {
|
|
871
|
+
return (await groups.byKeys(keys)).map((g) => ({
|
|
872
|
+
id: g.key,
|
|
873
|
+
name: g.name,
|
|
874
|
+
icon: g.icon,
|
|
875
|
+
alias: g.alias,
|
|
876
|
+
flags: [],
|
|
877
|
+
}))
|
|
878
|
+
},
|
|
879
|
+
|
|
880
|
+
async create(principal, input) {
|
|
881
|
+
const valid = await validate(principal, input)
|
|
882
|
+
if (valid !== 'Success') return { status: valid }
|
|
883
|
+
const key = normaliseUuid(input.key ?? crypto.randomUUID())
|
|
884
|
+
if (await groups.byKey(key)) return { status: 'DuplicateAlias' }
|
|
885
|
+
const saved = await save(input, key)
|
|
886
|
+
if (saved !== 'Success') return { status: saved }
|
|
887
|
+
// A non-admin who creates a group joins it, as in Umbraco
|
|
888
|
+
if (!principal.isAdmin) await users.addToGroup(key, [principal.id])
|
|
889
|
+
return { status: 'Success', key }
|
|
890
|
+
},
|
|
891
|
+
|
|
892
|
+
async update(principal, key, input) {
|
|
893
|
+
const group = await groups.byKey(key)
|
|
894
|
+
if (!group) return 'NotFound'
|
|
895
|
+
if (!isMember(principal, group.key)) return 'Forbidden'
|
|
896
|
+
const valid = await validate(principal, input, group)
|
|
897
|
+
if (valid !== 'Success') return valid
|
|
898
|
+
return save(input, group.key)
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
async delete(principal, keys) {
|
|
902
|
+
const found = await groups.byKeys(keys)
|
|
903
|
+
if (found.length !== new Set(keys.map(normaliseUuid)).size) return 'NotFound'
|
|
904
|
+
if (!found.every((g) => isMember(principal, g.key))) return 'Forbidden'
|
|
905
|
+
if (found.some((g) => SYSTEM_GROUPS.has(g.alias))) return 'IsSystemUserGroup'
|
|
906
|
+
for (const group of found) await groups.delete(group.key)
|
|
907
|
+
return 'Success'
|
|
908
|
+
},
|
|
909
|
+
|
|
910
|
+
async addUsers(principal, key, userKeys) {
|
|
911
|
+
const group = await groups.byKey(key)
|
|
912
|
+
if (!group) return 'NotFound'
|
|
913
|
+
if (!isMember(principal, group.key)) return 'Forbidden'
|
|
914
|
+
if ((await users.byKeys(userKeys)).length !== new Set(userKeys.map(normaliseUuid)).size)
|
|
915
|
+
return 'UserNotFound'
|
|
916
|
+
await users.addToGroup(group.key, userKeys)
|
|
917
|
+
return 'Success'
|
|
918
|
+
},
|
|
919
|
+
|
|
920
|
+
async removeUsers(principal, key, userKeys) {
|
|
921
|
+
const group = await groups.byKey(key)
|
|
922
|
+
if (!group) return 'NotFound'
|
|
923
|
+
if (!isMember(principal, group.key)) return 'Forbidden'
|
|
924
|
+
if ((await users.byKeys(userKeys)).length !== new Set(userKeys.map(normaliseUuid)).size)
|
|
925
|
+
return 'UserNotFound'
|
|
926
|
+
if (await adminWouldBeEmpty(group.key, userKeys)) return 'AdminGroupCannotBeEmpty'
|
|
927
|
+
await users.removeFromGroup(group.key, userKeys)
|
|
928
|
+
return 'Success'
|
|
929
|
+
},
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function createUserDataPort(db: Db): UserDataPort {
|
|
934
|
+
const repo = new UserDataRepository(db)
|
|
935
|
+
const idOf = async (principal: Principal) =>
|
|
936
|
+
(await new UserRepository(db).byKey(principal.id))?.id ?? -1
|
|
937
|
+
return {
|
|
938
|
+
async list(principal, filter, skip, take) {
|
|
939
|
+
return repo.list(await idOf(principal), filter, skip, take) as Promise<
|
|
940
|
+
Page<{
|
|
941
|
+
key: string
|
|
942
|
+
group: string
|
|
943
|
+
identifier: string
|
|
944
|
+
value: string
|
|
945
|
+
}>
|
|
946
|
+
>
|
|
947
|
+
},
|
|
948
|
+
get: async (principal, key) => repo.get(await idOf(principal), key),
|
|
949
|
+
async create(principal, entry) {
|
|
950
|
+
return (await repo.create(await idOf(principal), entry)) === 'Success'
|
|
951
|
+
? 'Success'
|
|
952
|
+
: 'AlreadyExists'
|
|
953
|
+
},
|
|
954
|
+
async update(principal, entry) {
|
|
955
|
+
return (await repo.update(await idOf(principal), entry)) === 'Success'
|
|
956
|
+
? 'Success'
|
|
957
|
+
: 'NotFound'
|
|
958
|
+
},
|
|
959
|
+
async delete(principal, key) {
|
|
960
|
+
return (await repo.delete(await idOf(principal), key)) === 'Success' ? 'Success' : 'NotFound'
|
|
961
|
+
},
|
|
962
|
+
}
|
|
963
|
+
}
|