Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
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 });