Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import crypto from 'node:crypto';
2import { type Kysely } from 'kysely';
3
4import type { Dependencies } from '../../iocContainer/index.js';
5import { inject } from '../../iocContainer/utils.js';
6import {
7 UserPermission,
8 type Invoker,
9 type UserRole,
10} from '../../models/types/permissioning.js';
11import {
12 makeNotFoundError,
13 makeUnauthorizedError,
14} from '../../utils/errors.js';
15import { asyncRandomBytes } from '../../utils/misc.js';
16import { HOUR_MS } from '../../utils/time.js';
17import { CoopEmailAddress } from '../sendEmailService/sendEmailService.js';
18import type { MrtChartConfig } from './dbTypes.js';
19import type { UserManagementPg } from './index.js';
20import { hashPassword } from './utils.js';
21
22class UserManagementService {
23 constructor(
24 private readonly pgQuery: Kysely<UserManagementPg>,
25 private readonly sendEmail: Dependencies['sendEmail'],
26 private readonly configService: Dependencies['ConfigService'],
27 ) {}
28
29 async getUserInterfaceSettings(opts: { userId: string; orgId: string }) {
30 const { userId, orgId } = opts;
31 const row = await this.pgQuery
32 .selectFrom('user_management_service.user_interface_settings')
33 .selectAll()
34 .where('user_id', '=', userId)
35 .executeTakeFirst();
36
37 if (
38 row &&
39 row.moderator_safety_grayscale &&
40 row.moderator_safety_blur_level &&
41 row.moderator_safety_mute_video
42 ) {
43 // If all the user's settings have been set, just return them
44 return {
45 moderatorSafetyGrayscale: row.moderator_safety_grayscale,
46 moderatorSafetyBlurLevel: row.moderator_safety_blur_level,
47 moderatorSafetyMuteVideo: row.moderator_safety_mute_video,
48 mrtChartConfigurations: row.mrt_chart_configurations ?? [],
49 };
50 }
51
52 // Otherwise, merge the user's settings with the org's default settings,
53 // prioritizing the user's settings over the defaults.
54 const orgDefaults = await this.getOrgDefaultUserInterfaceSettings(orgId);
55 return {
56 moderatorSafetyGrayscale:
57 row?.moderator_safety_grayscale ?? orgDefaults.moderatorSafetyGrayscale,
58 moderatorSafetyBlurLevel:
59 row?.moderator_safety_blur_level ??
60 orgDefaults.moderatorSafetyBlurLevel,
61 moderatorSafetyMuteVideo:
62 row?.moderator_safety_mute_video ??
63 orgDefaults.moderatorSafetyMuteVideo,
64 mrtChartConfigurations: row?.mrt_chart_configurations ?? [],
65 };
66 }
67
68 async getInviteUserToken(opts: { token: string }) {
69 const { token } = opts;
70 const tokenRow = await this.pgQuery
71 .selectFrom('public.invite_user_tokens')
72 .selectAll()
73 .where('token', '=', token)
74 .executeTakeFirst();
75
76 if (tokenRow == null) {
77 return null;
78 }
79
80 const orgSettings = await this.pgQuery
81 .selectFrom('public.org_settings')
82 .select('saml_enabled')
83 .where('org_id', '=', tokenRow.org_id)
84 .executeTakeFirst();
85
86 return {
87 ...tokenRow,
88 orgId: tokenRow.org_id,
89 createdAt: tokenRow.created_at,
90 samlEnabled: orgSettings?.saml_enabled ?? false,
91 };
92 }
93
94 async createInviteUserToken(opts: {
95 email: string;
96 role: UserRole;
97 orgId: string;
98 }) {
99 const { email, role, orgId } = opts;
100
101 const token = (await asyncRandomBytes(32)).toString('hex');
102 await this.pgQuery
103 .insertInto('public.invite_user_tokens')
104 .values({ token, email, role, org_id: orgId })
105 .execute();
106 return token;
107 }
108
109 async getPendingInvites(
110 orgId: string,
111 ): Promise<
112 Array<{ id: string; email: string; role: UserRole; createdAt: string }>
113 > {
114 const result = await this.pgQuery
115 .selectFrom('public.invite_user_tokens')
116 .selectAll()
117 .where('org_id', '=', orgId)
118 .orderBy('created_at', 'desc')
119 .execute();
120
121 return result.map((row) => ({
122 id: row.id,
123 email: row.email,
124 role: row.role,
125 createdAt: row.created_at.toISOString(),
126 }));
127 }
128
129 async deleteInvite(inviteId: string, orgId: string): Promise<boolean> {
130 const result = await this.pgQuery
131 .deleteFrom('public.invite_user_tokens')
132 .where('id', '=', inviteId)
133 .where('org_id', '=', orgId)
134 .execute();
135
136 return result.length > 0;
137 }
138
139 async upsertUserInterfaceSettings(input: {
140 userId: string;
141 userInterfaceSettings: {
142 moderatorSafetySettings?: {
143 moderatorSafetyMuteVideo: boolean;
144 moderatorSafetyGrayscale: boolean;
145 moderatorSafetyBlurLevel: number;
146 };
147 mrtChartConfigurations?: readonly MrtChartConfig[];
148 };
149 }) {
150 const { userId, userInterfaceSettings } = input;
151 const { moderatorSafetySettings, mrtChartConfigurations } =
152 userInterfaceSettings;
153
154 const dbFormattedInterfaceSettings = {
155 ...(moderatorSafetySettings
156 ? {
157 moderator_safety_grayscale:
158 moderatorSafetySettings.moderatorSafetyGrayscale,
159 moderator_safety_blur_level:
160 moderatorSafetySettings.moderatorSafetyBlurLevel,
161 moderator_safety_mute_video:
162 moderatorSafetySettings.moderatorSafetyMuteVideo,
163 }
164 : {}),
165 ...(mrtChartConfigurations
166 ? {
167 mrt_chart_configurations: [...mrtChartConfigurations],
168 }
169 : {}),
170 };
171
172 let query = this.pgQuery
173 .insertInto('user_management_service.user_interface_settings')
174 .values({
175 user_id: userId,
176 ...dbFormattedInterfaceSettings,
177 });
178
179 // Only add onConflict if there are fields to update
180 if (Object.keys(dbFormattedInterfaceSettings).length > 0) {
181 query = query.onConflict((oc) =>
182 oc.column('user_id').doUpdateSet({
183 ...dbFormattedInterfaceSettings,
184 }),
185 );
186 } else {
187 // If no update fields, just do nothing on conflict
188 query = query.onConflict((oc) => oc.column('user_id').doNothing());
189 }
190
191 return query.returningAll().execute();
192 }
193
194 async getOrgDefaultUserInterfaceSettings(orgId: string) {
195 const row = await this.pgQuery
196 .selectFrom('user_management_service.org_default_user_interface_settings')
197 .selectAll()
198 .where('org_id', '=', orgId)
199 // Every org should have a default interface settings row
200 .executeTakeFirstOrThrow();
201
202 return {
203 moderatorSafetyGrayscale: row.moderator_safety_grayscale,
204 moderatorSafetyBlurLevel: row.moderator_safety_blur_level,
205 moderatorSafetyMuteVideo: row.moderator_safety_mute_video,
206 };
207 }
208
209 async upsertOrgDefaultUserInterfaceSettings(opts: {
210 orgId: string;
211 // If you don't provide these values, they will be set to the default values
212 // configured on the pg table definition
213 moderatorSafetyGrayscale?: boolean;
214 moderatorSafetyBlurLevel?: number;
215 moderatorSafetyMuteVideo?: boolean;
216 }) {
217 const {
218 orgId,
219 moderatorSafetyGrayscale,
220 moderatorSafetyBlurLevel,
221 moderatorSafetyMuteVideo,
222 } = opts;
223 const updateFields = {
224 ...(moderatorSafetyGrayscale !== undefined
225 ? { moderator_safety_grayscale: moderatorSafetyGrayscale }
226 : {}),
227 ...(moderatorSafetyBlurLevel !== undefined
228 ? { moderator_safety_blur_level: moderatorSafetyBlurLevel }
229 : {}),
230 ...(moderatorSafetyMuteVideo !== undefined
231 ? { moderator_safety_mute_video: moderatorSafetyMuteVideo }
232 : {}),
233 };
234
235 let query = this.pgQuery
236 .insertInto('user_management_service.org_default_user_interface_settings')
237 .values([
238 {
239 org_id: orgId,
240 moderator_safety_grayscale: moderatorSafetyGrayscale,
241 moderator_safety_blur_level: moderatorSafetyBlurLevel,
242 moderator_safety_mute_video: moderatorSafetyMuteVideo,
243 },
244 ]);
245
246 // Only add onConflict if there are fields to update
247 if (Object.keys(updateFields).length > 0) {
248 query = query.onConflict((oc) =>
249 // Explicitly check for undefined because these values are booleans and
250 // numbers, so they can be falsey
251 oc.column('org_id').doUpdateSet(updateFields),
252 );
253 } else {
254 // If no update fields, just do nothing on conflict
255 query = query.onConflict((oc) => oc.column('org_id').doNothing());
256 }
257
258 await query.execute();
259 }
260
261 async updateUserRole(input: {
262 userId: string;
263 newRole: UserRole;
264 orgId: string;
265 invoker: Invoker;
266 }): Promise<void> {
267 const { userId, newRole, orgId, invoker } = input;
268 if (orgId !== invoker.orgId) {
269 throw makeUnauthorizedError(
270 'User does not have permission to change roles in another org',
271 { shouldErrorSpan: true },
272 );
273 }
274
275 if (!invoker.permissions.includes(UserPermission.MANAGE_ORG)) {
276 throw makeUnauthorizedError(
277 'User does not have permission to change roles',
278 { shouldErrorSpan: true },
279 );
280 }
281
282 const result = await this.pgQuery
283 .updateTable('public.users')
284 .set({ role: newRole })
285 .where('id', '=', userId)
286 .where('org_id', '=', invoker.orgId)
287 .executeTakeFirst();
288
289 if (result.numUpdatedRows === 0n) {
290 throw makeNotFoundError('User not found', { shouldErrorSpan: true });
291 }
292 }
293
294 /**
295 * NB: this function is a no-op if the email does not exist in the database.
296 * However, in that case, we do return earlier than if the email had been
297 * found, so callers should NOT await this function, and return to the end
298 * user before the call completes in order to prevent timing attacks.
299 */
300 async sendPasswordResetEmail(opts: { email: string }) {
301 const { email } = opts;
302
303 const existingUser = await this.pgQuery
304 .selectFrom('public.users')
305 .select(['id as userId', 'org_id as orgId'])
306 .where('email', '=', email)
307 .executeTakeFirst();
308
309 if (existingUser == null) {
310 return;
311 }
312
313 const { userId, orgId } = existingUser;
314 const token = await this.#createPasswordResetToken({ userId, orgId });
315
316 const url = new URL(`${this.configService.uiUrl}/reset_password/` + token);
317 const msg = {
318 to: email,
319 from: CoopEmailAddress.NoReply,
320 subject: '[Coop] Reset your password',
321 html: `You recently indicated that you forgot your Coop password. Click on <a href='${url.href}'>this link</a> to create a new password. The link expires in 1 hour, so please make sure to sign up soon.
322 <br /><br />
323 Best,<br />
324 Coop Support Team`,
325 };
326
327 await this.sendEmail(msg);
328 }
329
330 /**
331 * Generate a password reset token for a specific user (for admin use).
332 * Returns the raw token that can be shared with the user.
333 * Also sends an email to the user using the standard password reset flow.
334 */
335 async generatePasswordResetTokenForUser(opts: {
336 userId: string;
337 invokerOrgId: string;
338 }) {
339 const { userId, invokerOrgId } = opts;
340
341 const existingUser = await this.pgQuery
342 .selectFrom('public.users')
343 .select(['email', 'org_id as orgId'])
344 .where('id', '=', userId)
345 .executeTakeFirst();
346
347 if (existingUser == null) {
348 throw makeNotFoundError('User not found', { shouldErrorSpan: true });
349 }
350
351 // Security check: ensure admin can only reset passwords for users in their own org
352 if (existingUser.orgId !== invokerOrgId) {
353 throw makeUnauthorizedError(
354 'You can only reset passwords for users in your organization',
355 { shouldErrorSpan: true },
356 );
357 }
358
359 const { email } = existingUser;
360
361 const token = await this.#createPasswordResetToken({
362 userId,
363 orgId: existingUser.orgId,
364 });
365
366 // Send email using the standard flow (will be no-op if SendGrid not configured)
367 const url = new URL(`${this.configService.uiUrl}/reset_password/` + token);
368 const msg = {
369 to: email,
370 from: CoopEmailAddress.NoReply,
371 subject: '[Coop] Reset your password',
372 html: `Your organization administrator has initiated a password reset for your account. Click on <a href='${url.href}'>this link</a> to create a new password. The link expires in 1 hour, so please make sure to reset your password soon.
373 <br /><br />
374 Best,<br />
375 Coop Support Team`,
376 };
377
378 await this.sendEmail(msg);
379
380 return token;
381 }
382
383 async #createPasswordResetToken(opts: { userId: string; orgId: string }) {
384 const { userId, orgId } = opts;
385 const token = (await asyncRandomBytes(32)).toString('hex');
386 const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
387
388 // delete tokens for the user
389 await this.pgQuery
390 .deleteFrom('user_management_service.password_reset_tokens')
391 .where('user_id', '=', userId)
392 .execute();
393
394 // create new token
395 await this.pgQuery
396 .insertInto('user_management_service.password_reset_tokens')
397 .values({
398 user_id: userId,
399 org_id: orgId,
400 hashed_token: hashedToken,
401 created_at: new Date(),
402 })
403 .execute();
404
405 return token;
406 }
407
408 /**
409 * NB: we use a hashed token instead of comparing the token directly to
410 * prevent timing attacks (by 'removing' any attempts at direct manipulation
411 * of the input via the hash function). If we were not hashing the token, we
412 * would be vulnerable to timing attacks because an attacker could measure the
413 * time to compare the token and use that information to iteratively guess the
414 * token.
415 */
416 async resetPasswordForToken(opts: { token: string; newPassword: string }) {
417 const { token, newPassword } = opts;
418
419 const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
420
421 // Step 1: Validate token
422 const fetchedToken = await this.pgQuery
423 .selectFrom('user_management_service.password_reset_tokens')
424 .selectAll()
425 .where('hashed_token', '=', hashedToken)
426 .executeTakeFirst();
427
428 if (fetchedToken == null) {
429 return;
430 }
431
432 // NB: Tokens expire one hour after creation
433 if (Date.now() - new Date(fetchedToken.created_at).getTime() > HOUR_MS) {
434 return;
435 }
436
437 // Step 2: reset password for that token's user
438 await this.pgQuery
439 .updateTable('public.users')
440 .set({ password: await hashPassword(newPassword) })
441 .where('id', '=', fetchedToken.user_id)
442 .execute();
443
444 // Step 3: Delete all tokens for the user
445 await this.pgQuery
446 .deleteFrom('user_management_service.password_reset_tokens')
447 .where('user_id', '=', fetchedToken.user_id)
448 .execute();
449 }
450
451 async getUsersForOrg(orgId: string) {
452 return this.pgQuery
453 .selectFrom('public.users')
454 .select([
455 'id',
456 'email',
457 'first_name as firstName',
458 'last_name as lastName',
459 'role',
460 ])
461 .where('org_id', '=', orgId)
462 .where('rejected_by_admin', '=', false)
463 .where('approved_by_admin', '=', true)
464 .execute();
465 }
466}
467
468export default inject(
469 ['KyselyPg', 'sendEmail', 'ConfigService'],
470 UserManagementService,
471);
472export { type UserManagementService };