create invite and accept invite endpoints

This commit is contained in:
Milo Schwartz 2024-11-02 18:12:17 -04:00
parent a83a3e88bb
commit a6bb8f5bb1
No known key found for this signature in database
7 changed files with 333 additions and 48 deletions

View file

@ -30,7 +30,7 @@ export enum ActionsEnum {
getRole = "getRole", getRole = "getRole",
listRoles = "listRoles", listRoles = "listRoles",
updateRole = "updateRole", updateRole = "updateRole",
addUser = "addUser", inviteUser = "inviteUser",
removeUser = "removeUser", removeUser = "removeUser",
listUsers = "listUsers", listUsers = "listUsers",
listSiteRoles = "listSiteRoles", listSiteRoles = "listSiteRoles",
@ -55,7 +55,7 @@ export enum ActionsEnum {
export async function checkUserActionPermission( export async function checkUserActionPermission(
actionId: string, actionId: string,
req: Request, req: Request
): Promise<boolean> { ): Promise<boolean> {
const userId = req.user?.userId; const userId = req.user?.userId;
@ -66,7 +66,7 @@ export async function checkUserActionPermission(
if (!req.userOrgId) { if (!req.userOrgId) {
throw createHttpError( throw createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"Organization ID is required", "Organization ID is required"
); );
} }
@ -81,15 +81,15 @@ export async function checkUserActionPermission(
.where( .where(
and( and(
eq(userOrgs.userId, userId), eq(userOrgs.userId, userId),
eq(userOrgs.orgId, req.userOrgId!), eq(userOrgs.orgId, req.userOrgId!)
), )
) )
.limit(1); .limit(1);
if (userOrgRole.length === 0) { if (userOrgRole.length === 0) {
throw createHttpError( throw createHttpError(
HttpCode.FORBIDDEN, 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( and(
eq(userActions.userId, userId), eq(userActions.userId, userId),
eq(userActions.actionId, actionId), 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); .limit(1);
@ -121,8 +121,8 @@ export async function checkUserActionPermission(
and( and(
eq(roleActions.actionId, actionId), eq(roleActions.actionId, actionId),
eq(roleActions.roleId, userOrgRoleId!), eq(roleActions.roleId, userOrgRoleId!),
eq(roleActions.orgId, req.userOrgId!), eq(roleActions.orgId, req.userOrgId!)
), )
) )
.limit(1); .limit(1);
@ -133,7 +133,7 @@ export async function checkUserActionPermission(
console.error("Error checking user action permission:", error); console.error("Error checking user action permission:", error);
throw createHttpError( throw createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
"Error checking action permission", "Error checking action permission"
); );
} }
} }

View file

@ -206,6 +206,19 @@ export const limitsTable = sqliteTable("limits", {
description: text("description"), 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<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
@ -227,3 +240,4 @@ export type UserSite = InferSelectModel<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>; export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>; export type UserResource = InferSelectModel<typeof userResources>;
export type Limit = InferSelectModel<typeof limitsTable>; export type Limit = InferSelectModel<typeof limitsTable>;
export type UserInvite = InferSelectModel<typeof userInvites>;

View file

@ -46,7 +46,11 @@ authenticated.put("/org/:orgId/site", verifyOrgAccess, site.createSite);
authenticated.get("/org/:orgId/sites", verifyOrgAccess, site.listSites); authenticated.get("/org/:orgId/sites", verifyOrgAccess, site.listSites);
authenticated.get("/org/:orgId/site/:niceId", verifyOrgAccess, site.getSite); 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", verifySiteAccess, site.getSite);
authenticated.get("/site/:siteId/roles", verifySiteAccess, site.listSiteRoles); authenticated.get("/site/:siteId/roles", verifySiteAccess, site.listSiteRoles);
authenticated.post("/site/:siteId", verifySiteAccess, site.updateSite); authenticated.post("/site/:siteId", verifySiteAccess, site.updateSite);
@ -55,138 +59,146 @@ authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite);
authenticated.put( authenticated.put(
"/org/:orgId/site/:siteId/resource", "/org/:orgId/site/:siteId/resource",
verifyOrgAccess, verifyOrgAccess,
resource.createResource, resource.createResource
); );
authenticated.get("/site/:siteId/resources", resource.listResources); authenticated.get("/site/:siteId/resources", resource.listResources);
authenticated.get( authenticated.get(
"/org/:orgId/resources", "/org/:orgId/resources",
verifyOrgAccess, verifyOrgAccess,
resource.listResources, resource.listResources
); );
authenticated.post(
"/org/:orgId/create-invite",
verifyOrgAccess,
user.inviteUser
);
authenticated.post("/org/:orgId/accept-invite", user.acceptInvite);
authenticated.get( authenticated.get(
"/resource/:resourceId/roles", "/resource/:resourceId/roles",
verifyResourceAccess, verifyResourceAccess,
resource.listResourceRoles, resource.listResourceRoles
); );
authenticated.get( authenticated.get(
"/resource/:resourceId", "/resource/:resourceId",
verifyResourceAccess, verifyResourceAccess,
resource.getResource, resource.getResource
); );
authenticated.post( authenticated.post(
"/resource/:resourceId", "/resource/:resourceId",
verifyResourceAccess, verifyResourceAccess,
resource.updateResource, resource.updateResource
); );
authenticated.delete( authenticated.delete(
"/resource/:resourceId", "/resource/:resourceId",
verifyResourceAccess, verifyResourceAccess,
resource.deleteResource, resource.deleteResource
); );
authenticated.put( authenticated.put(
"/resource/:resourceId/target", "/resource/:resourceId/target",
verifyResourceAccess, verifyResourceAccess,
target.createTarget, target.createTarget
); );
authenticated.get( authenticated.get(
"/resource/:resourceId/targets", "/resource/:resourceId/targets",
verifyResourceAccess, verifyResourceAccess,
target.listTargets, target.listTargets
); );
authenticated.get("/target/:targetId", verifyTargetAccess, target.getTarget); authenticated.get("/target/:targetId", verifyTargetAccess, target.getTarget);
authenticated.post( authenticated.post(
"/target/:targetId", "/target/:targetId",
verifyTargetAccess, verifyTargetAccess,
target.updateTarget, target.updateTarget
); );
authenticated.delete( authenticated.delete(
"/target/:targetId", "/target/:targetId",
verifyTargetAccess, verifyTargetAccess,
target.deleteTarget, target.deleteTarget
); );
authenticated.put( authenticated.put(
"/org/:orgId/role", "/org/:orgId/role",
verifyOrgAccess, verifyOrgAccess,
verifySuperuser, verifySuperuser,
role.createRole, role.createRole
); );
authenticated.get("/org/:orgId/roles", verifyOrgAccess, role.listRoles); authenticated.get("/org/:orgId/roles", verifyOrgAccess, role.listRoles);
authenticated.get( authenticated.get(
"/role/:roleId", "/role/:roleId",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
role.getRole, role.getRole
); );
authenticated.post( authenticated.post(
"/role/:roleId", "/role/:roleId",
verifyRoleAccess, verifyRoleAccess,
verifySuperuser, verifySuperuser,
role.updateRole, role.updateRole
); );
authenticated.delete( authenticated.delete(
"/role/:roleId", "/role/:roleId",
verifyRoleAccess, verifyRoleAccess,
verifySuperuser, verifySuperuser,
role.deleteRole, role.deleteRole
); );
authenticated.put( authenticated.put(
"/role/:roleId/site", "/role/:roleId/site",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
role.addRoleSite, role.addRoleSite
); );
authenticated.delete( authenticated.delete(
"/role/:roleId/site", "/role/:roleId/site",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
role.removeRoleSite, role.removeRoleSite
); );
authenticated.get( authenticated.get(
"/role/:roleId/sites", "/role/:roleId/sites",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
role.listRoleSites, role.listRoleSites
); );
authenticated.put( authenticated.put(
"/role/:roleId/resource", "/role/:roleId/resource",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
role.addRoleResource, role.addRoleResource
); );
authenticated.delete( authenticated.delete(
"/role/:roleId/resource", "/role/:roleId/resource",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
role.removeRoleResource, role.removeRoleResource
); );
authenticated.get( authenticated.get(
"/role/:roleId/resources", "/role/:roleId/resources",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
role.listRoleResources, role.listRoleResources
); );
authenticated.put( authenticated.put(
"/role/:roleId/action", "/role/:roleId/action",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
role.addRoleAction, role.addRoleAction
); );
authenticated.delete( authenticated.delete(
"/role/:roleId/action", "/role/:roleId/action",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
verifySuperuser, verifySuperuser,
role.removeRoleAction, role.removeRoleAction
); );
authenticated.get( authenticated.get(
"/role/:roleId/actions", "/role/:roleId/actions",
verifyRoleAccess, verifyRoleAccess,
verifyUserInRole, verifyUserInRole,
verifySuperuser, verifySuperuser,
role.listRoleActions, role.listRoleActions
); );
unauthenticated.get("/user", verifySessionMiddleware, user.getUser); unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
@ -196,52 +208,52 @@ authenticated.delete(
"/org/:orgId/user/:userId", "/org/:orgId/user/:userId",
verifyOrgAccess, verifyOrgAccess,
verifyUserAccess, verifyUserAccess,
user.removeUserOrg, user.removeUserOrg
); );
authenticated.put( authenticated.put(
"/org/:orgId/user/:userId", "/org/:orgId/user/:userId",
verifyOrgAccess, verifyOrgAccess,
verifyUserAccess, verifyUserAccess,
user.addUserOrg, user.addUserOrg
); );
authenticated.put( authenticated.put(
"/user/:userId/site", "/user/:userId/site",
verifySiteAccess, verifySiteAccess,
verifyUserAccess, verifyUserAccess,
role.addRoleSite, role.addRoleSite
); );
authenticated.delete( authenticated.delete(
"/user/:userId/site", "/user/:userId/site",
verifySiteAccess, verifySiteAccess,
verifyUserAccess, verifyUserAccess,
role.removeRoleSite, role.removeRoleSite
); );
authenticated.put( authenticated.put(
"/user/:userId/resource", "/user/:userId/resource",
verifyResourceAccess, verifyResourceAccess,
verifyUserAccess, verifyUserAccess,
role.addRoleResource, role.addRoleResource
); );
authenticated.delete( authenticated.delete(
"/user/:userId/resource", "/user/:userId/resource",
verifyResourceAccess, verifyResourceAccess,
verifyUserAccess, verifyUserAccess,
role.removeRoleResource, role.removeRoleResource
); );
authenticated.put( authenticated.put(
"/org/:orgId/user/:userId/action", "/org/:orgId/user/:userId/action",
verifyOrgAccess, verifyOrgAccess,
verifyUserAccess, verifyUserAccess,
verifySuperuser, verifySuperuser,
role.addRoleAction, role.addRoleAction
); );
authenticated.delete( authenticated.delete(
"/org/:orgId/user/:userId/action", "/org/:orgId/user/:userId/action",
verifyOrgAccess, verifyOrgAccess,
verifyUserAccess, verifyUserAccess,
verifySuperuser, verifySuperuser,
role.removeRoleAction, role.removeRoleAction
); );
// Auth routes // Auth routes
@ -252,7 +264,7 @@ authRouter.use(
windowMin: 10, windowMin: 10,
max: 15, max: 15,
type: "IP_AND_PATH", type: "IP_AND_PATH",
}), })
); );
authRouter.put("/signup", auth.signup); authRouter.put("/signup", auth.signup);
@ -262,19 +274,19 @@ authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
authRouter.post( authRouter.post(
"/2fa/request", "/2fa/request",
verifySessionUserMiddleware, verifySessionUserMiddleware,
auth.requestTotpSecret, auth.requestTotpSecret
); );
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
authRouter.post( authRouter.post(
"/verify-email/request", "/verify-email/request",
verifySessionMiddleware, verifySessionMiddleware,
auth.requestEmailVerificationCode, auth.requestEmailVerificationCode
); );
authRouter.post( authRouter.post(
"/change-password", "/change-password",
verifySessionUserMiddleware, verifySessionUserMiddleware,
auth.changePassword, auth.changePassword
); );
authRouter.post("/reset-password/request", auth.requestPasswordReset); authRouter.post("/reset-password/request", auth.requestPasswordReset);
authRouter.post("/reset-password/", auth.resetPassword); authRouter.post("/reset-password/", auth.resetPassword);

View file

@ -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<any> {
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<AcceptInviteResponse>(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")
);
}
}

View file

@ -3,3 +3,5 @@ export * from "./removeUserOrg";
export * from "./addUserOrg"; export * from "./addUserOrg";
export * from "./listUsers"; export * from "./listUsers";
export * from "./setUserRole"; export * from "./setUserRole";
export * from "./inviteUser";
export * from "./acceptInvite";

View file

@ -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<any> {
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<InviteUserResponse>(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")
);
}
}

View file

@ -226,7 +226,7 @@ export default function StepperForm() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{currentStep !== "org" ? ( {currentStep !== "org" ? (
<Link <Link
href={`/${orgId}/sites`} href={`/${orgId}/settings/sites`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
Skip for now Skip for now