From a6bb8f5bb1119969492e8bff38ce6d04586ba899 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 2 Nov 2024 18:12:17 -0400 Subject: [PATCH] create invite and accept invite endpoints --- server/auth/actions.ts | 22 ++--- server/db/schema.ts | 14 +++ server/routers/external.ts | 84 ++++++++++-------- server/routers/user/acceptInvite.ts | 124 ++++++++++++++++++++++++++ server/routers/user/index.ts | 2 + server/routers/user/inviteUser.ts | 133 ++++++++++++++++++++++++++++ src/app/setup/page.tsx | 2 +- 7 files changed, 333 insertions(+), 48 deletions(-) create mode 100644 server/routers/user/acceptInvite.ts create mode 100644 server/routers/user/inviteUser.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 0f041653..2c17760a 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -30,7 +30,7 @@ export enum ActionsEnum { getRole = "getRole", listRoles = "listRoles", updateRole = "updateRole", - addUser = "addUser", + inviteUser = "inviteUser", removeUser = "removeUser", listUsers = "listUsers", listSiteRoles = "listSiteRoles", @@ -55,7 +55,7 @@ export enum ActionsEnum { export async function checkUserActionPermission( actionId: string, - req: Request, + req: Request ): Promise { const userId = req.user?.userId; @@ -66,7 +66,7 @@ export async function checkUserActionPermission( if (!req.userOrgId) { throw createHttpError( HttpCode.BAD_REQUEST, - "Organization ID is required", + "Organization ID is required" ); } @@ -81,15 +81,15 @@ export async function checkUserActionPermission( .where( and( eq(userOrgs.userId, userId), - eq(userOrgs.orgId, req.userOrgId!), - ), + eq(userOrgs.orgId, req.userOrgId!) + ) ) .limit(1); if (userOrgRole.length === 0) { throw createHttpError( HttpCode.FORBIDDEN, - "User does not have access to this organization", + "User does not have access to this organization" ); } @@ -104,8 +104,8 @@ export async function checkUserActionPermission( and( eq(userActions.userId, userId), eq(userActions.actionId, actionId), - eq(userActions.orgId, req.userOrgId!), // TODO: we cant pass the org id if we are not checking the org - ), + eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org + ) ) .limit(1); @@ -121,8 +121,8 @@ export async function checkUserActionPermission( and( eq(roleActions.actionId, actionId), eq(roleActions.roleId, userOrgRoleId!), - eq(roleActions.orgId, req.userOrgId!), - ), + eq(roleActions.orgId, req.userOrgId!) + ) ) .limit(1); @@ -133,7 +133,7 @@ export async function checkUserActionPermission( console.error("Error checking user action permission:", error); throw createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Error checking action permission", + "Error checking action permission" ); } } diff --git a/server/db/schema.ts b/server/db/schema.ts index 68426caa..ba52fff7 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -206,6 +206,19 @@ export const limitsTable = sqliteTable("limits", { description: text("description"), }); +export const userInvites = sqliteTable("userInvites", { + inviteId: text("inviteId").primaryKey(), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId), + email: text("email").notNull(), + expiresAt: integer("expiresAt").notNull(), + tokenHash: text("token").notNull(), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId), +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -227,3 +240,4 @@ export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type Limit = InferSelectModel; +export type UserInvite = InferSelectModel; diff --git a/server/routers/external.ts b/server/routers/external.ts index 5bb3305b..e123ddd5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -46,7 +46,11 @@ authenticated.put("/org/:orgId/site", verifyOrgAccess, site.createSite); authenticated.get("/org/:orgId/sites", verifyOrgAccess, site.listSites); authenticated.get("/org/:orgId/site/:niceId", verifyOrgAccess, site.getSite); -authenticated.get("/org/:orgId/pickSiteDefaults", verifyOrgAccess, site.pickSiteDefaults); +authenticated.get( + "/org/:orgId/pickSiteDefaults", + verifyOrgAccess, + site.pickSiteDefaults +); authenticated.get("/site/:siteId", verifySiteAccess, site.getSite); authenticated.get("/site/:siteId/roles", verifySiteAccess, site.listSiteRoles); authenticated.post("/site/:siteId", verifySiteAccess, site.updateSite); @@ -55,138 +59,146 @@ authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyOrgAccess, - resource.createResource, + resource.createResource ); authenticated.get("/site/:siteId/resources", resource.listResources); authenticated.get( "/org/:orgId/resources", verifyOrgAccess, - resource.listResources, + resource.listResources ); + +authenticated.post( + "/org/:orgId/create-invite", + verifyOrgAccess, + user.inviteUser +); +authenticated.post("/org/:orgId/accept-invite", user.acceptInvite); + authenticated.get( "/resource/:resourceId/roles", verifyResourceAccess, - resource.listResourceRoles, + resource.listResourceRoles ); authenticated.get( "/resource/:resourceId", verifyResourceAccess, - resource.getResource, + resource.getResource ); authenticated.post( "/resource/:resourceId", verifyResourceAccess, - resource.updateResource, + resource.updateResource ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, - resource.deleteResource, + resource.deleteResource ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, - target.createTarget, + target.createTarget ); authenticated.get( "/resource/:resourceId/targets", verifyResourceAccess, - target.listTargets, + target.listTargets ); authenticated.get("/target/:targetId", verifyTargetAccess, target.getTarget); authenticated.post( "/target/:targetId", verifyTargetAccess, - target.updateTarget, + target.updateTarget ); authenticated.delete( "/target/:targetId", verifyTargetAccess, - target.deleteTarget, + target.deleteTarget ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifySuperuser, - role.createRole, + role.createRole ); authenticated.get("/org/:orgId/roles", verifyOrgAccess, role.listRoles); authenticated.get( "/role/:roleId", verifyRoleAccess, verifyUserInRole, - role.getRole, + role.getRole ); authenticated.post( "/role/:roleId", verifyRoleAccess, verifySuperuser, - role.updateRole, + role.updateRole ); authenticated.delete( "/role/:roleId", verifyRoleAccess, verifySuperuser, - role.deleteRole, + role.deleteRole ); authenticated.put( "/role/:roleId/site", verifyRoleAccess, verifyUserInRole, - role.addRoleSite, + role.addRoleSite ); authenticated.delete( "/role/:roleId/site", verifyRoleAccess, verifyUserInRole, - role.removeRoleSite, + role.removeRoleSite ); authenticated.get( "/role/:roleId/sites", verifyRoleAccess, verifyUserInRole, - role.listRoleSites, + role.listRoleSites ); authenticated.put( "/role/:roleId/resource", verifyRoleAccess, verifyUserInRole, - role.addRoleResource, + role.addRoleResource ); authenticated.delete( "/role/:roleId/resource", verifyRoleAccess, verifyUserInRole, - role.removeRoleResource, + role.removeRoleResource ); authenticated.get( "/role/:roleId/resources", verifyRoleAccess, verifyUserInRole, - role.listRoleResources, + role.listRoleResources ); authenticated.put( "/role/:roleId/action", verifyRoleAccess, verifyUserInRole, - role.addRoleAction, + role.addRoleAction ); authenticated.delete( "/role/:roleId/action", verifyRoleAccess, verifyUserInRole, verifySuperuser, - role.removeRoleAction, + role.removeRoleAction ); authenticated.get( "/role/:roleId/actions", verifyRoleAccess, verifyUserInRole, verifySuperuser, - role.listRoleActions, + role.listRoleActions ); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); @@ -196,52 +208,52 @@ authenticated.delete( "/org/:orgId/user/:userId", verifyOrgAccess, verifyUserAccess, - user.removeUserOrg, + user.removeUserOrg ); authenticated.put( "/org/:orgId/user/:userId", verifyOrgAccess, verifyUserAccess, - user.addUserOrg, + user.addUserOrg ); authenticated.put( "/user/:userId/site", verifySiteAccess, verifyUserAccess, - role.addRoleSite, + role.addRoleSite ); authenticated.delete( "/user/:userId/site", verifySiteAccess, verifyUserAccess, - role.removeRoleSite, + role.removeRoleSite ); authenticated.put( "/user/:userId/resource", verifyResourceAccess, verifyUserAccess, - role.addRoleResource, + role.addRoleResource ); authenticated.delete( "/user/:userId/resource", verifyResourceAccess, verifyUserAccess, - role.removeRoleResource, + role.removeRoleResource ); authenticated.put( "/org/:orgId/user/:userId/action", verifyOrgAccess, verifyUserAccess, verifySuperuser, - role.addRoleAction, + role.addRoleAction ); authenticated.delete( "/org/:orgId/user/:userId/action", verifyOrgAccess, verifyUserAccess, verifySuperuser, - role.removeRoleAction, + role.removeRoleAction ); // Auth routes @@ -252,7 +264,7 @@ authRouter.use( windowMin: 10, max: 15, type: "IP_AND_PATH", - }), + }) ); authRouter.put("/signup", auth.signup); @@ -262,19 +274,19 @@ authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp); authRouter.post( "/2fa/request", verifySessionUserMiddleware, - auth.requestTotpSecret, + auth.requestTotpSecret ); authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); authRouter.post( "/verify-email/request", verifySessionMiddleware, - auth.requestEmailVerificationCode, + auth.requestEmailVerificationCode ); authRouter.post( "/change-password", verifySessionUserMiddleware, - auth.changePassword, + auth.changePassword ); authRouter.post("/reset-password/request", auth.requestPasswordReset); authRouter.post("/reset-password/", auth.resetPassword); diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts new file mode 100644 index 00000000..bccdf146 --- /dev/null +++ b/server/routers/user/acceptInvite.ts @@ -0,0 +1,124 @@ +import { verify } from "@node-rs/argon2"; +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { roles, userInvites, userOrgs, users } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/utils/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const acceptInviteBodySchema = z.object({ + token: z.string(), + inviteId: z.string(), +}); + +export type AcceptInviteResponse = {}; + +export async function acceptInvite( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = acceptInviteBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { token, inviteId } = parsedBody.data; + + const existingInvite = await db + .select() + .from(userInvites) + .where(eq(userInvites.inviteId, inviteId)) + .limit(1); + + if (!existingInvite.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invite ID or token is invalid" + ) + ); + } + + const validToken = await verify(existingInvite[0].tokenHash, token, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + if (!validToken) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invite ID or token is invalid" + ) + ); + } + + const existingUser = await db + .select() + .from(users) + .where(eq(users.email, existingInvite[0].email)) + .limit(1); + if (!existingUser.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not exist. Please create an account first." + ) + ); + } + + let roleId: number; + // get the role to make sure it exists + const existingRole = await db + .select() + .from(roles) + .where(eq(roles.roleId, existingInvite[0].roleId)) + .limit(1); + if (existingRole.length) { + roleId = existingRole[0].roleId; + } else { + // TODO: use the default role on the org instead of failing + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Role does not exist. Please contact an admin." + ) + ); + } + + // add the user to the org + await db.insert(userOrgs).values({ + userId: existingUser[0].userId, + orgId: existingInvite[0].orgId, + roleId: existingInvite[0].roleId, + }); + + // delete the invite + await db.delete(userInvites).where(eq(userInvites.inviteId, inviteId)); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Invite accepted", + status: HttpCode.OK, + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 148217cf..421c288a 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -3,3 +3,5 @@ export * from "./removeUserOrg"; export * from "./addUserOrg"; export * from "./listUsers"; export * from "./setUserRole"; +export * from "./inviteUser"; +export * from "./acceptInvite"; \ No newline at end of file diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts new file mode 100644 index 00000000..16db4d63 --- /dev/null +++ b/server/routers/user/inviteUser.ts @@ -0,0 +1,133 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userInvites, userOrgs, users } from "@server/db/schema"; +import { and, eq } from "drizzle-orm"; +import response from "@server/utils/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import logger from "@server/logger"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { createDate, TimeSpan } from "oslo"; +import config from "@server/config"; +import { hashPassword } from "@server/auth/password"; +import { fromError } from "zod-validation-error"; + +const inviteUserParamsSchema = z.object({ + orgId: z.string(), +}); + +const inviteUserBodySchema = z.object({ + email: z.string().email(), + roleId: z.number(), + validHours: z.number().gt(0).lte(168), +}); + +export type InviteUserResponse = { + inviteLink: string; + expiresAt: number; +}; + +export async function inviteUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = inviteUserParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = inviteUserBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { email, validHours, roleId } = parsedBody.data; + + const hasPermission = await checkUserActionPermission( + ActionsEnum.inviteUser, + req + ); + if (!hasPermission) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to perform this action" + ) + ); + } + + const existingUser = await db + .select() + .from(users) + .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) + .where(eq(users.email, email)) + .limit(1); + if (existingUser.length && existingUser[0].userOrgs?.orgId === orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is already a member of this organization" + ) + ); + } + + const inviteId = generateRandomString( + 10, + alphabet("a-z", "A-Z", "0-9") + ); + const token = generateRandomString(32, alphabet("a-z", "A-Z", "0-9")); + const expiresAt = createDate(new TimeSpan(validHours, "h")).getTime(); + + const tokenHash = await hashPassword(token); + + // delete any existing invites for this email + await db + .delete(userInvites) + .where( + and(eq(userInvites.email, email), eq(userInvites.orgId, orgId)) + ) + .execute(); + + await db.insert(userInvites).values({ + inviteId, + orgId, + email, + expiresAt, + tokenHash, + roleId, + }); + + const inviteLink = `${config.app.base_url}/invite/${inviteId}-${token}`; + + return response(res, { + data: { + inviteLink, + expiresAt, + }, + success: true, + error: false, + message: "User invited successfully", + status: HttpCode.OK, + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index d0bf9406..897501a1 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -226,7 +226,7 @@ export default function StepperForm() {
{currentStep !== "org" ? ( Skip for now