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 340 lines 9.2 kB view raw
1/* eslint-disable max-classes-per-file */ 2 3import { type Exception } from '@opentelemetry/api'; 4import { DataSource } from 'apollo-datasource'; 5import { type PassportContext } from 'graphql-passport'; 6import { uid } from 'uid'; 7 8import { inject, type Dependencies } from '../../iocContainer/index.js'; 9import { type Rule } from '../../models/rules/RuleModel.js'; 10import { type User as TUser } from '../../models/UserModel.js'; 11import { hashPassword } from '../../services/userManagementService/index.js'; 12import { 13 CoopError, 14 ErrorType, 15 makeBadRequestError, 16 makeInternalServerError, 17 makeUnauthorizedError, 18 type ErrorInstanceData, 19} from '../../utils/errors.js'; 20import { safePick } from '../../utils/misc.js'; 21import { WEEK_MS } from '../../utils/time.js'; 22 23/** 24 * GraphQL Object for a User 25 */ 26class UserAPI extends DataSource { 27 constructor( 28 private readonly sequelize: Dependencies['Sequelize'], 29 private readonly tracer: Dependencies['Tracer'], 30 private readonly userManagementService: Dependencies['UserManagementService'], 31 ) { 32 super(); 33 } 34 35 async getGraphQLUserFromId(opts: { id: string; orgId: string }) { 36 const { id, orgId } = opts; 37 38 return this.sequelize.User.findOne({ 39 where: { 40 id, 41 orgId, 42 }, 43 rejectOnEmpty: true, 44 }); 45 } 46 47 async getGraphQLUsersFromIds(ids: string[]) { 48 return this.sequelize.User.findAll({ 49 where: { id: ids }, 50 }); 51 } 52 53 async login(params: any, context: PassportContext<TUser, any>) { 54 const credentials = safePick(params.input, ['email', 'password']); 55 56 // NB: this will throw for bad credentials; will be handled in the resolver. 57 const { user } = await context.authenticate('graphql-local', credentials); 58 59 if (!user) { 60 throw makeInternalServerError('Unknown error during login attempt', { 61 shouldErrorSpan: true, 62 }); 63 } 64 65 await context.login(user); 66 67 return user; 68 } 69 70 async logout(context: any) { 71 try { 72 context.logout(); 73 return true; 74 } catch (_) { 75 return false; 76 } 77 } 78 79 async signUp(params: any, _: any) { 80 const { role } = params.input; 81 const { 82 email, 83 password, 84 firstName, 85 lastName, 86 orgId, 87 inviteUserToken, 88 loginMethod, 89 } = params.input; 90 91 if (password == null && loginMethod === 'PASSWORD') 92 throw makeBadRequestError( 93 'Password is required for password login method', 94 { shouldErrorSpan: true }, 95 ); 96 97 const existingUser = await this.sequelize.User.findOne({ 98 where: { email }, 99 }); 100 if (existingUser != null) { 101 throw makeSignUpUserExistsError({ shouldErrorSpan: true }); 102 } 103 const passwordToSave = 104 password == null ? null : await hashPassword(password); 105 106 let token; 107 if (inviteUserToken != null) { 108 token = await this.userManagementService.getInviteUserToken({ 109 token: inviteUserToken, 110 }); 111 } 112 if ( 113 !( 114 token != null && 115 token.email === email && 116 token.orgId === orgId && 117 token.role === role && 118 Date.now() - new Date(token.createdAt).getTime() < 2 * WEEK_MS 119 ) 120 ) { 121 throw makeUnauthorizedError('Invalid invite token', { 122 shouldErrorSpan: true, 123 }); 124 } 125 126 const user = await this.sequelize.User.create({ 127 id: uid(), 128 email, 129 password: passwordToSave, 130 firstName, 131 lastName, 132 role: token.role, 133 approvedByAdmin: true, 134 orgId, 135 loginMethods: [loginMethod.toLowerCase()], 136 }); 137 138 // Delete the invite token after successful user creation 139 await this.userManagementService.deleteInvite(token.id, orgId); 140 141 return user; 142 } 143 144 async updateAccountInfo( 145 user: TUser, 146 params: { firstName?: string | null; lastName?: string | null }, 147 ) { 148 const { firstName, lastName } = params; 149 if (firstName != null) { 150 user.firstName = firstName; 151 } 152 if (lastName != null) { 153 user.lastName = lastName; 154 } 155 await user.save(); 156 } 157 158 async changePassword( 159 user: TUser, 160 params: { currentPassword: string; newPassword: string }, 161 ) { 162 const { currentPassword, newPassword } = params; 163 164 // Check if user has password login method 165 if (!user.loginMethods.includes('password')) { 166 throw makeChangePasswordNotAllowedError({ 167 detail: 'Password login is not enabled for this user.', 168 shouldErrorSpan: true, 169 }); 170 } 171 172 // Verify current password 173 if (user.password == null) { 174 throw makeChangePasswordIncorrectPasswordError({ 175 detail: 'Current password is not set.', 176 shouldErrorSpan: true, 177 }); 178 } 179 180 const isCurrentPasswordValid = await this.sequelize.User.passwordMatchesHash( 181 currentPassword, 182 user.password, 183 ); 184 185 if (!isCurrentPasswordValid) { 186 throw makeChangePasswordIncorrectPasswordError({ 187 shouldErrorSpan: true, 188 }); 189 } 190 191 // Hash and save new password 192 const hashedNewPassword = await hashPassword(newPassword); 193 user.password = hashedNewPassword; 194 await user.save(); 195 196 return { 197 __typename: 'ChangePasswordSuccessResponse' as const, 198 _: true, 199 }; 200 } 201 202 async deleteUser(opts: { id: string; orgId: string }) { 203 const { id, orgId } = opts; 204 try { 205 const user = await this.sequelize.User.findOne({ where: { id, orgId } }); 206 await user?.destroy(); 207 } catch (exception) { 208 const activeSpan = this.tracer.getActiveSpan(); 209 if (activeSpan?.isRecording()) { 210 activeSpan.recordException(exception as Exception); 211 } 212 return false; 213 } 214 return true; 215 } 216 217 async approveUser(id: string, invokerOrgId: string) { 218 const user = await this.sequelize.User.findByPk(id, { 219 rejectOnEmpty: true, 220 }); 221 222 // Security check: ensure admin can only approve users in their own org 223 if (user.orgId !== invokerOrgId) { 224 throw makeUnauthorizedError( 225 'You can only approve users in your organization', 226 { shouldErrorSpan: true }, 227 ); 228 } 229 230 user.approvedByAdmin = true; 231 await user.save(); 232 return true; 233 } 234 235 async rejectUser(id: string, invokerOrgId: string) { 236 const user = await this.sequelize.User.findByPk(id, { 237 rejectOnEmpty: true, 238 }); 239 240 // Security check: ensure admin can only reject users in their own org 241 if (user.orgId !== invokerOrgId) { 242 throw makeUnauthorizedError( 243 'You can only reject users in your organization', 244 { shouldErrorSpan: true }, 245 ); 246 } 247 248 user.rejectedByAdmin = true; 249 await user.save(); 250 return true; 251 } 252 253 async getFavoriteRules(id: string, orgId: string): Promise<Array<Rule>> { 254 const user = await this.getGraphQLUserFromId({ id, orgId }); 255 const rules = await user.getFavoriteRules(); 256 return rules; 257 } 258 259 async addFavoriteRule(userId: string, ruleId: string, orgId: string) { 260 const user = await this.getGraphQLUserFromId({ id: userId, orgId }); 261 await user.addFavoriteRules([ruleId]); 262 } 263 264 async removeFavoriteRule(userId: string, ruleId: string, orgId: string) { 265 const user = await this.getGraphQLUserFromId({ id: userId, orgId }); 266 await user.removeFavoriteRules([ruleId]); 267 } 268} 269 270export default inject( 271 ['Sequelize', 'Tracer', 'UserManagementService'], 272 UserAPI, 273); 274export type { UserAPI }; 275 276export type UserErrorType = 277 | 'LoginUserDoesNotExistError' 278 | 'LoginIncorrectPasswordError' 279 | 'LoginSsoRequiredError' 280 | 'CannotDeleteDefaultUserError' 281 | 'ChangePasswordIncorrectPasswordError' 282 | 'ChangePasswordNotAllowedError'; 283 284export const makeLoginUserDoesNotExistError = (data: ErrorInstanceData) => 285 new CoopError({ 286 status: 401, 287 type: [ErrorType.Unauthenticated], 288 title: 'User with this email does not exist.', 289 name: 'LoginUserDoesNotExistError', 290 ...data, 291 }); 292 293export const makeLoginIncorrectPasswordError = (data: ErrorInstanceData) => 294 new CoopError({ 295 status: 401, 296 type: [ErrorType.Unauthenticated], 297 title: 'Incorrect password.', 298 name: 'LoginIncorrectPasswordError', 299 ...data, 300 }); 301 302export const makeLoginSsoRequiredError = (data: ErrorInstanceData) => 303 new CoopError({ 304 status: 401, 305 type: [ErrorType.Unauthenticated], 306 title: 'SSO Login is Required', 307 name: 'LoginSsoRequiredError', 308 ...data, 309 }); 310 311export type SignUpErrorType = 'SignUpUserExistsError'; 312 313export const makeSignUpUserExistsError = (data: ErrorInstanceData) => 314 new CoopError({ 315 status: 409, 316 type: [ErrorType.UniqueViolation], 317 title: 'User with this email already exists.', 318 name: 'SignUpUserExistsError', 319 ...data, 320 }); 321 322export const makeChangePasswordIncorrectPasswordError = ( 323 data: ErrorInstanceData, 324) => 325 new CoopError({ 326 status: 401, 327 type: [ErrorType.Unauthenticated], 328 title: 'Current password is incorrect.', 329 name: 'ChangePasswordIncorrectPasswordError', 330 ...data, 331 }); 332 333export const makeChangePasswordNotAllowedError = (data: ErrorInstanceData) => 334 new CoopError({ 335 status: 403, 336 type: [ErrorType.Unauthorized], 337 title: 'Password change is not allowed for this user.', 338 name: 'ChangePasswordNotAllowedError', 339 ...data, 340 });