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",
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<boolean> {
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"
);
}
}

View file

@ -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<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@ -227,3 +240,4 @@ export type UserSite = InferSelectModel<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>;
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/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);

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 "./listUsers";
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">
{currentStep !== "org" ? (
<Link
href={`/${orgId}/sites`}
href={`/${orgId}/settings/sites`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Skip for now