ability to remove user from org

This commit is contained in:
Milo Schwartz 2024-11-03 17:28:12 -05:00
parent 2852d62258
commit fadfaf1f0b
No known key found for this signature in database
28 changed files with 718 additions and 264 deletions

View file

@ -7,7 +7,7 @@
// targets, // targets,
// } from "@server/db/schema"; // } from "@server/db/schema";
// import db from "@server/db"; // import db from "@server/db";
// import { createSuperuserRole } from "@server/db/ensureActions"; // import { createSuperUserRole } from "@server/db/ensureActions";
async function insertDummyData() { async function insertDummyData() {
// // Insert dummy orgs // // Insert dummy orgs
@ -21,7 +21,7 @@ async function insertDummyData() {
// .returning() // .returning()
// .get(); // .get();
// await createSuperuserRole(org1.orgId!); // await createSuperUserRole(org1.orgId!);
// const org2 = db // const org2 = db
// .insert(orgs) // .insert(orgs)
@ -33,7 +33,7 @@ async function insertDummyData() {
// .returning() // .returning()
// .get(); // .get();
// await createSuperuserRole(org2.orgId!); // await createSuperUserRole(org2.orgId!);
// // Insert dummy exit nodes // // Insert dummy exit nodes
// const exitNode1 = db // const exitNode1 = db

View file

@ -15,7 +15,7 @@ export async function ensureActions() {
const defaultRoles = await db const defaultRoles = await db
.select() .select()
.from(roles) .from(roles)
.where(eq(roles.isSuperuserRole, true)) .where(eq(roles.isSuperUserRole, true))
.execute(); .execute();
// Add new actions // Add new actions
@ -38,15 +38,15 @@ export async function ensureActions() {
} }
} }
export async function createSuperuserRole(orgId: string) { export async function createSuperUserRole(orgId: string) {
// Create the Default role if it doesn't exist // Create the Default role if it doesn't exist
const [insertedRole] = await db const [insertedRole] = await db
.insert(roles) .insert(roles)
.values({ .values({
orgId, orgId,
isSuperuserRole: true, isSuperUserRole: true,
name: 'Superuser', name: 'Super User',
description: 'Superuser role with all actions' description: 'Super User role with all actions'
}) })
.returning({ roleId: roles.roleId }) .returning({ roleId: roles.roleId })
.execute(); .execute();
@ -56,7 +56,7 @@ export async function createSuperuserRole(orgId: string) {
const actionIds = await db.select().from(actions).execute(); const actionIds = await db.select().from(actions).execute();
if (actionIds.length === 0) { if (actionIds.length === 0) {
logger.info('No actions to assign to the Superuser role'); logger.info('No actions to assign to the Super User role');
return; return;
} }

View file

@ -131,7 +131,7 @@ export const roles = sqliteTable("roles", {
orgId: text("orgId").references(() => orgs.orgId, { orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
isSuperuserRole: integer("isSuperuserRole", { mode: "boolean" }), isSuperUserRole: integer("isSuperUserRole", { mode: "boolean" }),
name: text("name").notNull(), name: text("name").notNull(),
description: text("description"), description: text("description"),
}); });

View file

@ -0,0 +1,75 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind,
Button,
} from "@react-email/components";
import * as React from "react";
interface SendInviteLinkProps {
email: string;
inviteLink: string;
orgName: string;
inviterName?: string;
expiresInDays: string;
}
export const SendInviteLink = ({
email,
inviteLink,
orgName,
inviterName,
expiresInDays,
}: SendInviteLinkProps) => {
const previewText = `${inviterName} invited to join ${orgName}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8">
<Heading className="text-2xl font-semibold text-gray-800 text-center">
You're invite to join a Fossorial organization
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {email || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
Youve been invited to join the organization{" "}
{orgName}
{inviterName ? ` by ${inviterName}.` : ""}. Please
access the link below to accept the invite.
</Text>
<Text className="text-base text-gray-700 mt-2">
This invite will expire in{" "}
<b>{expiresInDays} days.</b>
</Text>
<Section className="text-center my-6">
<Button
href={inviteLink}
className="rounded-md bg-gray-600 px-[12px] py-[12px] text-center font-semibold text-white cursor-pointer"
>
Accept invitation to {orgName}
</Button>
</Section>
<Text className="text-sm text-gray-500 mt-6">
Best regards,
<br />
Fossorial
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default SendInviteLink;

View file

@ -11,7 +11,7 @@ export * from "./verifyResourceAccess";
export * from "./verifyTargetAccess"; export * from "./verifyTargetAccess";
export * from "./verifyRoleAccess"; export * from "./verifyRoleAccess";
export * from "./verifyUserAccess"; export * from "./verifyUserAccess";
export * from "./verifySuperuser"; export * from "./verifySuperUser";
export * from "./verifyEmail"; export * from "./verifyEmail";
export * from "./requestEmailVerificationCode"; export * from "./requestEmailVerificationCode";
export * from "./changePassword"; export * from "./changePassword";

View file

@ -4,7 +4,7 @@ import db from "@server/db";
import { users, emailVerificationCodes } from "@server/db/schema"; import { users, emailVerificationCodes } from "@server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { sendEmail } from "@server/emails"; import { sendEmail } from "@server/emails";
import VerifyEmail from "@server/emails/templates/verifyEmailCode"; import VerifyEmail from "@server/emails/templates/VerifyEmailCode";
import config from "@server/config"; import config from "@server/config";
export async function sendEmailVerificationCode( export async function sendEmailVerificationCode(

View file

@ -1,21 +1,29 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from "express";
import { db } from '@server/db'; import { db } from "@server/db";
import { userOrgs } from '@server/db/schema'; import { userOrgs } from "@server/db/schema";
import { and, eq } from 'drizzle-orm'; import { and, eq } from "drizzle-orm";
import createHttpError from 'http-errors'; import createHttpError from "http-errors";
import HttpCode from '@server/types/HttpCode'; import HttpCode from "@server/types/HttpCode";
import { AuthenticatedRequest } from '@server/types/Auth'; import { AuthenticatedRequest } from "@server/types/Auth";
export function verifyOrgAccess(req: Request, res: Response, next: NextFunction) { export function verifyOrgAccess(
req: Request,
res: Response,
next: NextFunction
) {
const userId = req.user!.userId; // Assuming you have user information in the request const userId = req.user!.userId; // Assuming you have user information in the request
const orgId = req.params.orgId; const orgId = req.params.orgId;
if (!userId) { if (!userId) {
return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated')); return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
} }
if (!orgId) { if (!orgId) {
return next(createHttpError(HttpCode.BAD_REQUEST, 'Invalid organization ID')); return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
} }
db.select() db.select()
@ -23,7 +31,12 @@ export function verifyOrgAccess(req: Request, res: Response, next: NextFunction)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.then((result) => { .then((result) => {
if (result.length === 0) { if (result.length === 0) {
next(createHttpError(HttpCode.FORBIDDEN, 'User does not have access to this organization')); next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
} else { } else {
// User has access, attach the user's role to the request for potential future use // User has access, attach the user's role to the request for potential future use
req.userOrgRoleId = result[0].roleId; req.userOrgRoleId = result[0].roleId;
@ -32,6 +45,11 @@ export function verifyOrgAccess(req: Request, res: Response, next: NextFunction)
} }
}) })
.catch((error) => { .catch((error) => {
next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Error verifying organization access')); next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying organization access"
)
);
}); });
} }

View file

@ -6,7 +6,7 @@ import createHttpError from 'http-errors';
import HttpCode from '@server/types/HttpCode'; import HttpCode from '@server/types/HttpCode';
import logger from '@server/logger'; import logger from '@server/logger';
export async function verifySuperuser(req: Request, res: Response, next: NextFunction) { export async function verifySuperUser(req: Request, res: Response, next: NextFunction) {
const userId = req.user?.userId; // Assuming you have user information in the request const userId = req.user?.userId; // Assuming you have user information in the request
const orgId = req.userOrgId; const orgId = req.userOrgId;
@ -30,14 +30,14 @@ export async function verifySuperuser(req: Request, res: Response, next: NextFun
} }
// get userOrgRole[0].roleId // get userOrgRole[0].roleId
// Check if the user's role in the organization is a superuser role // Check if the user's role in the organization is a Super User role
const userRole = await db.select() const userRole = await db.select()
.from(roles) .from(roles)
.where(eq(roles.roleId, userOrgRole[0].roleId)) .where(eq(roles.roleId, userOrgRole[0].roleId))
.limit(1); .limit(1);
if (userRole.length === 0 || !userRole[0].isSuperuserRole) { if (userRole.length === 0 || !userRole[0].isSuperUserRole) {
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have superuser access')); return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have Super User access'));
} }
return next(); return next();

View file

@ -1,37 +1,62 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from "express";
import { db } from '@server/db'; import { db } from "@server/db";
import { sites, userOrgs, userSites, roleSites, roles } from '@server/db/schema'; import {
import { and, eq, or } from 'drizzle-orm'; sites,
import createHttpError from 'http-errors'; userOrgs,
import HttpCode from '@server/types/HttpCode'; userSites,
roleSites,
roles,
} from "@server/db/schema";
import { and, eq, or } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyUserAccess(req: Request, res: Response, next: NextFunction) { export async function verifyUserAccess(
req: Request,
res: Response,
next: NextFunction
) {
const userId = req.user!.userId; // Assuming you have user information in the request const userId = req.user!.userId; // Assuming you have user information in the request
const reqUserId = req.params.userId || req.body.userId || req.query.userId; const reqUserId = req.params.userId || req.body.userId || req.query.userId;
if (!userId) { if (!userId) {
return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated')); return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
} }
if (!reqUserId) { if (!reqUserId) {
return next(createHttpError(HttpCode.BAD_REQUEST, 'Invalid user ID')); return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID"));
} }
try { try {
const userOrg = await db
const userOrg = await db.select() .select()
.from(userOrgs) .from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, req.userOrgId!))) .where(
and(
eq(userOrgs.userId, reqUserId),
eq(userOrgs.orgId, req.userOrgId!)
)
)
.limit(1); .limit(1);
if (userOrg.length === 0) { if (userOrg.length === 0) {
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have access to this user')); return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this user"
)
);
} }
// If we reach here, the user doesn't have access to the site return next();
return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have access to this site'));
} catch (error) { } catch (error) {
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Error verifying site access')); return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error checking if user has access to this user"
)
);
} }
} }

View file

@ -19,7 +19,7 @@ import {
verifyResourceAccess, verifyResourceAccess,
verifyTargetAccess, verifyTargetAccess,
verifyRoleAccess, verifyRoleAccess,
verifySuperuser, verifySuperUser,
verifyUserInRole, verifyUserInRole,
verifyUserAccess, verifyUserAccess,
} from "./auth"; } from "./auth";
@ -40,7 +40,7 @@ authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
authenticated.get("/org/:orgId", verifyOrgAccess, org.getOrg); authenticated.get("/org/:orgId", verifyOrgAccess, org.getOrg);
authenticated.post("/org/:orgId", verifyOrgAccess, org.updateOrg); authenticated.post("/org/:orgId", verifyOrgAccess, org.updateOrg);
authenticated.delete("/org/:orgId", verifyOrgAccess, org.deleteOrg); // authenticated.delete("/org/:orgId", verifyOrgAccess, org.deleteOrg);
authenticated.put("/org/:orgId/site", verifyOrgAccess, site.createSite); authenticated.put("/org/:orgId/site", verifyOrgAccess, site.createSite);
authenticated.get("/org/:orgId/sites", verifyOrgAccess, site.listSites); authenticated.get("/org/:orgId/sites", verifyOrgAccess, site.listSites);
@ -52,7 +52,7 @@ authenticated.get(
site.pickSiteDefaults 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);
authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite); authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite);
@ -75,11 +75,11 @@ authenticated.post(
); // maybe make this /invite/create instead ); // maybe make this /invite/create instead
authenticated.post("/invite/accept", user.acceptInvite); authenticated.post("/invite/accept", 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,
@ -121,85 +121,85 @@ authenticated.delete(
// 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);
@ -211,44 +211,44 @@ authenticated.delete(
user.removeUserOrg user.removeUserOrg
); );
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
export const authRouter = Router(); export const authRouter = Router();

View file

@ -8,7 +8,7 @@ import HttpCode from '@server/types/HttpCode';
import createHttpError from 'http-errors'; import createHttpError from 'http-errors';
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
import logger from '@server/logger'; import logger from '@server/logger';
import { createSuperuserRole } from '@server/db/ensureActions'; import { createSuperUserRole } from '@server/db/ensureActions';
import config, { APP_PATH } from "@server/config"; import config, { APP_PATH } from "@server/config";
import { fromError } from 'zod-validation-error'; import { fromError } from 'zod-validation-error';
@ -75,13 +75,13 @@ export async function createOrg(req: Request, res: Response, next: NextFunction)
domain domain
}).returning(); }).returning();
const roleId = await createSuperuserRole(newOrg[0].orgId); const roleId = await createSuperUserRole(newOrg[0].orgId);
if (!roleId) { if (!roleId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Error creating superuser role` `Error creating Super User role`
) )
); );
} }

View file

@ -87,27 +87,27 @@ export async function createResource(req: Request, res: Response, next: NextFunc
subdomain, subdomain,
}).returning(); }).returning();
// find the superuser roleId and also add the resource to the superuser role // find the Super User roleId and also add the resource to the Super User role
const superuserRole = await db.select() const superUserRole = await db.select()
.from(roles) .from(roles)
.where(and(eq(roles.isSuperuserRole, true), eq(roles.orgId, orgId))) .where(and(eq(roles.isSuperUserRole, true), eq(roles.orgId, orgId)))
.limit(1); .limit(1);
if (superuserRole.length === 0) { if (superUserRole.length === 0) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Superuser role not found` `Super User role not found`
) )
); );
} }
await db.insert(roleResources).values({ await db.insert(roleResources).values({
roleId: superuserRole[0].roleId, roleId: superUserRole[0].roleId,
resourceId: newResource[0].resourceId, resourceId: newResource[0].resourceId,
}); });
if (req.userOrgRoleId != superuserRole[0].roleId) { if (req.userOrgRoleId != superUserRole[0].roleId) {
// make sure the user can access the resource // make sure the user can access the resource
await db.insert(userResources).values({ await db.insert(userResources).values({
userId: req.user?.userId!, userId: req.user?.userId!,

View file

@ -51,7 +51,7 @@ export async function listResourceRoles(
roleId: roles.roleId, roleId: roles.roleId,
name: roles.name, name: roles.name,
description: roles.description, description: roles.description,
isSuperuserRole: roles.isSuperuserRole, isSuperUserRole: roles.isSuperUserRole,
}) })
.from(roleResources) .from(roleResources)
.innerJoin(roles, eq(roleResources.roleId, roles.roleId)) .innerJoin(roles, eq(roleResources.roleId, roles.roleId))

View file

@ -61,11 +61,11 @@ export async function deleteRole(
); );
} }
if (role[0].isSuperuserRole) { if (role[0].isSuperUserRole) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
`Cannot delete a superuser role` `Cannot delete a Super User role`
) )
); );
} }

View file

@ -80,7 +80,7 @@ export async function listRoles(
.select({ .select({
roleId: roles.roleId, roleId: roles.roleId,
orgId: roles.orgId, orgId: roles.orgId,
isSuperuserRole: roles.isSuperuserRole, isSuperUserRole: roles.isSuperUserRole,
name: roles.name, name: roles.name,
description: roles.description, description: roles.description,
orgName: orgs.name, orgName: orgs.name,

View file

@ -81,11 +81,11 @@ export async function updateRole(
); );
} }
if (role[0].isSuperuserRole) { if (role[0].isSuperUserRole) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
`Cannot update a superuser role` `Cannot update a Super User role`
) )
); );
} }

View file

@ -107,25 +107,25 @@ export async function createSite(
subnet, subnet,
}) })
.returning(); .returning();
// find the superuser roleId and also add the resource to the superuser role // find the Super User roleId and also add the resource to the Super User role
const superuserRole = await db const superUserRole = await db
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.isSuperuserRole, true), eq(roles.orgId, orgId))) .where(and(eq(roles.isSuperUserRole, true), eq(roles.orgId, orgId)))
.limit(1); .limit(1);
if (superuserRole.length === 0) { if (superUserRole.length === 0) {
return next( return next(
createHttpError(HttpCode.NOT_FOUND, `Superuser role not found`) createHttpError(HttpCode.NOT_FOUND, `Super User role not found`)
); );
} }
await db.insert(roleSites).values({ await db.insert(roleSites).values({
roleId: superuserRole[0].roleId, roleId: superUserRole[0].roleId,
siteId: newSite.siteId, siteId: newSite.siteId,
}); });
if (req.userOrgRoleId != superuserRole[0].roleId) { if (req.userOrgRoleId != superUserRole[0].roleId) {
// make sure the user can access the site // make sure the user can access the site
db.insert(userSites).values({ db.insert(userSites).values({
userId: req.user?.userId!, userId: req.user?.userId!,

View file

@ -51,7 +51,7 @@ export async function listSiteRoles(
roleId: roles.roleId, roleId: roles.roleId,
name: roles.name, name: roles.name,
description: roles.description, description: roles.description,
isSuperuserRole: roles.isSuperuserRole, isSuperUserRole: roles.isSuperUserRole,
}) })
.from(roleSites) .from(roleSites)
.innerJoin(roles, eq(roleSites.roleId, roles.roleId)) .innerJoin(roles, eq(roleSites.roleId, roles.roleId))

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { userInvites, userOrgs, users } from "@server/db/schema"; import { orgs, userInvites, userOrgs, users } from "@server/db/schema";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -13,6 +13,8 @@ import { createDate, TimeSpan } from "oslo";
import config from "@server/config"; import config from "@server/config";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { sendEmail } from "@server/emails";
import SendInviteLink from "@server/emails/templates/SendInviteLink";
const inviteUserParamsSchema = z.object({ const inviteUserParamsSchema = z.object({
orgId: z.string(), orgId: z.string(),
@ -31,6 +33,8 @@ export type InviteUserResponse = {
expiresAt: number; expiresAt: number;
}; };
const inviteTracker: Record<string, { timestamps: number[] }> = {};
export async function inviteUser( export async function inviteUser(
req: Request, req: Request,
res: Response, res: Response,
@ -73,6 +77,39 @@ export async function inviteUser(
); );
} }
const currentTime = Date.now();
const oneHourAgo = currentTime - 3600000;
if (!inviteTracker[email]) {
inviteTracker[email] = { timestamps: [] };
}
inviteTracker[email].timestamps = inviteTracker[
email
].timestamps.filter((timestamp) => timestamp > oneHourAgo);
if (inviteTracker[email].timestamps.length >= 3) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"User has already been invited 3 times in the last hour"
)
);
}
inviteTracker[email].timestamps.push(currentTime);
const org = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org.length) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
);
}
const existingUser = await db const existingUser = await db
.select() .select()
.from(users) .from(users)
@ -116,6 +153,21 @@ export async function inviteUser(
const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`; const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`;
await sendEmail(
SendInviteLink({
email,
inviteLink,
expiresInDays: (validHours / 24).toString(),
orgName: orgId,
inviterName: req.user?.email,
}),
{
to: email,
from: config.email?.no_reply,
subject: "You're invited to join a Fossorial organization",
}
);
return response<InviteUserResponse>(res, { return response<InviteUserResponse>(res, {
data: { data: {
inviteLink, inviteLink,

View file

@ -11,7 +11,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
const removeUserSchema = z.object({ const removeUserSchema = z.object({
userId: z.string().uuid(), userId: z.string(),
orgId: z.string(), orgId: z.string(),
}); });
@ -33,7 +33,6 @@ export async function removeUserOrg(
const { userId, orgId } = parsedParams.data; const { userId, orgId } = parsedParams.data;
// Check if the user has permission to list sites
const hasPermission = await checkUserActionPermission( const hasPermission = await checkUserActionPermission(
ActionsEnum.removeUser, ActionsEnum.removeUser,
req req
@ -56,7 +55,7 @@ export async function removeUserOrg(
data: null, data: null,
success: true, success: true,
error: false, error: false,
message: "User deleted successfully", message: "User remove from org successfully",
status: HttpCode.OK, status: HttpCode.OK,
}); });
} catch (error) { } catch (error) {

View file

@ -79,7 +79,7 @@ export const columns: ColumnDef<SiteRow>[] = [
.then(() => { .then(() => {
router.refresh(); router.refresh();
}); });
} };
return ( return (
<DropdownMenu> <DropdownMenu>
@ -98,7 +98,12 @@ export const columns: ColumnDef<SiteRow>[] = [
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<button onClick={() => deleteSite(siteRow.id)} className="text-red-600 hover:text-red-800 hover:underline cursor-pointer">Delete</button> <button
onClick={() => deleteSite(siteRow.id)}
className="text-red-600 hover:text-red-800"
>
Delete
</button>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View file

@ -122,7 +122,13 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
return ( return (
<> <>
<Credenza open={open} onOpenChange={setOpen}> <Credenza open={open} onOpenChange={(val) => {
setOpen(val);
setInviteLink(null);
setLoading(false);
setExpiresInDays(1);
form.reset();
}}>
<CredenzaContent> <CredenzaContent>
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle>Invite User</CredenzaTitle> <CredenzaTitle>Invite User</CredenzaTitle>
@ -257,7 +263,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
type="submit" type="submit"
form="invite-user-form" form="invite-user-form"
loading={loading} loading={loading}
disabled={inviteLink !== null} disabled={inviteLink !== null || loading}
> >
Create Invitation Create Invitation
</Button> </Button>

View file

@ -12,13 +12,34 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "./UsersDataTable"; import { UsersDataTable } from "./UsersDataTable";
import { useState } from "react"; import { useState } from "react";
import InviteUserForm from "./InviteUserForm"; import InviteUserForm from "./InviteUserForm";
import { Badge } from "@app/components/ui/badge";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useUserContext } from "@app/hooks/useUserContext";
import api from "@app/api";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useToast } from "@app/hooks/useToast";
export type UserRow = { export type UserRow = {
id: string; id: string;
email: string; email: string;
status: string;
role: string;
}; };
export const columns: ColumnDef<UserRow>[] = [ type UsersTableProps = {
users: UserRow[];
};
export default function UsersTable({ users }: UsersTableProps) {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [userToRemove, setUserToRemove] = useState<UserRow | null>(null);
const user = useUserContext();
const { org } = useOrgContext();
const { toast } = useToast();
const columns: ColumnDef<UserRow>[] = [
{ {
accessorKey: "email", accessorKey: "email",
header: ({ column }) => { header: ({ column }) => {
@ -35,12 +56,45 @@ export const columns: ColumnDef<UserRow>[] = [
); );
}, },
}, },
{
accessorKey: "status",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Status
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "role",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Role
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {
const userRow = row.original; const userRow = row.original;
return ( return (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button variant="ghost" className="h-8 w-8 p-0">
@ -49,23 +103,87 @@ export const columns: ColumnDef<UserRow>[] = [
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem>Edit access</DropdownMenuItem> <DropdownMenuItem>Manage user</DropdownMenuItem>
{userRow.email !== user?.email && (
<DropdownMenuItem>
<button
className="text-red-600 hover:text-red-800"
onClick={() => {
setIsDeleteModalOpen(true);
setUserToRemove(userRow);
}}
>
Remove User
</button>
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</>
); );
}, },
}, },
]; ];
type UsersTableProps = { async function removeUser() {
users: UserRow[]; if (userToRemove) {
}; const res = await api
.delete(`/org/${org!.org.orgId}/user/${userToRemove.id}`)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to remove user",
description:
e.message ??
"An error occurred while removing the user.",
});
});
export default function UsersTable({ users }: UsersTableProps) { if (res && res.status === 200) {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); toast({
variant: "default",
title: "User removed",
description: `The user ${userToRemove.email} has been removed from the organization.`,
});
}
}
setIsDeleteModalOpen(false);
}
return ( return (
<> <>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setUserToRemove(null);
}}
dialog={
<div>
<p className="mb-2">
Are you sure you want to remove{" "}
<b>{userToRemove?.email}</b> from the organization?
</p>
<p className="mb-2">
Once removed, this user will no longer have access
to the organization. You can always re-invite them
later, but they will need to accept the invitation
again.
</p>
<p>
To confirm, please type the email address of the
user below.
</p>
</div>
}
buttonText="Confirm remove user"
onConfirm={removeUser}
string={userToRemove?.email ?? ""}
title="Remove user from organization"
/>
<InviteUserForm <InviteUserForm
open={isInviteModalOpen} open={isInviteModalOpen}
setOpen={setIsInviteModalOpen} setOpen={setIsInviteModalOpen}

View file

@ -6,6 +6,8 @@ import UsersTable, { UserRow } from "./components/UsersTable";
import { GetOrgResponse } from "@server/routers/org"; import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react"; import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
type UsersPageProps = { type UsersPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -14,6 +16,9 @@ type UsersPageProps = {
export default async function UsersPage(props: UsersPageProps) { export default async function UsersPage(props: UsersPageProps) {
const params = await props.params; const params = await props.params;
const getUser = cache(verifySession);
const user = await getUser();
let users: ListUsersResponse["users"] = []; let users: ListUsersResponse["users"] = [];
const res = await internal const res = await internal
.get<AxiosResponse<ListUsersResponse>>( .get<AxiosResponse<ListUsersResponse>>(
@ -49,6 +54,8 @@ export default async function UsersPage(props: UsersPageProps) {
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
status: "Confirmed",
role: user.roleName || "",
}; };
}); });
@ -64,9 +71,11 @@ export default async function UsersPage(props: UsersPageProps) {
</p> </p>
</div> </div>
<UserProvider user={user!}>
<OrgProvider org={org}> <OrgProvider org={org}>
<UsersTable users={userRows} /> <UsersTable users={userRows} />
</OrgProvider> </OrgProvider>
</UserProvider>
</> </>
); );
} }

View file

@ -1,7 +1,7 @@
import { internal } from "@app/api"; import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { LandingProvider } from "@app/providers/LandingProvider"; import UserProvider from "@app/providers/UserProvider";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
@ -42,9 +42,9 @@ export default async function Page(props: {
return ( return (
<> <>
<LandingProvider user={user}> <UserProvider user={user}>
<p>Logged in as {user.email}</p> <p>Logged in as {user.email}</p>
</LandingProvider> </UserProvider>
<div className="mt-4"> <div className="mt-4">
{orgs.map((org) => ( {orgs.map((org) => (

View file

@ -0,0 +1,144 @@
"use client";
import api from "@app/api";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import {
InviteUserBody,
InviteUserResponse,
ListUsersResponse,
} from "@server/routers/user";
import { AxiosResponse } from "axios";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { Description } from "@radix-ui/react-toast";
type InviteUserFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
string: string;
title: string;
dialog: React.ReactNode;
buttonText: string;
onConfirm: () => Promise<void>;
};
export default function InviteUserForm({
open,
setOpen,
string,
title,
onConfirm,
buttonText,
dialog,
}: InviteUserFormProps) {
const [loading, setLoading] = useState(false);
const formSchema = z.object({
string: z.string().refine((val) => val === string, {
message: "Invalid confirmation",
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
string: "",
},
});
function reset() {
form.reset();
setLoading(false);
}
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
await onConfirm();
reset();
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{title}</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<div className="mb-4">{dialog}</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="confirm-delete-form"
>
<FormField
control={form.control}
name="string"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
type="submit"
form="confirm-delete-form"
loading={loading}
disabled={loading}
>
{buttonText}
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View file

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
@ -15,13 +15,16 @@ const badgeVariants = cva(
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground", outline: "text-foreground",
green: "border-transparent bg-green-300",
yellow: "border-transparent bg-yellow-300",
red: "border-transparent bg-red-300",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} }
) );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +33,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div className={cn(badgeVariants({ variant }), className)} {...props} />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View file

@ -4,13 +4,13 @@ import { UserContext } from "@app/contexts/userContext";
import { GetUserResponse } from "@server/routers/user"; import { GetUserResponse } from "@server/routers/user";
import { ReactNode } from "react"; import { ReactNode } from "react";
type LandingProviderProps = { type UserProviderProps = {
user: GetUserResponse; user: GetUserResponse;
children: ReactNode; children: ReactNode;
}; };
export function LandingProvider({ user, children }: LandingProviderProps) { export function UserProvider({ user, children }: UserProviderProps) {
return <UserContext.Provider value={user}>{children}</UserContext.Provider>; return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
} }
export default LandingProvider; export default UserProvider;