Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 472 lines 15 kB view raw
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 };