Skip to content

Commit 9bbdc2e

Browse files
authored
Merge pull request #50 from uwblueprint/F24/sayi/invite-user-endpoint
F24/sayi/invite-user-endpoint
2 parents befb6ec + e4acd2c commit 9bbdc2e

File tree

12 files changed

+846
-78
lines changed

12 files changed

+846
-78
lines changed

backend/typescript/middlewares/validators/authValidators.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ export const loginRequestValidator = async (
2323
return next();
2424
};
2525

26+
export const loginWithSignInLinkRequestValidator = async (
27+
req: Request,
28+
res: Response,
29+
next: NextFunction,
30+
) => {
31+
if (!validatePrimitive(req.body.accessToken, "string")) {
32+
return res.status(400).send(getApiValidationError("accessToken", "string"));
33+
}
34+
if (!validatePrimitive(req.body.refreshToken, "string")) {
35+
return res
36+
.status(400)
37+
.send(getApiValidationError("refreshToken", "string"));
38+
}
39+
if (!validatePrimitive(req.body.email, "string")) {
40+
return res.status(400).send(getApiValidationError("email", "string"));
41+
}
42+
43+
return next();
44+
};
45+
2646
export const registerRequestValidator = async (
2747
req: Request,
2848
res: Response,
@@ -43,3 +63,15 @@ export const registerRequestValidator = async (
4363

4464
return next();
4565
};
66+
67+
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
68+
export const inviteUserDtoValidator = async (
69+
req: Request,
70+
res: Response,
71+
next: NextFunction,
72+
) => {
73+
if (!validatePrimitive(req.body.email, "string")) {
74+
return res.status(400).send(getApiValidationError("email", "string"));
75+
}
76+
return next();
77+
};

backend/typescript/rest/authRoutes.ts

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { CookieOptions, Router } from "express";
22

3-
import { isAuthorizedByEmail, isAuthorizedByUserId } from "../middlewares/auth";
3+
import {
4+
isAuthorizedByEmail,
5+
isAuthorizedByUserId,
6+
isAuthorizedByRole,
7+
} from "../middlewares/auth";
48
import {
59
loginRequestValidator,
6-
registerRequestValidator,
10+
loginWithSignInLinkRequestValidator,
11+
inviteUserDtoValidator,
712
} from "../middlewares/validators/authValidators";
813
import nodemailerConfig from "../nodemailer.config";
914
import AuthService from "../services/implementations/authService";
@@ -12,8 +17,8 @@ import UserService from "../services/implementations/userService";
1217
import IAuthService from "../services/interfaces/authService";
1318
import IEmailService from "../services/interfaces/emailService";
1419
import IUserService from "../services/interfaces/userService";
15-
import { getErrorMessage } from "../utilities/errorUtils";
16-
import { Role } from "../types";
20+
import { getErrorMessage, NotFoundError } from "../utilities/errorUtils";
21+
import { UserStatus, Role } from "../types";
1722

1823
const authRouter: Router = Router();
1924
const userService: IUserService = new UserService();
@@ -45,36 +50,30 @@ authRouter.post("/login", loginRequestValidator, async (req, res) => {
4550
}
4651
});
4752

48-
/* Register a user, returns access token and user info in response body and sets refreshToken as an httpOnly cookie */
49-
authRouter.post("/register", registerRequestValidator, async (req, res) => {
50-
try {
51-
await userService.createUser({
52-
firstName: req.body.firstName,
53-
lastName: req.body.lastName,
54-
email: req.body.email,
55-
role: req.body.role ?? Role.VOLUNTEER,
56-
skillLevel: req.body.skillLevel ?? null,
57-
canSeeAllLogs: req.body.canSeeAllLogs ?? null,
58-
canAssignUsersToTasks: req.body.canAssignUsersToTasks ?? null,
59-
phoneNumber: req.body.phoneNumber ?? null,
60-
});
61-
62-
const authDTO = await authService.generateToken(
63-
req.body.email,
64-
req.body.password,
65-
);
66-
const { refreshToken, ...rest } = authDTO;
67-
68-
await authService.sendEmailVerificationLink(req.body.email);
53+
/* Returns access token and user info in response body and sets refreshToken as an httpOnly cookie */
54+
authRouter.post(
55+
"/loginWithSignInLink",
56+
loginWithSignInLinkRequestValidator,
57+
async (req, res) => {
58+
try {
59+
if (isAuthorizedByEmail(req.body.email)) {
60+
const userDTO = await userService.getUserByEmail(req.body.email);
61+
const rest = { ...{ accessToken: req.body.accessToken }, ...userDTO };
6962

70-
res
71-
.cookie("refreshToken", refreshToken, cookieOptions)
72-
.status(200)
73-
.json(rest);
74-
} catch (error: unknown) {
75-
res.status(500).json({ error: getErrorMessage(error) });
76-
}
77-
});
63+
res
64+
.cookie("refreshToken", req.body.refreshToken, cookieOptions)
65+
.status(200)
66+
.json(rest);
67+
}
68+
} catch (error: unknown) {
69+
if (error instanceof NotFoundError) {
70+
res.status(404).send(getErrorMessage(error));
71+
} else {
72+
res.status(500).json({ error: getErrorMessage(error) });
73+
}
74+
}
75+
},
76+
);
7877

7978
/* Returns access token in response body and sets refreshToken as an httpOnly cookie */
8079
authRouter.post("/refresh", async (req, res) => {
@@ -118,4 +117,45 @@ authRouter.post(
118117
},
119118
);
120119

120+
/* Invite a user */
121+
authRouter.post("/invite-user", inviteUserDtoValidator, async (req, res) => {
122+
try {
123+
if (
124+
!isAuthorizedByRole(
125+
new Set([Role.ADMINISTRATOR, Role.ANIMAL_BEHAVIOURIST]),
126+
)
127+
) {
128+
res
129+
.status(401)
130+
.json({ error: "User is not authorized to invite user. " });
131+
return;
132+
}
133+
134+
const user = await userService.getUserByEmail(req.body.email);
135+
if (user.status === UserStatus.ACTIVE) {
136+
res.status(400).json({ error: "User has already claimed account." });
137+
return;
138+
}
139+
140+
await authService.sendInviteEmail(req.body.email, String(user.role));
141+
if (user.status === UserStatus.INVITED) {
142+
res
143+
.status(204)
144+
.send("Success. Previous invitation has been invalidated.");
145+
return;
146+
}
147+
const invitedUser = user;
148+
invitedUser.status = UserStatus.INVITED;
149+
await userService.updateUserById(user.id, invitedUser);
150+
151+
res.status(204).send();
152+
} catch (error: unknown) {
153+
if (error instanceof NotFoundError) {
154+
res.status(404).send(getErrorMessage(error));
155+
} else {
156+
res.status(500).json({ error: getErrorMessage(error) });
157+
}
158+
}
159+
});
160+
121161
export default authRouter;

backend/typescript/rest/userRoutes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ userRouter.post("/", createUserDtoValidator, async (req, res) => {
110110
role: req.body.role,
111111
skillLevel: req.body.skillLevel ?? null,
112112
canSeeAllLogs: req.body.canSeeAllLogs ?? null,
113-
canAssignUsersToTasks: req.body.canSeeAllUsers ?? null,
113+
canAssignUsersToTasks: req.body.canAssignUsersToTasks ?? null,
114114
phoneNumber: req.body.phoneNumber ?? null,
115115
});
116116

backend/typescript/services/implementations/authService.ts

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,65 @@ class AuthService implements IAuthService {
9999
}
100100
}
101101

102+
async generateSignInLink(email: string): Promise<string> {
103+
const actionCodeSettings = {
104+
url: `http://localhost:3000/login/?email=${email}`,
105+
handleCodeInApp: true,
106+
};
107+
108+
try {
109+
const signInLink = firebaseAdmin
110+
.auth()
111+
.generateSignInWithEmailLink(email, actionCodeSettings);
112+
return await signInLink;
113+
} catch (error) {
114+
Logger.error(
115+
`Failed to generate email sign-in link for user with email ${email}`,
116+
);
117+
throw error;
118+
}
119+
}
120+
121+
async sendInviteEmail(email: string, role: string): Promise<void> {
122+
if (!this.emailService) {
123+
const errorMessage =
124+
"Attempted to call sendEmailVerificationLink but this instance of AuthService does not have an EmailService instance";
125+
Logger.error(errorMessage);
126+
throw new Error(errorMessage);
127+
}
128+
129+
try {
130+
let roleString =
131+
role === "Administrator" || role === "Animal Behaviourist"
132+
? "an "
133+
: "a ";
134+
roleString += role;
135+
136+
const signInLink = await this.generateSignInLink(email);
137+
const emailBody = `
138+
Hello,
139+
<br><br>
140+
You have been invited to the Oakville and Milton Humane Society as ${roleString}.
141+
<br><br>
142+
Please click the following link to verify your email and activate your account.
143+
<strong>This link is only valid for 6 hours.</strong>
144+
<br><br>
145+
<a href=${signInLink}>Verify email</a>
146+
<br><br>
147+
To log in for the first time, use this email and the following link.</strong>`;
148+
this.emailService.sendEmail(
149+
email,
150+
"Welcome to the Oakville and Milton Humane Society!",
151+
emailBody,
152+
);
153+
} catch (error) {
154+
Logger.error(
155+
`Failed to send email invite link for user with email ${email}`,
156+
);
157+
throw error;
158+
}
159+
}
160+
102161
async resetPassword(email: string): Promise<void> {
103162
if (!this.emailService) {
104163
const errorMessage =
@@ -129,7 +188,7 @@ class AuthService implements IAuthService {
129188
}
130189
}
131190

132-
async sendEmailVerificationLink(email: string): Promise<void> {
191+
/* async sendEmailVerificationLink(email: string): Promise<void> {
133192
if (!this.emailService) {
134193
const errorMessage =
135194
"Attempted to call sendEmailVerificationLink but this instance of AuthService does not have an EmailService instance";
@@ -140,23 +199,27 @@ class AuthService implements IAuthService {
140199
try {
141200
const emailVerificationLink = await firebaseAdmin
142201
.auth()
143-
.generateEmailVerificationLink(email);
144-
const emailBody = `
202+
.generateEmailVerificationLink(email);
203+
const emailBody = `
145204
Hello,
146205
<br><br>
206+
You have been invited to the Oakville and Milton Humane Society as a <role>.
207+
<br><br>
147208
Please click the following link to verify your email and activate your account.
148209
<strong>This link is only valid for 1 hour.</strong>
149210
<br><br>
150-
<a href=${emailVerificationLink}>Verify email</a>`;
211+
<a href=${emailVerificationLink}>Verify email</a>
212+
<br><br>
213+
To log in for the first time, use this email and the following link.</strong>`;
151214
152-
this.emailService.sendEmail(email, "Verify your email", emailBody);
215+
this.emailService.sendEmail(email, "Welcome to the Oakville and Milton Humane Society!", emailBody);
153216
} catch (error) {
154217
Logger.error(
155218
`Failed to generate email verification link for user with email ${email}`,
156219
);
157220
throw error;
158221
}
159-
}
222+
} */
160223

161224
async isAuthorizedByRole(
162225
accessToken: string,
@@ -190,12 +253,13 @@ class AuthService implements IAuthService {
190253
decodedIdToken.uid,
191254
);
192255

193-
const firebaseUser = await firebaseAdmin
194-
.auth()
195-
.getUser(decodedIdToken.uid);
256+
// const firebaseUser = await firebaseAdmin
257+
// .auth()
258+
// .getUser(decodedIdToken.uid);
196259

197260
return (
198-
firebaseUser.emailVerified && String(tokenUserId) === requestedUserId
261+
/* firebaseUser.emailVerified && */ String(tokenUserId) ===
262+
requestedUserId
199263
);
200264
} catch (error) {
201265
return false;

backend/typescript/services/implementations/userService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ class UserService implements IUserService {
186186
last_name: user.lastName,
187187
auth_id: firebaseUser.uid,
188188
role: user.role,
189-
status: UserStatus.INVITED,
189+
status: UserStatus.INACTIVE,
190190
email: firebaseUser.email ?? "",
191191
skill_level: user.skillLevel,
192192
can_see_all_logs: user.canSeeAllLogs,

backend/typescript/services/interfaces/authService.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ interface IAuthService {
3535
*/
3636
renewToken(refreshToken: string): Promise<Token>;
3737

38+
/**
39+
* Generate new sign-in link for provided email
40+
* @param email signs in user with this email
41+
* @returns sign-in link
42+
* @throws Error if unable to generate link
43+
*/
44+
generateSignInLink(email: string): Promise<string>;
45+
46+
/**
47+
* Sends invite email with newly generated sign-in link
48+
* @param email sends invite to this email
49+
* @param role role of user with respective email
50+
* @throws Error if unable to generate link or send email
51+
*/
52+
sendInviteEmail(email: string, role: string): Promise<void>;
53+
3854
/**
3955
* Generate a password reset link for the user with the given email and send
4056
* the link to that email address
@@ -49,7 +65,7 @@ interface IAuthService {
4965
* @param email email of user that needs to be verified
5066
* @throws Error if unable to generate link or send email
5167
*/
52-
sendEmailVerificationLink(email: string): Promise<void>;
68+
// sendEmailVerificationLink(email: string): Promise<void>;
5369

5470
/**
5571
* Determine if the provided access token is valid and authorized for at least

backend/typescript/yarn.lock

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -561,15 +561,6 @@
561561
camel-case "4.1.2"
562562
tslib "~2.1.0"
563563

564-
"@graphql-tools/utils@^7.6.0":
565-
version "7.10.0"
566-
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.10.0.tgz#07a4cb5d1bec1ff1dc1d47a935919ee6abd38699"
567-
integrity sha512-d334r6bo9mxdSqZW6zWboEnnOOFRrAPVQJ7LkU8/6grglrbcu6WhwCLzHb90E94JI3TD3ricC3YGbUqIi9Xg0w==
568-
dependencies:
569-
"@ardatan/aggregate-error" "0.0.6"
570-
camel-case "4.1.2"
571-
tslib "~2.2.0"
572-
573564
"@grpc/grpc-js@~1.2.0":
574565
version "1.2.10"
575566
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.2.10.tgz#f316d29a45fcc324e923d593cb849d292b1ed598"
@@ -3569,25 +3560,6 @@ graphql-middleware@^6.0.6:
35693560
"@graphql-tools/delegate" "^7.1.1"
35703561
"@graphql-tools/schema" "^7.1.3"
35713562

3572-
graphql-rate-limit@^3.3.0:
3573-
version "3.3.0"
3574-
resolved "https://registry.yarnpkg.com/graphql-rate-limit/-/graphql-rate-limit-3.3.0.tgz#241e3f6dc4cc3cbd139d63d40e2ce613f8485646"
3575-
integrity sha512-mbbEv5z3SjkDLvVVdHi0XrVLavw2Mwo93GIqgQB/fx8dhcNSEv3eYI1OGdp8mhsm/MsZm7hjrRlwQMVRKBVxhA==
3576-
dependencies:
3577-
"@graphql-tools/utils" "^7.6.0"
3578-
graphql-shield "^7.5.0"
3579-
lodash.get "^4.4.2"
3580-
ms "^2.1.3"
3581-
3582-
graphql-shield@^7.5.0:
3583-
version "7.5.0"
3584-
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.5.0.tgz#aa3af226946946dfadac33eccc6cbe7fec6e9000"
3585-
integrity sha512-T1A6OreOe/dHDk/1Qg3AHCrKLmTkDJ3fPFGYpSOmUbYXyDnjubK4J5ab5FjHdKHK5fWQRZNTvA0SrBObYsyfaw==
3586-
dependencies:
3587-
"@types/yup" "0.29.11"
3588-
object-hash "^2.0.3"
3589-
yup "^0.31.0"
3590-
35913563
graphql-subscriptions@^1.0.0:
35923564
version "1.2.1"
35933565
resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-1.2.1.tgz#2142b2d729661ddf967b7388f7cf1dd4cf2e061d"

0 commit comments

Comments
 (0)