From 231e1d2e2d4d7e37577852e03c99f2741d035c9d Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 9 Nov 2024 23:59:19 -0500 Subject: [PATCH] more user role stuff --- server/auth/actions.ts | 1 + server/routers/auth/verifyAdmin.ts | 9 +- server/routers/auth/verifyOrgAccess.ts | 9 +- server/routers/auth/verifyResourceAccess.ts | 9 +- server/routers/auth/verifyRoleAccess.ts | 20 +- server/routers/auth/verifySiteAccess.ts | 27 ++- server/routers/auth/verifyTargetAccess.ts | 13 +- server/routers/auth/verifyUserAccess.ts | 8 +- server/routers/auth/verifyUserIsOrgOwner.ts | 9 +- server/routers/external.ts | 28 +-- server/routers/org/createOrg.ts | 23 +- server/routers/role/createRole.ts | 17 +- .../user/{setUserRole.ts => addUserRole.ts} | 46 +++- server/routers/user/getOrgUser.ts | 120 ++++++++++ server/routers/user/getUser.ts | 41 ++-- server/routers/user/index.ts | 5 +- server/types/ArrayElement.ts | 2 + src/app/[orgId]/page.tsx | 43 ++++ src/app/[orgId]/settings/access/layout.tsx | 7 +- .../roles/components/CreateRoleForm.tsx | 179 ++++++++++++++ .../roles/components/RolesDataTable.tsx | 4 + .../access/roles/components/RolesTable.tsx | 31 ++- .../settings/access/users/[userId]/layout.tsx | 57 +++++ .../settings/access/users/[userId]/page.tsx | 20 ++ .../users/components/ManageUserForm.tsx | 226 ++++++++++++++++++ .../users/components/UsersDataTable.tsx | 4 + .../access/users/components/UsersTable.tsx | 32 ++- src/app/[orgId]/settings/layout.tsx | 15 +- .../components/ResourcesDataTable.tsx | 4 + .../sites/components/SitesDataTable.tsx | 4 + src/components/DataTablePagination.tsx | 2 +- src/components/sidebar-nav.tsx | 20 +- 32 files changed, 897 insertions(+), 138 deletions(-) rename server/routers/user/{setUserRole.ts => addUserRole.ts} (59%) create mode 100644 server/routers/user/getOrgUser.ts create mode 100644 server/types/ArrayElement.ts create mode 100644 src/app/[orgId]/page.tsx create mode 100644 src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx create mode 100644 src/app/[orgId]/settings/access/users/[userId]/layout.tsx create mode 100644 src/app/[orgId]/settings/access/users/[userId]/page.tsx create mode 100644 src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 2e40f85c..04b72a8d 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -51,6 +51,7 @@ export enum ActionsEnum { // removeUserAction = "removeUserAction", removeUserResource = "removeUserResource", removeUserSite = "removeUserSite", + getOrgUser = "getOrgUser", } export async function checkUserActionPermission( diff --git a/server/routers/auth/verifyAdmin.ts b/server/routers/auth/verifyAdmin.ts index 579a63e3..08ab3d09 100644 --- a/server/routers/auth/verifyAdmin.ts +++ b/server/routers/auth/verifyAdmin.ts @@ -12,7 +12,6 @@ export async function verifyAdmin( ) { const userId = req.user?.userId; const orgId = req.userOrgId; - let userOrg = req.userOrg; if (!userId) { return next( @@ -26,16 +25,16 @@ export async function verifyAdmin( ); } - if (!userOrg) { + if (!req.userOrg) { const userOrgRes = await db .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!))) .limit(1); - userOrg = userOrgRes[0]; + req.userOrg = userOrgRes[0]; } - if (!userOrg) { + if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -47,7 +46,7 @@ export async function verifyAdmin( const userRole = await db .select() .from(roles) - .where(eq(roles.roleId, userOrg.roleId)) + .where(eq(roles.roleId, req.userOrg.roleId)) .limit(1); if (userRole.length === 0 || !userRole[0].isAdmin) { diff --git a/server/routers/auth/verifyOrgAccess.ts b/server/routers/auth/verifyOrgAccess.ts index a6b93f65..595a724a 100644 --- a/server/routers/auth/verifyOrgAccess.ts +++ b/server/routers/auth/verifyOrgAccess.ts @@ -12,7 +12,6 @@ export async function verifyOrgAccess( ) { const userId = req.user!.userId; const orgId = req.params.orgId; - let userOrg = req.userOrg; if (!userId) { return next( @@ -27,17 +26,17 @@ export async function verifyOrgAccess( } try { - if (!userOrg) { + if (!req.userOrg) { const userOrgRes = await db .select() .from(userOrgs) .where( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ); - userOrg = userOrgRes[0]; + req.userOrg = userOrgRes[0]; } - if (!userOrg) { + if (!req.userOrg) { next( createHttpError( HttpCode.FORBIDDEN, @@ -46,7 +45,7 @@ export async function verifyOrgAccess( ); } else { // User has access, attach the user's role to the request for potential future use - req.userOrgRoleId = userOrg.roleId; + req.userOrgRoleId = req.userOrg.roleId; req.userOrgId = orgId; return next(); } diff --git a/server/routers/auth/verifyResourceAccess.ts b/server/routers/auth/verifyResourceAccess.ts index 1ddbe69b..adbc1c0f 100644 --- a/server/routers/auth/verifyResourceAccess.ts +++ b/server/routers/auth/verifyResourceAccess.ts @@ -18,7 +18,6 @@ export async function verifyResourceAccess( const userId = req.user!.userId; const resourceId = req.params.resourceId || req.body.resourceId || req.query.resourceId; - let userOrg = req.userOrg; if (!userId) { return next( @@ -51,7 +50,7 @@ export async function verifyResourceAccess( ); } - if (!userOrg) { + if (!req.userOrg) { const userOrgRole = await db .select() .from(userOrgs) @@ -62,10 +61,10 @@ export async function verifyResourceAccess( ) ) .limit(1); - userOrg = userOrgRole[0]; + req.userOrg = userOrgRole[0]; } - if (!userOrg) { + if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -74,7 +73,7 @@ export async function verifyResourceAccess( ); } - const userOrgRoleId = userOrg.roleId; + const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = resource[0].orgId; diff --git a/server/routers/auth/verifyRoleAccess.ts b/server/routers/auth/verifyRoleAccess.ts index bd74226f..d714e1ba 100644 --- a/server/routers/auth/verifyRoleAccess.ts +++ b/server/routers/auth/verifyRoleAccess.ts @@ -15,7 +15,6 @@ export async function verifyRoleAccess( const roleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); - let userOrg = req.userOrg; if (!userId) { return next( @@ -43,7 +42,7 @@ export async function verifyRoleAccess( ); } - if (!userOrg) { + if (!req.userOrg) { const userOrgRole = await db .select() .from(userOrgs) @@ -54,10 +53,10 @@ export async function verifyRoleAccess( ) ) .limit(1); - userOrg = userOrgRole[0]; + req.userOrg = userOrgRole[0]; } - if (!userOrg) { + if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -66,8 +65,17 @@ export async function verifyRoleAccess( ); } - req.userOrgRoleId = userOrg.roleId; - req.userOrgId = userOrg.orgId; + if (req.userOrg.orgId !== role[0].orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Role does not belong to the organization" + ) + ); + } + + req.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = req.userOrg.orgId; return next(); } catch (error) { diff --git a/server/routers/auth/verifySiteAccess.ts b/server/routers/auth/verifySiteAccess.ts index 7d4e9690..f0443baa 100644 --- a/server/routers/auth/verifySiteAccess.ts +++ b/server/routers/auth/verifySiteAccess.ts @@ -57,19 +57,22 @@ export async function verifySiteAccess( ); } - // Get user's role ID in the organization - const userOrgRole = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, site[0].orgId) + if (!req.userOrg) { + // Get user's role ID in the organization + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, site[0].orgId) + ) ) - ) - .limit(1); + .limit(1); + req.userOrg = userOrgRole[0]; + } - if (userOrgRole.length === 0) { + if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -78,7 +81,7 @@ export async function verifySiteAccess( ); } - const userOrgRoleId = userOrgRole[0].roleId; + const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = site[0].orgId; diff --git a/server/routers/auth/verifyTargetAccess.ts b/server/routers/auth/verifyTargetAccess.ts index 87557e87..eb086622 100644 --- a/server/routers/auth/verifyTargetAccess.ts +++ b/server/routers/auth/verifyTargetAccess.ts @@ -12,7 +12,6 @@ export async function verifyTargetAccess( ) { const userId = req.user!.userId; const targetId = parseInt(req.params.targetId); - let userOrg = req.userOrg; if (!userId) { return next( @@ -36,7 +35,7 @@ export async function verifyTargetAccess( return next( createHttpError( HttpCode.NOT_FOUND, - `target with ID ${targetId} not found` + `Target with ID ${targetId} not found` ) ); } @@ -47,7 +46,7 @@ export async function verifyTargetAccess( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - `target with ID ${targetId} does not have a resource ID` + `Target with ID ${targetId} does not have a resource ID` ) ); } @@ -77,7 +76,7 @@ export async function verifyTargetAccess( ); } - if (!userOrg) { + if (!req.userOrg) { const res = await db .select() .from(userOrgs) @@ -87,10 +86,10 @@ export async function verifyTargetAccess( eq(userOrgs.orgId, resource[0].orgId) ) ); - userOrg = res[0]; + req.userOrg = res[0]; } - if (!userOrg) { + if (!req.userOrg) { next( createHttpError( HttpCode.FORBIDDEN, @@ -98,7 +97,7 @@ export async function verifyTargetAccess( ) ); } else { - req.userOrgRoleId = userOrg.roleId; + req.userOrgRoleId = req.userOrg.roleId; req.userOrgId = resource[0].orgId!; next(); } diff --git a/server/routers/auth/verifyUserAccess.ts b/server/routers/auth/verifyUserAccess.ts index c9fa4634..2aa73e69 100644 --- a/server/routers/auth/verifyUserAccess.ts +++ b/server/routers/auth/verifyUserAccess.ts @@ -13,8 +13,6 @@ export async function verifyUserAccess( const userId = req.user!.userId; const reqUserId = req.params.userId || req.body.userId || req.query.userId; - let userOrg = req.userOrg; - if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") @@ -26,7 +24,7 @@ export async function verifyUserAccess( } try { - if (!userOrg) { + if (!req.userOrg) { const res = await db .select() .from(userOrgs) @@ -37,10 +35,10 @@ export async function verifyUserAccess( ) ) .limit(1); - userOrg = res[0]; + req.userOrg = res[0]; } - if (userOrg) { + if (req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/routers/auth/verifyUserIsOrgOwner.ts b/server/routers/auth/verifyUserIsOrgOwner.ts index 5314b52a..1b89ba67 100644 --- a/server/routers/auth/verifyUserIsOrgOwner.ts +++ b/server/routers/auth/verifyUserIsOrgOwner.ts @@ -12,7 +12,6 @@ export async function verifyUserIsOrgOwner( ) { const userId = req.user!.userId; const orgId = req.params.orgId; - let userOrg = req.userOrg; if (!userId) { return next( @@ -30,17 +29,17 @@ export async function verifyUserIsOrgOwner( } try { - if (!userOrg) { + if (!req.userOrg) { const res = await db .select() .from(userOrgs) .where( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ); - userOrg = res[0]; + req.userOrg = res[0]; } - if (!userOrg) { + if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -49,7 +48,7 @@ export async function verifyUserIsOrgOwner( ); } - if (!userOrg.isOwner) { + if (!req.userOrg.isOwner) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/routers/external.ts b/server/routers/external.ts index 6b196486..238a2521 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -19,7 +19,6 @@ import { verifyResourceAccess, verifyTargetAccess, verifyRoleAccess, - verifyAdmin, verifyUserInRole, verifyUserAccess, } from "./auth"; @@ -195,7 +194,6 @@ authenticated.delete( authenticated.put( "/org/:orgId/role", verifyOrgAccess, - verifyAdmin, verifyUserHasAction(ActionsEnum.createRole), role.createRole ); @@ -215,17 +213,22 @@ authenticated.get( // authenticated.post( // "/role/:roleId", // verifyRoleAccess, -// verifyAdmin, // verifyUserHasAction(ActionsEnum.updateRole), // role.updateRole // ); -// authenticated.delete( -// "/role/:roleId", -// verifyRoleAccess, -// verifyAdmin, -// verifyUserHasAction(ActionsEnum.deleteRole), -// role.deleteRole -// ); +authenticated.delete( + "/role/:roleId", + verifyRoleAccess, + verifyUserHasAction(ActionsEnum.deleteRole), + role.deleteRole +); +authenticated.post( + "/role/:roleId/add/:userId", + verifyRoleAccess, + verifyUserAccess, + verifyUserHasAction(ActionsEnum.addUserRole), + user.addUserRole +); // authenticated.put( // "/role/:roleId/site", @@ -280,7 +283,6 @@ authenticated.get( // "/role/:roleId/action", // verifyRoleAccess, // verifyUserInRole, -// verifyAdmin, // verifyUserHasAction(ActionsEnum.removeRoleAction), // role.removeRoleAction // ); @@ -288,13 +290,13 @@ authenticated.get( // "/role/:roleId/actions", // verifyRoleAccess, // verifyUserInRole, -// verifyAdmin, // verifyUserHasAction(ActionsEnum.listRoleActions), // role.listRoleActions // ); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); +authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get( "/org/:orgId/users", verifyOrgAccess, @@ -341,7 +343,6 @@ authenticated.delete( // "/org/:orgId/user/:userId/action", // verifyOrgAccess, // verifyUserAccess, -// verifyAdmin, // verifyUserHasAction(ActionsEnum.addRoleAction), // role.addRoleAction // ); @@ -349,7 +350,6 @@ authenticated.delete( // "/org/:orgId/user/:userId/action", // verifyOrgAccess, // verifyUserAccess, -// verifyAdmin, // verifyUserHasAction(ActionsEnum.removeRoleAction), // role.removeRoleAction // ); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index ad6330ce..4401f0b2 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; -import { orgs, userOrgs } from "@server/db/schema"; +import { orgs, roleActions, roles, userOrgs } from "@server/db/schema"; import response from "@server/utils/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { createAdminRole } from "@server/db/ensureActions"; import config from "@server/config"; import { fromError } from "zod-validation-error"; +import { defaultRoleAllowedActions } from "../role"; const createOrgSchema = z.object({ orgId: z.string(), @@ -96,6 +97,26 @@ export async function createOrg( }) .execute(); + const memberRole = await db + .insert(roles) + .values({ + name: "Member", + description: "Members can only view resources", + orgId, + }) + .returning(); + + await db + .insert(roleActions) + .values( + defaultRoleAllowedActions.map((action) => ({ + roleId: memberRole[0].roleId, + actionId: action, + orgId, + })) + ) + .execute(); + return response(res, { data: newOrg[0], success: true, diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index 4009ceaa..60992c03 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -19,6 +19,14 @@ const createRoleSchema = z.object({ description: z.string().optional(), }); +export const defaultRoleAllowedActions: ActionsEnum[] = [ + ActionsEnum.getOrg, + ActionsEnum.getResource, + ActionsEnum.listResources, +]; + +export type CreateRoleBody = z.infer; + export type CreateRoleResponse = Role; export async function createRole( @@ -78,17 +86,10 @@ export async function createRole( }) .returning(); - // default allowed actions for a non admin role - const allowedActions: ActionsEnum[] = [ - ActionsEnum.getOrg, - ActionsEnum.getResource, - ActionsEnum.listResources, - ]; - await db .insert(roleActions) .values( - allowedActions.map((action) => ({ + defaultRoleAllowedActions.map((action) => ({ roleId: newRole[0].roleId, actionId: action, orgId, diff --git a/server/routers/user/setUserRole.ts b/server/routers/user/addUserRole.ts similarity index 59% rename from server/routers/user/setUserRole.ts rename to server/routers/user/addUserRole.ts index da4ab3e6..7cdb893a 100644 --- a/server/routers/user/setUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -9,19 +9,20 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -const addUserRoleSchema = z.object({ +const addUserRoleParamsSchema = z.object({ userId: z.string(), roleId: z.number().int().positive(), - orgId: z.string(), }); +export type AddUserRoleResponse = z.infer; + export async function addUserRole( req: Request, res: Response, next: NextFunction ): Promise { try { - const parsedBody = addUserRoleSchema.safeParse(req.body); + const parsedBody = addUserRoleParamsSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( @@ -31,7 +32,42 @@ export async function addUserRole( ); } - const { userId, roleId, orgId } = parsedBody.data; + const { userId, roleId } = parsedBody.data; + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const orgId = req.userOrg.orgId; + + const existingUser = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (existingUser.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found or does not belong to the specified organization" + ) + ); + } + + if (existingUser[0].isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the role of the owner of the organization" + ) + ); + } const roleExists = await db .select() @@ -59,7 +95,7 @@ export async function addUserRole( success: true, error: false, message: "Role added to user successfully", - status: HttpCode.CREATED, + status: HttpCode.OK, }); } catch (error) { logger.error(error); diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts new file mode 100644 index 00000000..4f675468 --- /dev/null +++ b/server/routers/user/getOrgUser.ts @@ -0,0 +1,120 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { roles, 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 logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; + +async function queryUser(orgId: string, userId: string) { + const [user] = await db + .select({ + orgId: userOrgs.orgId, + userId: users.userId, + email: users.email, + roleId: userOrgs.roleId, + roleName: roles.name, + isOwner: userOrgs.isOwner, + isAdmin: roles.isAdmin, + }) + .from(userOrgs) + .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin(users, eq(userOrgs.userId, users.userId)) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + return user; +} + +export type GetOrgUserResponse = NonNullable< + Awaited> +>; + +const getOrgUserParamsSchema = z.object({ + userId: z.string(), + orgId: z.string(), +}); + +export async function getOrgUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getOrgUserParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + let user; + user = await queryUser(orgId, userId); + + if (!user) { + const [fullUser] = await db + .select() + .from(users) + .where(eq(users.email, userId)) + .limit(1); + + if (fullUser) { + user = await queryUser(orgId, fullUser.userId); + } + } + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found in org` + ) + ); + } + + if (user.userId !== req.userOrg.userId) { + const hasPermission = await checkUserActionPermission( + ActionsEnum.getOrgUser, + req + ); + if (!hasPermission) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + } + + return response(res, { + data: user, + success: true, + error: false, + message: "User retrieved successfully", + status: HttpCode.OK, + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/getUser.ts b/server/routers/user/getUser.ts index 43aa6c87..3a710458 100644 --- a/server/routers/user/getUser.ts +++ b/server/routers/user/getUser.ts @@ -8,11 +8,23 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -export type GetUserResponse = { - email: string; - twoFactorEnabled: boolean; - emailVerified: boolean; -}; +async function queryUser(userId: string) { + const [user] = await db + .select({ + userId: users.userId, + email: users.email, + twoFactorEnabled: users.twoFactorEnabled, + emailVerified: users.emailVerified, + }) + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + return user; +} + +export type GetUserResponse = NonNullable< + Awaited> +>; export async function getUser( req: Request, @@ -28,13 +40,9 @@ export async function getUser( ); } - const user = await db - .select() - .from(users) - .where(eq(users.userId, userId)) - .limit(1); + const user = await queryUser(userId); - if (user.length === 0) { + if (!user) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -44,11 +52,7 @@ export async function getUser( } return response(res, { - data: { - email: user[0].email, - twoFactorEnabled: user[0].twoFactorEnabled, - emailVerified: user[0].emailVerified, - }, + data: user, success: true, error: false, message: "User retrieved successfully", @@ -57,10 +61,7 @@ export async function getUser( } catch (error) { logger.error(error); return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "An error occurred..." - ) + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 68541b7f..1bd7faa4 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -1,6 +1,7 @@ export * from "./getUser"; export * from "./removeUserOrg"; export * from "./listUsers"; -export * from "./setUserRole"; +export * from "./addUserRole"; export * from "./inviteUser"; -export * from "./acceptInvite"; \ No newline at end of file +export * from "./acceptInvite"; +export * from "./getOrgUser"; \ No newline at end of file diff --git a/server/types/ArrayElement.ts b/server/types/ArrayElement.ts new file mode 100644 index 00000000..16b82b64 --- /dev/null +++ b/server/types/ArrayElement.ts @@ -0,0 +1,2 @@ +export type ArrayElement = + ArrayType extends readonly (infer ElementType)[] ? ElementType : never; diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx new file mode 100644 index 00000000..730e6851 --- /dev/null +++ b/src/app/[orgId]/page.tsx @@ -0,0 +1,43 @@ +import { internal } from "@app/api"; +import { authCookieHeader } from "@app/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { GetOrgUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +type OrgPageProps = { + params: Promise<{ orgId: string }>; +}; + +export default async function OrgPage(props: OrgPageProps) { + const params = await props.params; + const orgId = params.orgId; + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect("/auth/login"); + } + + const cookie = await authCookieHeader(); + + try { + const getOrgUser = cache(() => + internal.get>( + `/org/${orgId}/user/${user.userId}`, + cookie + ) + ); + const orgUser = await getOrgUser(); + } catch { + redirect(`/`); + } + + return ( + <> +

Welcome to {orgId} dashboard

+ + ); +} diff --git a/src/app/[orgId]/settings/access/layout.tsx b/src/app/[orgId]/settings/access/layout.tsx index cb21ecd5..fabd0380 100644 --- a/src/app/[orgId]/settings/access/layout.tsx +++ b/src/app/[orgId]/settings/access/layout.tsx @@ -27,13 +27,12 @@ export default async function ResourceLayout(props: AccessLayoutProps) { Users & Roles

- Manage users and roles for your organization. + Invite users and add them to roles to manage access to your + organization.

- + {children} diff --git a/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx new file mode 100644 index 00000000..db3805b8 --- /dev/null +++ b/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx @@ -0,0 +1,179 @@ +"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 { useToast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosResponse } from "axios"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle, +} from "@app/components/Credenza"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; + +type CreateRoleFormProps = { + open: boolean; + setOpen: (open: boolean) => void; + afterCreate?: (res: CreateRoleResponse) => Promise; +}; + +const formSchema = z.object({ + name: z.string({ message: "Name is required" }).max(32), + description: z.string().max(255).optional(), +}); + +export default function CreateRoleForm({ + open, + setOpen, + afterCreate, +}: CreateRoleFormProps) { + const { toast } = useToast(); + const { org } = useOrgContext(); + + const [loading, setLoading] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .put>( + `/org/${org?.org.orgId}/role`, + { + name: values.name, + description: values.description, + } as CreateRoleBody + ) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to create role", + description: + e.response?.data?.message || + "An error occurred while creating the role.", + }); + }); + + if (res && res.status === 201) { + toast({ + variant: "default", + title: "Role created", + description: "The role has been successfully created.", + }); + + if (open) { + setOpen(false); + } + + if (afterCreate) { + afterCreate(res.data.data); + } + } + + setLoading(false); + } + + return ( + <> + { + setOpen(val); + setLoading(false); + form.reset(); + }} + > + + + Create Role + + Create a new role to group users and manage their + permissions. + + + +
+ + ( + + Role Name + + + + + + )} + /> + ( + + Description + + + + + + )} + /> + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/access/roles/components/RolesDataTable.tsx b/src/app/[orgId]/settings/access/roles/components/RolesDataTable.tsx index 1141a5fc..26abf881 100644 --- a/src/app/[orgId]/settings/access/roles/components/RolesDataTable.tsx +++ b/src/app/[orgId]/settings/access/roles/components/RolesDataTable.tsx @@ -51,6 +51,10 @@ export function RolesDataTable({ state: { sorting, columnFilters, + pagination: { + pageSize: 100, + pageIndex: 0, + }, }, }); diff --git a/src/app/[orgId]/settings/access/roles/components/RolesTable.tsx b/src/app/[orgId]/settings/access/roles/components/RolesTable.tsx index 3330a87c..a8218e23 100644 --- a/src/app/[orgId]/settings/access/roles/components/RolesTable.tsx +++ b/src/app/[orgId]/settings/access/roles/components/RolesTable.tsx @@ -16,6 +16,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { useToast } from "@app/hooks/useToast"; import { RolesDataTable } from "./RolesDataTable"; import { Role } from "@server/db/schema"; +import CreateRoleForm from "./CreateRoleForm"; export type RoleRow = Role; @@ -23,8 +24,12 @@ type RolesTableProps = { roles: RoleRow[]; }; -export default function UsersTable({ roles }: RolesTableProps) { +export default function UsersTable({ roles: r }: RolesTableProps) { + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const [roles, setRoles] = useState(r); + const [roleToRemove, setUserToRemove] = useState(null); const { org } = useOrgContext(); @@ -80,7 +85,7 @@ export default function UsersTable({ roles }: RolesTableProps) { setUserToRemove(roleRow); }} > - Remove User + Delete Role @@ -95,7 +100,7 @@ export default function UsersTable({ roles }: RolesTableProps) { async function removeRole() { if (roleToRemove) { const res = await api - .delete(`/org/${org!.org.orgId}/role/${roleToRemove.roleId}`) + .delete(`/role/${roleToRemove.roleId}`) .catch((e) => { toast({ variant: "destructive", @@ -112,6 +117,10 @@ export default function UsersTable({ roles }: RolesTableProps) { title: "Role removed", description: `The role ${roleToRemove.name} has been removed from the organization.`, }); + + setRoles((prev) => + prev.filter((role) => role.roleId !== roleToRemove.roleId) + ); } } setIsDeleteModalOpen(false); @@ -119,6 +128,14 @@ export default function UsersTable({ roles }: RolesTableProps) { return ( <> + { + setRoles((prev) => [...prev, role]); + }} + /> + { @@ -148,7 +165,13 @@ export default function UsersTable({ roles }: RolesTableProps) { title="Remove role from organization" /> - + { + setIsCreateModalOpen(true); + }} + /> ); } diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx new file mode 100644 index 00000000..82ccdc3e --- /dev/null +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -0,0 +1,57 @@ +import SiteProvider from "@app/providers/SiteProvider"; +import { internal } from "@app/api"; +import { GetSiteResponse } from "@server/routers/site"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/api/cookies"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import { GetOrgUserResponse } from "@server/routers/user"; + +interface UserLayoutProps { + children: React.ReactNode; + params: Promise<{ userId: string; orgId: string }>; +} + +export default async function UserLayoutProps(props: UserLayoutProps) { + const params = await props.params; + + const { children } = props; + + let user = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/user/${params.userId}`, + await authCookieHeader() + ); + user = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/sites`); + } + + const sidebarNavItems = [ + { + title: "General", + href: "/{orgId}/settings/access/users/{userId}", + }, + ]; + + return ( + <> +
+

+ User {user?.email} +

+

+ Manage user access and permissions +

+
+ + + {children} + + + ); +} diff --git a/src/app/[orgId]/settings/access/users/[userId]/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/page.tsx new file mode 100644 index 00000000..1c454ef4 --- /dev/null +++ b/src/app/[orgId]/settings/access/users/[userId]/page.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { Separator } from "@/components/ui/separator"; + +export default async function UserPage(props: { + params: Promise<{ niceId: string }>; +}) { + const params = await props.params; + + return ( +
+
+

Manage User

+

+ Manage user access and permissions +

+
+ +
+ ); +} diff --git a/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx b/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx new file mode 100644 index 00000000..49582f3c --- /dev/null +++ b/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx @@ -0,0 +1,226 @@ +"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 { InviteUserResponse, ListUsersResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle, +} from "@app/components/Credenza"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { ListRolesResponse } from "@server/routers/role"; +import { ArrayElement } from "@server/types/ArrayElement"; + +type ManageUserFormProps = { + open: boolean; + setOpen: (open: boolean) => void; + user: ArrayElement; + onUserUpdate(): ( + user: ArrayElement + ) => Promise; +}; + +const formSchema = z.object({ + email: z.string().email({ message: "Please enter a valid email" }), + roleId: z.string().min(1, { message: "Please select a role" }), +}); + +export default function ManageUserForm({ + open, + setOpen, + user, +}: ManageUserFormProps) { + const { toast } = useToast(); + const { org } = useOrgContext(); + + const [loading, setLoading] = useState(false); + const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: user.email, + roleId: user.roleId?.toString(), + }, + }); + + useEffect(() => { + if (!open) { + return; + } + + async function fetchRoles() { + const res = await api + .get>( + `/org/${org?.org.orgId}/roles` + ) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch roles", + description: + e.message || + "An error occurred while fetching the roles", + }); + }); + + if (res?.status === 200) { + setRoles(res.data.data.roles); + // form.setValue( + // "roleId", + // res.data.data.roles[0].roleId.toString() + // ); + } + } + + fetchRoles(); + }, [open]); + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .post>( + `/role/${values.roleId}/add/${user.id}` + ) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to add user to role", + description: + e.response?.data?.message || + "An error occurred while adding user to the role.", + }); + }); + + if (res && res.status === 200) { + toast({ + variant: "default", + title: "User invited", + description: "The user has been updated.", + }); + } + + setLoading(false); + } + + return ( + <> + { + setOpen(val); + setLoading(false); + form.reset(); + }} + > + + + Manage User + + Update the role of the user in the organization. + + + +
+ + ( + + Email + + + + + + )} + /> + ( + + Role + + + + )} + /> + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/access/users/components/UsersDataTable.tsx b/src/app/[orgId]/settings/access/users/components/UsersDataTable.tsx index c0bb1e5d..f9e1e265 100644 --- a/src/app/[orgId]/settings/access/users/components/UsersDataTable.tsx +++ b/src/app/[orgId]/settings/access/users/components/UsersDataTable.tsx @@ -51,6 +51,10 @@ export function UsersDataTable({ state: { sorting, columnFilters, + pagination: { + pageSize: 100, + pageIndex: 0, + }, }, }); diff --git a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx index 5ee1c374..edd6d5ab 100644 --- a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx @@ -17,6 +17,8 @@ import { useUserContext } from "@app/hooks/useUserContext"; import api from "@app/api"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { useToast } from "@app/hooks/useToast"; +import ManageUserForm from "./ManageUserForm"; +import Link from "next/link"; export type UserRow = { id: string; @@ -30,10 +32,12 @@ type UsersTableProps = { users: UserRow[]; }; -export default function UsersTable({ users }: UsersTableProps) { +export default function UsersTable({ users: u }: UsersTableProps) { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [userToRemove, setUserToRemove] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + + const [users, setUsers] = useState(u); const user = useUserContext(); const { org } = useOrgContext(); @@ -120,7 +124,11 @@ export default function UsersTable({ users }: UsersTableProps) { - Manage user + + Manage User + {userRow.email !== user?.email && ( @@ -128,7 +136,7 @@ export default function UsersTable({ users }: UsersTableProps) { className="text-red-600 hover:text-red-800" onClick={() => { setIsDeleteModalOpen(true); - setUserToRemove(userRow); + setSelectedUser(userRow); }} > Remove User @@ -145,9 +153,9 @@ export default function UsersTable({ users }: UsersTableProps) { ]; async function removeUser() { - if (userToRemove) { + if (selectedUser) { const res = await api - .delete(`/org/${org!.org.orgId}/user/${userToRemove.id}`) + .delete(`/org/${org!.org.orgId}/user/${selectedUser.id}`) .catch((e) => { toast({ variant: "destructive", @@ -162,8 +170,12 @@ export default function UsersTable({ users }: UsersTableProps) { toast({ variant: "default", title: "User removed", - description: `The user ${userToRemove.email} has been removed from the organization.`, + description: `The user ${selectedUser.email} has been removed from the organization.`, }); + + setUsers((prev) => + prev.filter((u) => u.id !== selectedUser?.id) + ); } } setIsDeleteModalOpen(false); @@ -175,13 +187,13 @@ export default function UsersTable({ users }: UsersTableProps) { open={isDeleteModalOpen} setOpen={(val) => { setIsDeleteModalOpen(val); - setUserToRemove(null); + setSelectedUser(null); }} dialog={

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

@@ -199,7 +211,7 @@ export default function UsersTable({ users }: UsersTableProps) { } buttonText="Confirm remove user" onConfirm={removeUser} - string={userToRemove?.email ?? ""} + string={selectedUser?.email ?? ""} title="Remove user from organization" /> diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 6e161d5c..63286981 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -9,6 +9,7 @@ import { AxiosResponse } from "axios"; import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org"; import { authCookieHeader } from "@app/api/cookies"; import { cache } from "react"; +import { GetOrgUserResponse } from "@server/routers/user"; export const dynamic = "force-dynamic"; @@ -60,15 +61,19 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const cookie = await authCookieHeader(); try { - const getOrg = cache(() => - internal.get>( - `/org/${params.orgId}`, + const getOrgUser = cache(() => + internal.get>( + `/org/${params.orgId}/user/${user.userId}`, cookie ) ); - const org = await getOrg(); + const orgUser = await getOrgUser(); + + if (!orgUser.data.data.isAdmin || !orgUser.data.data.isOwner) { + throw new Error("User is not an admin or owner"); + } } catch { - redirect(`/`); + redirect(`/${params.orgId}`); } let orgs: ListOrgsResponse["orgs"] = []; diff --git a/src/app/[orgId]/settings/resources/components/ResourcesDataTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesDataTable.tsx index 2416f0ac..2d065b24 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesDataTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesDataTable.tsx @@ -52,6 +52,10 @@ export function ResourcesDataTable({ state: { sorting, columnFilters, + pagination: { + pageSize: 100, + pageIndex: 0, + }, }, }); diff --git a/src/app/[orgId]/settings/sites/components/SitesDataTable.tsx b/src/app/[orgId]/settings/sites/components/SitesDataTable.tsx index 932e4041..cb4fc120 100644 --- a/src/app/[orgId]/settings/sites/components/SitesDataTable.tsx +++ b/src/app/[orgId]/settings/sites/components/SitesDataTable.tsx @@ -52,6 +52,10 @@ export function SitesDataTable({ state: { sorting, columnFilters, + pagination: { + pageSize: 100, + pageIndex: 0, + }, }, }); diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index ac481a89..5a2148e7 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -41,7 +41,7 @@ export function DataTablePagination({ /> - {[10, 20, 30, 40, 50].map((pageSize) => ( + {[10, 20, 30, 40, 50, 100, 200].map((pageSize) => ( {items.map((item) => (