diff --git a/scripts/hydrate.ts b/scripts/hydrate.ts index d1bdc8dd..456b52f1 100644 --- a/scripts/hydrate.ts +++ b/scripts/hydrate.ts @@ -7,7 +7,7 @@ // targets, // } from "@server/db/schema"; // import db from "@server/db"; -// import { createSuperuserRole } from "@server/db/ensureActions"; +// import { createSuperUserRole } from "@server/db/ensureActions"; async function insertDummyData() { // // Insert dummy orgs @@ -21,7 +21,7 @@ async function insertDummyData() { // .returning() // .get(); - // await createSuperuserRole(org1.orgId!); + // await createSuperUserRole(org1.orgId!); // const org2 = db // .insert(orgs) @@ -33,7 +33,7 @@ async function insertDummyData() { // .returning() // .get(); - // await createSuperuserRole(org2.orgId!); + // await createSuperUserRole(org2.orgId!); // // Insert dummy exit nodes // const exitNode1 = db diff --git a/server/db/ensureActions.ts b/server/db/ensureActions.ts index 81ea5c68..7d96ee55 100644 --- a/server/db/ensureActions.ts +++ b/server/db/ensureActions.ts @@ -15,7 +15,7 @@ export async function ensureActions() { const defaultRoles = await db .select() .from(roles) - .where(eq(roles.isSuperuserRole, true)) + .where(eq(roles.isSuperUserRole, true)) .execute(); // 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 const [insertedRole] = await db .insert(roles) .values({ orgId, - isSuperuserRole: true, - name: 'Superuser', - description: 'Superuser role with all actions' + isSuperUserRole: true, + name: 'Super User', + description: 'Super User role with all actions' }) .returning({ roleId: roles.roleId }) .execute(); @@ -56,7 +56,7 @@ export async function createSuperuserRole(orgId: string) { const actionIds = await db.select().from(actions).execute(); 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; } diff --git a/server/db/schema.ts b/server/db/schema.ts index 4a24e488..493fa955 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -131,7 +131,7 @@ export const roles = sqliteTable("roles", { orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade", }), - isSuperuserRole: integer("isSuperuserRole", { mode: "boolean" }), + isSuperUserRole: integer("isSuperUserRole", { mode: "boolean" }), name: text("name").notNull(), description: text("description"), }); diff --git a/server/emails/templates/SendInviteLink.tsx b/server/emails/templates/SendInviteLink.tsx new file mode 100644 index 00000000..64f19bba --- /dev/null +++ b/server/emails/templates/SendInviteLink.tsx @@ -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 ( + + + {previewText} + + + + + You're invite to join a Fossorial organization + + + Hi {email || "there"}, + + + You’ve been invited to join the organization{" "} + {orgName} + {inviterName ? ` by ${inviterName}.` : ""}. Please + access the link below to accept the invite. + + + This invite will expire in{" "} + {expiresInDays} days. + +
+ +
+ + Best regards, +
+ Fossorial +
+
+ +
+ + ); +}; + +export default SendInviteLink; diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 7d7b36eb..c7a95cfb 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -11,7 +11,7 @@ export * from "./verifyResourceAccess"; export * from "./verifyTargetAccess"; export * from "./verifyRoleAccess"; export * from "./verifyUserAccess"; -export * from "./verifySuperuser"; +export * from "./verifySuperUser"; export * from "./verifyEmail"; export * from "./requestEmailVerificationCode"; export * from "./changePassword"; diff --git a/server/routers/auth/sendEmailVerificationCode.ts b/server/routers/auth/sendEmailVerificationCode.ts index 6a9ecd1c..a8e2f087 100644 --- a/server/routers/auth/sendEmailVerificationCode.ts +++ b/server/routers/auth/sendEmailVerificationCode.ts @@ -4,7 +4,7 @@ import db from "@server/db"; import { users, emailVerificationCodes } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { sendEmail } from "@server/emails"; -import VerifyEmail from "@server/emails/templates/verifyEmailCode"; +import VerifyEmail from "@server/emails/templates/VerifyEmailCode"; import config from "@server/config"; export async function sendEmailVerificationCode( diff --git a/server/routers/auth/verifyOrgAccess.ts b/server/routers/auth/verifyOrgAccess.ts index e67dc623..1d04e4ca 100644 --- a/server/routers/auth/verifyOrgAccess.ts +++ b/server/routers/auth/verifyOrgAccess.ts @@ -1,21 +1,29 @@ -import { Request, Response, NextFunction } from 'express'; -import { db } from '@server/db'; -import { userOrgs } from '@server/db/schema'; -import { and, eq } from 'drizzle-orm'; -import createHttpError from 'http-errors'; -import HttpCode from '@server/types/HttpCode'; -import { AuthenticatedRequest } from '@server/types/Auth'; +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs } from "@server/db/schema"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +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 orgId = req.params.orgId; if (!userId) { - return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated')); + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); } if (!orgId) { - return next(createHttpError(HttpCode.BAD_REQUEST, 'Invalid organization ID')); + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); } 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))) .then((result) => { 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 { // User has access, attach the user's role to the request for potential future use req.userOrgRoleId = result[0].roleId; @@ -32,6 +45,11 @@ export function verifyOrgAccess(req: Request, res: Response, next: NextFunction) } }) .catch((error) => { - next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Error verifying organization access')); + next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying organization access" + ) + ); }); } diff --git a/server/routers/auth/verifySuperuser.ts b/server/routers/auth/verifySuperuser.ts index 46b26c1d..5455d4a4 100644 --- a/server/routers/auth/verifySuperuser.ts +++ b/server/routers/auth/verifySuperuser.ts @@ -6,7 +6,7 @@ import createHttpError from 'http-errors'; import HttpCode from '@server/types/HttpCode'; 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 orgId = req.userOrgId; @@ -30,14 +30,14 @@ export async function verifySuperuser(req: Request, res: Response, next: NextFun } // 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() .from(roles) .where(eq(roles.roleId, userOrgRole[0].roleId)) .limit(1); - if (userRole.length === 0 || !userRole[0].isSuperuserRole) { - return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have superuser access')); + if (userRole.length === 0 || !userRole[0].isSuperUserRole) { + return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have Super User access')); } return next(); diff --git a/server/routers/auth/verifyUserAccess.ts b/server/routers/auth/verifyUserAccess.ts index 53606c48..c9d40793 100644 --- a/server/routers/auth/verifyUserAccess.ts +++ b/server/routers/auth/verifyUserAccess.ts @@ -1,37 +1,62 @@ -import { Request, Response, NextFunction } from 'express'; -import { db } from '@server/db'; -import { sites, userOrgs, 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'; +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { + sites, + userOrgs, + 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 reqUserId = req.params.userId || req.body.userId || req.query.userId; if (!userId) { - return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated')); + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); } if (!reqUserId) { - return next(createHttpError(HttpCode.BAD_REQUEST, 'Invalid user ID')); + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID")); } try { - - const userOrg = await db.select() + const userOrg = await db + .select() .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); 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(createHttpError(HttpCode.FORBIDDEN, 'User does not have access to this site')); - + return next(); } 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" + ) + ); } } diff --git a/server/routers/external.ts b/server/routers/external.ts index d57327f9..0aa38896 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -19,7 +19,7 @@ import { verifyResourceAccess, verifyTargetAccess, verifyRoleAccess, - verifySuperuser, + verifySuperUser, verifyUserInRole, verifyUserAccess, } 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("/org/:orgId", verifyOrgAccess, org.getOrg); 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.get("/org/:orgId/sites", verifyOrgAccess, site.listSites); @@ -52,7 +52,7 @@ authenticated.get( site.pickSiteDefaults ); 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.delete("/site/:siteId", verifySiteAccess, site.deleteSite); @@ -75,11 +75,11 @@ authenticated.post( ); // maybe make this /invite/create instead authenticated.post("/invite/accept", user.acceptInvite); -authenticated.get( - "/resource/:resourceId/roles", - verifyResourceAccess, - resource.listResourceRoles -); +// authenticated.get( +// "/resource/:resourceId/roles", +// verifyResourceAccess, +// resource.listResourceRoles +// ); authenticated.get( "/resource/:resourceId", verifyResourceAccess, @@ -121,85 +121,85 @@ authenticated.delete( // authenticated.put( // "/org/:orgId/role", // verifyOrgAccess, -// verifySuperuser, +// verifySuperUser, // role.createRole // ); -authenticated.get("/org/:orgId/roles", verifyOrgAccess, role.listRoles); -authenticated.get( - "/role/:roleId", - verifyRoleAccess, - verifyUserInRole, - role.getRole -); +// authenticated.get("/org/:orgId/roles", verifyOrgAccess, role.listRoles); +// authenticated.get( +// "/role/:roleId", +// verifyRoleAccess, +// verifyUserInRole, +// role.getRole +// ); // authenticated.post( // "/role/:roleId", // verifyRoleAccess, -// verifySuperuser, +// verifySuperUser, // role.updateRole // ); // authenticated.delete( // "/role/:roleId", // verifyRoleAccess, -// verifySuperuser, +// verifySuperUser, // role.deleteRole // ); -authenticated.put( - "/role/:roleId/site", - verifyRoleAccess, - verifyUserInRole, - role.addRoleSite -); -authenticated.delete( - "/role/:roleId/site", - verifyRoleAccess, - verifyUserInRole, - role.removeRoleSite -); -authenticated.get( - "/role/:roleId/sites", - verifyRoleAccess, - verifyUserInRole, - role.listRoleSites -); -authenticated.put( - "/role/:roleId/resource", - verifyRoleAccess, - verifyUserInRole, - role.addRoleResource -); -authenticated.delete( - "/role/:roleId/resource", - verifyRoleAccess, - verifyUserInRole, - role.removeRoleResource -); -authenticated.get( - "/role/:roleId/resources", - verifyRoleAccess, - verifyUserInRole, - role.listRoleResources -); -authenticated.put( - "/role/:roleId/action", - verifyRoleAccess, - verifyUserInRole, - role.addRoleAction -); -authenticated.delete( - "/role/:roleId/action", - verifyRoleAccess, - verifyUserInRole, - verifySuperuser, - role.removeRoleAction -); -authenticated.get( - "/role/:roleId/actions", - verifyRoleAccess, - verifyUserInRole, - verifySuperuser, - role.listRoleActions -); +// authenticated.put( +// "/role/:roleId/site", +// verifyRoleAccess, +// verifyUserInRole, +// role.addRoleSite +// ); +// authenticated.delete( +// "/role/:roleId/site", +// verifyRoleAccess, +// verifyUserInRole, +// role.removeRoleSite +// ); +// authenticated.get( +// "/role/:roleId/sites", +// verifyRoleAccess, +// verifyUserInRole, +// role.listRoleSites +// ); +// authenticated.put( +// "/role/:roleId/resource", +// verifyRoleAccess, +// verifyUserInRole, +// role.addRoleResource +// ); +// authenticated.delete( +// "/role/:roleId/resource", +// verifyRoleAccess, +// verifyUserInRole, +// role.removeRoleResource +// ); +// authenticated.get( +// "/role/:roleId/resources", +// verifyRoleAccess, +// verifyUserInRole, +// role.listRoleResources +// ); +// authenticated.put( +// "/role/:roleId/action", +// verifyRoleAccess, +// verifyUserInRole, +// role.addRoleAction +// ); +// authenticated.delete( +// "/role/:roleId/action", +// verifyRoleAccess, +// verifyUserInRole, +// verifySuperUser, +// role.removeRoleAction +// ); +// authenticated.get( +// "/role/:roleId/actions", +// verifyRoleAccess, +// verifyUserInRole, +// verifySuperUser, +// role.listRoleActions +// ); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); @@ -211,44 +211,44 @@ authenticated.delete( user.removeUserOrg ); -authenticated.put( - "/user/:userId/site", - verifySiteAccess, - verifyUserAccess, - role.addRoleSite -); -authenticated.delete( - "/user/:userId/site", - verifySiteAccess, - verifyUserAccess, - role.removeRoleSite -); -authenticated.put( - "/user/:userId/resource", - verifyResourceAccess, - verifyUserAccess, - role.addRoleResource -); -authenticated.delete( - "/user/:userId/resource", - verifyResourceAccess, - verifyUserAccess, - role.removeRoleResource -); -authenticated.put( - "/org/:orgId/user/:userId/action", - verifyOrgAccess, - verifyUserAccess, - verifySuperuser, - role.addRoleAction -); -authenticated.delete( - "/org/:orgId/user/:userId/action", - verifyOrgAccess, - verifyUserAccess, - verifySuperuser, - role.removeRoleAction -); +// authenticated.put( +// "/user/:userId/site", +// verifySiteAccess, +// verifyUserAccess, +// role.addRoleSite +// ); +// authenticated.delete( +// "/user/:userId/site", +// verifySiteAccess, +// verifyUserAccess, +// role.removeRoleSite +// ); +// authenticated.put( +// "/user/:userId/resource", +// verifyResourceAccess, +// verifyUserAccess, +// role.addRoleResource +// ); +// authenticated.delete( +// "/user/:userId/resource", +// verifyResourceAccess, +// verifyUserAccess, +// role.removeRoleResource +// ); +// authenticated.put( +// "/org/:orgId/user/:userId/action", +// verifyOrgAccess, +// verifyUserAccess, +// verifySuperUser, +// role.addRoleAction +// ); +// authenticated.delete( +// "/org/:orgId/user/:userId/action", +// verifyOrgAccess, +// verifyUserAccess, +// verifySuperUser, +// role.removeRoleAction +// ); // Auth routes export const authRouter = Router(); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 9f4854dc..23a79620 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -8,7 +8,7 @@ import HttpCode from '@server/types/HttpCode'; import createHttpError from 'http-errors'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; 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 { fromError } from 'zod-validation-error'; @@ -75,13 +75,13 @@ export async function createOrg(req: Request, res: Response, next: NextFunction) domain }).returning(); - const roleId = await createSuperuserRole(newOrg[0].orgId); + const roleId = await createSuperUserRole(newOrg[0].orgId); if (!roleId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - `Error creating superuser role` + `Error creating Super User role` ) ); } diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 77f28820..c58d7343 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -87,27 +87,27 @@ export async function createResource(req: Request, res: Response, next: NextFunc subdomain, }).returning(); - // find the superuser roleId and also add the resource to the superuser role - const superuserRole = await db.select() + // find the Super User roleId and also add the resource to the Super User role + const superUserRole = await db.select() .from(roles) - .where(and(eq(roles.isSuperuserRole, true), eq(roles.orgId, orgId))) + .where(and(eq(roles.isSuperUserRole, true), eq(roles.orgId, orgId))) .limit(1); - if (superuserRole.length === 0) { + if (superUserRole.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, - `Superuser role not found` + `Super User role not found` ) ); } await db.insert(roleResources).values({ - roleId: superuserRole[0].roleId, + roleId: superUserRole[0].roleId, resourceId: newResource[0].resourceId, }); - if (req.userOrgRoleId != superuserRole[0].roleId) { + if (req.userOrgRoleId != superUserRole[0].roleId) { // make sure the user can access the resource await db.insert(userResources).values({ userId: req.user?.userId!, diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index aa624733..c3d880f3 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/server/routers/resource/listResourceRoles.ts @@ -51,7 +51,7 @@ export async function listResourceRoles( roleId: roles.roleId, name: roles.name, description: roles.description, - isSuperuserRole: roles.isSuperuserRole, + isSuperUserRole: roles.isSuperUserRole, }) .from(roleResources) .innerJoin(roles, eq(roleResources.roleId, roles.roleId)) diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index 62138e53..24a650f0 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -61,11 +61,11 @@ export async function deleteRole( ); } - if (role[0].isSuperuserRole) { + if (role[0].isSuperUserRole) { return next( createHttpError( HttpCode.FORBIDDEN, - `Cannot delete a superuser role` + `Cannot delete a Super User role` ) ); } diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index 1b6f911f..f73f8af0 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -80,7 +80,7 @@ export async function listRoles( .select({ roleId: roles.roleId, orgId: roles.orgId, - isSuperuserRole: roles.isSuperuserRole, + isSuperUserRole: roles.isSuperUserRole, name: roles.name, description: roles.description, orgName: orgs.name, diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index 8d05db76..04ce2478 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -81,11 +81,11 @@ export async function updateRole( ); } - if (role[0].isSuperuserRole) { + if (role[0].isSuperUserRole) { return next( createHttpError( HttpCode.FORBIDDEN, - `Cannot update a superuser role` + `Cannot update a Super User role` ) ); } diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index c50018ec..b35793d9 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -107,25 +107,25 @@ export async function createSite( subnet, }) .returning(); - // find the superuser roleId and also add the resource to the superuser role - const superuserRole = await db + // find the Super User roleId and also add the resource to the Super User role + const superUserRole = await db .select() .from(roles) - .where(and(eq(roles.isSuperuserRole, true), eq(roles.orgId, orgId))) + .where(and(eq(roles.isSuperUserRole, true), eq(roles.orgId, orgId))) .limit(1); - if (superuserRole.length === 0) { + if (superUserRole.length === 0) { return next( - createHttpError(HttpCode.NOT_FOUND, `Superuser role not found`) + createHttpError(HttpCode.NOT_FOUND, `Super User role not found`) ); } await db.insert(roleSites).values({ - roleId: superuserRole[0].roleId, + roleId: superUserRole[0].roleId, siteId: newSite.siteId, }); - if (req.userOrgRoleId != superuserRole[0].roleId) { + if (req.userOrgRoleId != superUserRole[0].roleId) { // make sure the user can access the site db.insert(userSites).values({ userId: req.user?.userId!, diff --git a/server/routers/site/listSiteRoles.ts b/server/routers/site/listSiteRoles.ts index 8c725908..1c255124 100644 --- a/server/routers/site/listSiteRoles.ts +++ b/server/routers/site/listSiteRoles.ts @@ -51,7 +51,7 @@ export async function listSiteRoles( roleId: roles.roleId, name: roles.name, description: roles.description, - isSuperuserRole: roles.isSuperuserRole, + isSuperUserRole: roles.isSuperUserRole, }) .from(roleSites) .innerJoin(roles, eq(roleSites.roleId, roles.roleId)) diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index bcbff537..a4d69772 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 response from "@server/utils/response"; import HttpCode from "@server/types/HttpCode"; @@ -13,6 +13,8 @@ import { createDate, TimeSpan } from "oslo"; import config from "@server/config"; import { hashPassword } from "@server/auth/password"; import { fromError } from "zod-validation-error"; +import { sendEmail } from "@server/emails"; +import SendInviteLink from "@server/emails/templates/SendInviteLink"; const inviteUserParamsSchema = z.object({ orgId: z.string(), @@ -31,6 +33,8 @@ export type InviteUserResponse = { expiresAt: number; }; +const inviteTracker: Record = {}; + export async function inviteUser( req: Request, 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 .select() .from(users) @@ -116,6 +153,21 @@ export async function inviteUser( 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(res, { data: { inviteLink, diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index f1f297f9..652acc95 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -11,7 +11,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const removeUserSchema = z.object({ - userId: z.string().uuid(), + userId: z.string(), orgId: z.string(), }); @@ -33,7 +33,6 @@ export async function removeUserOrg( const { userId, orgId } = parsedParams.data; - // Check if the user has permission to list sites const hasPermission = await checkUserActionPermission( ActionsEnum.removeUser, req @@ -56,7 +55,7 @@ export async function removeUserOrg( data: null, success: true, error: false, - message: "User deleted successfully", + message: "User remove from org successfully", status: HttpCode.OK, }); } catch (error) { diff --git a/src/app/[orgId]/settings/sites/components/SitesTable.tsx b/src/app/[orgId]/settings/sites/components/SitesTable.tsx index 93466e3d..655d90cc 100644 --- a/src/app/[orgId]/settings/sites/components/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/components/SitesTable.tsx @@ -79,7 +79,7 @@ export const columns: ColumnDef[] = [ .then(() => { router.refresh(); }); - } + }; return ( @@ -98,7 +98,12 @@ export const columns: ColumnDef[] = [ - + diff --git a/src/app/[orgId]/settings/users/components/InviteUserForm.tsx b/src/app/[orgId]/settings/users/components/InviteUserForm.tsx index 2364233d..f3cf8073 100644 --- a/src/app/[orgId]/settings/users/components/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/users/components/InviteUserForm.tsx @@ -122,7 +122,13 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { return ( <> - + { + setOpen(val); + setInviteLink(null); + setLoading(false); + setExpiresInDays(1); + form.reset(); + }}> Invite User @@ -257,7 +263,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { type="submit" form="invite-user-form" loading={loading} - disabled={inviteLink !== null} + disabled={inviteLink !== null || loading} > Create Invitation diff --git a/src/app/[orgId]/settings/users/components/UsersTable.tsx b/src/app/[orgId]/settings/users/components/UsersTable.tsx index 54560985..a35e8f67 100644 --- a/src/app/[orgId]/settings/users/components/UsersTable.tsx +++ b/src/app/[orgId]/settings/users/components/UsersTable.tsx @@ -12,60 +12,178 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { UsersDataTable } from "./UsersDataTable"; import { useState } from "react"; 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 = { id: string; email: string; + status: string; + role: string; }; -export const columns: ColumnDef[] = [ - { - accessorKey: "email", - header: ({ column }) => { - return ( - - ); - }, - }, - { - id: "actions", - cell: ({ row }) => { - const userRow = row.original; - - return ( - - - - - - Edit access - - - ); - }, - }, -]; - type UsersTableProps = { users: UserRow[]; }; export default function UsersTable({ users }: UsersTableProps) { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [userToRemove, setUserToRemove] = useState(null); + + const user = useUserContext(); + const { org } = useOrgContext(); + const { toast } = useToast(); + + const columns: ColumnDef[] = [ + { + accessorKey: "email", + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "status", + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "role", + header: ({ column }) => { + return ( + + ); + }, + }, + { + id: "actions", + cell: ({ row }) => { + const userRow = row.original; + + return ( + <> + + + + + + Manage user + {userRow.email !== user?.email && ( + + + + )} + + + + ); + }, + }, + ]; + + async function removeUser() { + 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.", + }); + }); + + if (res && res.status === 200) { + toast({ + variant: "default", + title: "User removed", + description: `The user ${userToRemove.email} has been removed from the organization.`, + }); + } + } + setIsDeleteModalOpen(false); + } return ( <> + { + setIsDeleteModalOpen(val); + setUserToRemove(null); + }} + dialog={ +
+

+ Are you sure you want to remove{" "} + {userToRemove?.email} from the organization? +

+ +

+ 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. +

+ +

+ To confirm, please type the email address of the + user below. +

+
+ } + buttonText="Confirm remove user" + onConfirm={removeUser} + string={userToRemove?.email ?? ""} + title="Remove user from organization" + /> + ; @@ -14,6 +16,9 @@ type UsersPageProps = { export default async function UsersPage(props: UsersPageProps) { const params = await props.params; + const getUser = cache(verifySession); + const user = await getUser(); + let users: ListUsersResponse["users"] = []; const res = await internal .get>( @@ -49,6 +54,8 @@ export default async function UsersPage(props: UsersPageProps) { return { id: user.id, email: user.email, + status: "Confirmed", + role: user.roleName || "", }; }); @@ -64,9 +71,11 @@ export default async function UsersPage(props: UsersPageProps) {

- - - + + + + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3ace08a6..7f143399 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ import { internal } from "@app/api"; import { authCookieHeader } from "@app/api/cookies"; 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 { AxiosResponse } from "axios"; import { ArrowUpRight } from "lucide-react"; @@ -42,9 +42,9 @@ export default async function Page(props: { return ( <> - +

Logged in as {user.email}

-
+
{orgs.map((org) => ( diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx new file mode 100644 index 00000000..508027dd --- /dev/null +++ b/src/components/ConfirmDeleteDialog.tsx @@ -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; +}; + +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>({ + resolver: zodResolver(formSchema), + defaultValues: { + string: "", + }, + }); + + function reset() { + form.reset(); + setLoading(false); + } + + async function onSubmit(values: z.infer) { + setLoading(true); + await onConfirm(); + reset(); + } + + return ( + <> + { + setOpen(val); + reset(); + }} + > + + + {title} + + +
{dialog}
+
+ + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index f000e3ef..fb95de0d 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,36 +1,39 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; 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", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) + "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", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + green: "border-transparent bg-green-300", + yellow: "border-transparent bg-yellow-300", + red: "border-transparent bg-red-300", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends React.HTMLAttributes, + VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ) + return ( +
+ ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/src/providers/LandingProvider.tsx b/src/providers/UserProvider.tsx similarity index 68% rename from src/providers/LandingProvider.tsx rename to src/providers/UserProvider.tsx index fa745e53..47950725 100644 --- a/src/providers/LandingProvider.tsx +++ b/src/providers/UserProvider.tsx @@ -4,13 +4,13 @@ import { UserContext } from "@app/contexts/userContext"; import { GetUserResponse } from "@server/routers/user"; import { ReactNode } from "react"; -type LandingProviderProps = { +type UserProviderProps = { user: GetUserResponse; children: ReactNode; }; -export function LandingProvider({ user, children }: LandingProviderProps) { +export function UserProvider({ user, children }: UserProviderProps) { return {children}; } -export default LandingProvider; +export default UserProvider;