Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

F24/sayi/invite-user-endpoint #50

Merged
merged 10 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions backend/typescript/middlewares/validators/authValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ export const loginRequestValidator = async (
return next();
};

export const loginWithSignInLinkRequestValidator = async (
req: Request,
res: Response,
next: NextFunction,
) => {
if (!validatePrimitive(req.body.accessToken, "string")) {
return res.status(400).send(getApiValidationError("accessToken", "string"));
}
if (!validatePrimitive(req.body.refreshToken, "string")) {
return res
.status(400)
.send(getApiValidationError("refreshToken", "string"));
}
if (!validatePrimitive(req.body.email, "string")) {
return res.status(400).send(getApiValidationError("email", "string"));
}

return next();
};

export const registerRequestValidator = async (
req: Request,
res: Response,
Expand All @@ -43,3 +63,15 @@ export const registerRequestValidator = async (

return next();
};

/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
export const inviteUserDtoValidator = async (
req: Request,
res: Response,
next: NextFunction,
) => {
if (!validatePrimitive(req.body.email, "string")) {
return res.status(400).send(getApiValidationError("email", "string"));
}
return next();
};
106 changes: 73 additions & 33 deletions backend/typescript/rest/authRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { CookieOptions, Router } from "express";

import { isAuthorizedByEmail, isAuthorizedByUserId } from "../middlewares/auth";
import {
isAuthorizedByEmail,
isAuthorizedByUserId,
isAuthorizedByRole,
} from "../middlewares/auth";
import {
loginRequestValidator,
registerRequestValidator,
loginWithSignInLinkRequestValidator,
inviteUserDtoValidator,
} from "../middlewares/validators/authValidators";
import nodemailerConfig from "../nodemailer.config";
import AuthService from "../services/implementations/authService";
Expand All @@ -12,8 +17,8 @@ import UserService from "../services/implementations/userService";
import IAuthService from "../services/interfaces/authService";
import IEmailService from "../services/interfaces/emailService";
import IUserService from "../services/interfaces/userService";
import { getErrorMessage } from "../utilities/errorUtils";
import { Role } from "../types";
import { getErrorMessage, NotFoundError } from "../utilities/errorUtils";
import { UserStatus, Role } from "../types";

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

/* Register a user, returns access token and user info in response body and sets refreshToken as an httpOnly cookie */
authRouter.post("/register", registerRequestValidator, async (req, res) => {
try {
await userService.createUser({
firstName: req.body.firstName,
lastName: req.body.lastName,
email: req.body.email,
role: req.body.role ?? Role.VOLUNTEER,
skillLevel: req.body.skillLevel ?? null,
canSeeAllLogs: req.body.canSeeAllLogs ?? null,
canAssignUsersToTasks: req.body.canAssignUsersToTasks ?? null,
phoneNumber: req.body.phoneNumber ?? null,
});

const authDTO = await authService.generateToken(
req.body.email,
req.body.password,
);
const { refreshToken, ...rest } = authDTO;

await authService.sendEmailVerificationLink(req.body.email);
/* Returns access token and user info in response body and sets refreshToken as an httpOnly cookie */
authRouter.post(
"/loginWithSignInLink",
loginWithSignInLinkRequestValidator,
async (req, res) => {
try {
if (isAuthorizedByEmail(req.body.email)) {
const userDTO = await userService.getUserByEmail(req.body.email);
const rest = { ...{ accessToken: req.body.accessToken }, ...userDTO };

res
.cookie("refreshToken", refreshToken, cookieOptions)
.status(200)
.json(rest);
} catch (error: unknown) {
res.status(500).json({ error: getErrorMessage(error) });
}
});
res
.cookie("refreshToken", req.body.refreshToken, cookieOptions)
.status(200)
.json(rest);
}
} catch (error: unknown) {
if (error instanceof NotFoundError) {
res.status(404).send(getErrorMessage(error));
} else {
res.status(500).json({ error: getErrorMessage(error) });
}
}
},
);

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

/* Invite a user */
authRouter.post("/invite-user", inviteUserDtoValidator, async (req, res) => {
sthuray marked this conversation as resolved.
Show resolved Hide resolved
try {
if (
!isAuthorizedByRole(
new Set([Role.ADMINISTRATOR, Role.ANIMAL_BEHAVIOURIST]),
)
) {
res
.status(401)
.json({ error: "User is not authorized to invite user. " });
return;
}

const user = await userService.getUserByEmail(req.body.email);
if (user.status === UserStatus.ACTIVE) {
res.status(400).json({ error: "User has already claimed account." });
return;
}

await authService.sendInviteEmail(req.body.email, String(user.role));
if (user.status === UserStatus.INVITED) {
res
.status(204)
.send("Success. Previous invitation has been invalidated.");
return;
}
const invitedUser = user;
invitedUser.status = UserStatus.INVITED;
await userService.updateUserById(user.id, invitedUser);

res.status(204).send();
} catch (error: unknown) {
if (error instanceof NotFoundError) {
res.status(404).send(getErrorMessage(error));
} else {
res.status(500).json({ error: getErrorMessage(error) });
}
}
});

export default authRouter;
2 changes: 1 addition & 1 deletion backend/typescript/rest/userRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ userRouter.post("/", createUserDtoValidator, async (req, res) => {
role: req.body.role,
skillLevel: req.body.skillLevel ?? null,
canSeeAllLogs: req.body.canSeeAllLogs ?? null,
canAssignUsersToTasks: req.body.canSeeAllUsers ?? null,
canAssignUsersToTasks: req.body.canAssignUsersToTasks ?? null,
phoneNumber: req.body.phoneNumber ?? null,
});

Expand Down
84 changes: 74 additions & 10 deletions backend/typescript/services/implementations/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,65 @@ class AuthService implements IAuthService {
}
}

async generateSignInLink(email: string): Promise<string> {
const actionCodeSettings = {
url: `http://localhost:3000/login/?email=${email}`,
handleCodeInApp: true,
};

try {
const signInLink = firebaseAdmin
.auth()
.generateSignInWithEmailLink(email, actionCodeSettings);
return await signInLink;
} catch (error) {
Logger.error(
`Failed to generate email sign-in link for user with email ${email}`,
);
throw error;
}
}

async sendInviteEmail(email: string, role: string): Promise<void> {
if (!this.emailService) {
const errorMessage =
"Attempted to call sendEmailVerificationLink but this instance of AuthService does not have an EmailService instance";
Logger.error(errorMessage);
throw new Error(errorMessage);
}

try {
let roleString =
role === "Administrator" || role === "Animal Behaviourist"
? "an "
: "a ";
roleString += role;

const signInLink = await this.generateSignInLink(email);
const emailBody = `
Hello,
<br><br>
You have been invited to the Oakville and Milton Humane Society as ${roleString}.
<br><br>
Please click the following link to verify your email and activate your account.
<strong>This link is only valid for 6 hours.</strong>
<br><br>
<a href=${signInLink}>Verify email</a>
<br><br>
To log in for the first time, use this email and the following link.</strong>`;
this.emailService.sendEmail(
email,
"Welcome to the Oakville and Milton Humane Society!",
emailBody,
);
} catch (error) {
Logger.error(
`Failed to send email invite link for user with email ${email}`,
);
throw error;
}
}

async resetPassword(email: string): Promise<void> {
if (!this.emailService) {
const errorMessage =
Expand Down Expand Up @@ -129,7 +188,7 @@ class AuthService implements IAuthService {
}
}

async sendEmailVerificationLink(email: string): Promise<void> {
/* async sendEmailVerificationLink(email: string): Promise<void> {
if (!this.emailService) {
const errorMessage =
"Attempted to call sendEmailVerificationLink but this instance of AuthService does not have an EmailService instance";
Expand All @@ -140,23 +199,27 @@ class AuthService implements IAuthService {
try {
const emailVerificationLink = await firebaseAdmin
.auth()
.generateEmailVerificationLink(email);
const emailBody = `
.generateEmailVerificationLink(email);
const emailBody = `
Hello,
<br><br>
You have been invited to the Oakville and Milton Humane Society as a <role>.
<br><br>
Please click the following link to verify your email and activate your account.
<strong>This link is only valid for 1 hour.</strong>
<br><br>
<a href=${emailVerificationLink}>Verify email</a>`;
<a href=${emailVerificationLink}>Verify email</a>
<br><br>
To log in for the first time, use this email and the following link.</strong>`;

this.emailService.sendEmail(email, "Verify your email", emailBody);
this.emailService.sendEmail(email, "Welcome to the Oakville and Milton Humane Society!", emailBody);
} catch (error) {
Logger.error(
`Failed to generate email verification link for user with email ${email}`,
);
throw error;
}
}
} */

async isAuthorizedByRole(
accessToken: string,
Expand Down Expand Up @@ -191,12 +254,13 @@ class AuthService implements IAuthService {
decodedIdToken.uid,
);

const firebaseUser = await firebaseAdmin
.auth()
.getUser(decodedIdToken.uid);
// const firebaseUser = await firebaseAdmin
// .auth()
// .getUser(decodedIdToken.uid);

return (
firebaseUser.emailVerified && String(tokenUserId) === requestedUserId
/* firebaseUser.emailVerified && */ String(tokenUserId) ===
requestedUserId
);
} catch (error) {
return false;
Expand Down
2 changes: 1 addition & 1 deletion backend/typescript/services/implementations/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ class UserService implements IUserService {
last_name: user.lastName,
auth_id: firebaseUser.uid,
role: user.role,
status: UserStatus.INVITED,
status: UserStatus.INACTIVE,
email: firebaseUser.email ?? "",
skill_level: user.skillLevel,
can_see_all_logs: user.canSeeAllLogs,
Expand Down
18 changes: 17 additions & 1 deletion backend/typescript/services/interfaces/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ interface IAuthService {
*/
renewToken(refreshToken: string): Promise<Token>;

/**
* Generate new sign-in link for provided email
* @param email signs in user with this email
* @returns sign-in link
* @throws Error if unable to generate link
*/
generateSignInLink(email: string): Promise<string>;

/**
* Sends invite email with newly generated sign-in link
* @param email sends invite to this email
* @param role role of user with respective email
* @throws Error if unable to generate link or send email
*/
sendInviteEmail(email: string, role: string): Promise<void>;

/**
* Generate a password reset link for the user with the given email and send
* the link to that email address
Expand All @@ -49,7 +65,7 @@ interface IAuthService {
* @param email email of user that needs to be verified
* @throws Error if unable to generate link or send email
*/
sendEmailVerificationLink(email: string): Promise<void>;
// sendEmailVerificationLink(email: string): Promise<void>;

/**
* Determine if the provided access token is valid and authorized for at least
Expand Down
28 changes: 0 additions & 28 deletions backend/typescript/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -561,15 +561,6 @@
camel-case "4.1.2"
tslib "~2.1.0"

"@graphql-tools/utils@^7.6.0":
version "7.10.0"
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.10.0.tgz#07a4cb5d1bec1ff1dc1d47a935919ee6abd38699"
integrity sha512-d334r6bo9mxdSqZW6zWboEnnOOFRrAPVQJ7LkU8/6grglrbcu6WhwCLzHb90E94JI3TD3ricC3YGbUqIi9Xg0w==
dependencies:
"@ardatan/aggregate-error" "0.0.6"
camel-case "4.1.2"
tslib "~2.2.0"

"@grpc/grpc-js@~1.2.0":
version "1.2.10"
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.2.10.tgz#f316d29a45fcc324e923d593cb849d292b1ed598"
Expand Down Expand Up @@ -3569,25 +3560,6 @@ graphql-middleware@^6.0.6:
"@graphql-tools/delegate" "^7.1.1"
"@graphql-tools/schema" "^7.1.3"

graphql-rate-limit@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/graphql-rate-limit/-/graphql-rate-limit-3.3.0.tgz#241e3f6dc4cc3cbd139d63d40e2ce613f8485646"
integrity sha512-mbbEv5z3SjkDLvVVdHi0XrVLavw2Mwo93GIqgQB/fx8dhcNSEv3eYI1OGdp8mhsm/MsZm7hjrRlwQMVRKBVxhA==
dependencies:
"@graphql-tools/utils" "^7.6.0"
graphql-shield "^7.5.0"
lodash.get "^4.4.2"
ms "^2.1.3"

graphql-shield@^7.5.0:
version "7.5.0"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.5.0.tgz#aa3af226946946dfadac33eccc6cbe7fec6e9000"
integrity sha512-T1A6OreOe/dHDk/1Qg3AHCrKLmTkDJ3fPFGYpSOmUbYXyDnjubK4J5ab5FjHdKHK5fWQRZNTvA0SrBObYsyfaw==
dependencies:
"@types/yup" "0.29.11"
object-hash "^2.0.3"
yup "^0.31.0"

graphql-subscriptions@^1.0.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-1.2.1.tgz#2142b2d729661ddf967b7388f7cf1dd4cf2e061d"
Expand Down
Loading
Loading