diff --git a/server/routers/auth/verifyUserAccess.ts b/server/routers/auth/verifyUserAccess.ts index 2aa73e69..f983f057 100644 --- a/server/routers/auth/verifyUserAccess.ts +++ b/server/routers/auth/verifyUserAccess.ts @@ -38,7 +38,7 @@ export async function verifyUserAccess( req.userOrg = res[0]; } - if (req.userOrg) { + if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index 7cdb893a..81c7b07f 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -8,10 +8,11 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import stoi from "@server/utils/stoi"; const addUserRoleParamsSchema = z.object({ userId: z.string(), - roleId: z.number().int().positive(), + roleId: z.string().transform(stoi).pipe(z.number()), }); export type AddUserRoleResponse = z.infer; @@ -22,17 +23,17 @@ export async function addUserRole( next: NextFunction ): Promise { try { - const parsedBody = addUserRoleParamsSchema.safeParse(req.body); - if (!parsedBody.success) { + const parsedParams = addUserRoleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() + fromError(parsedParams.error).toString() ) ); } - const { userId, roleId } = parsedBody.data; + const { userId, roleId } = parsedParams.data; if (!req.userOrg) { return next( diff --git a/src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx b/src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx new file mode 100644 index 00000000..dfae7275 --- /dev/null +++ b/src/app/[orgId]/settings/access/components/AccessPageHeaderAndNav.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { SidebarSettings } from "@app/components/SidebarSettings"; + +type AccessPageHeaderAndNavProps = { + children: React.ReactNode; +}; + +export default function AccessPageHeaderAndNav({ + children, +}: AccessPageHeaderAndNavProps) { + const sidebarNavItems = [ + { + title: "Users", + href: `/{orgId}/settings/access/users`, + }, + { + title: "Roles", + href: `/{orgId}/settings/access/roles`, + }, + ]; + + return ( + <> + {" "} +
+

+ Users & Roles +

+

+ Invite users and add them to roles to manage access to your + organization +

+
+ + {children} + + + ); +} diff --git a/src/app/[orgId]/settings/access/layout.tsx b/src/app/[orgId]/settings/access/layout.tsx index fabd0380..2dd20177 100644 --- a/src/app/[orgId]/settings/access/layout.tsx +++ b/src/app/[orgId]/settings/access/layout.tsx @@ -1,40 +1,14 @@ -import { SidebarSettings } from "@app/components/SidebarSettings"; - interface AccessLayoutProps { children: React.ReactNode; - params: Promise<{ resourceId: number | string; orgId: string }>; + params: Promise<{ + resourceId: number | string; + orgId: string; + }>; } export default async function ResourceLayout(props: AccessLayoutProps) { const params = await props.params; const { children } = props; - const sidebarNavItems = [ - { - title: "Users", - href: `/{orgId}/settings/access/users`, - }, - { - title: "Roles", - href: `/{orgId}/settings/access/roles`, - }, - ]; - - return ( - <> -
-

- Users & Roles -

-

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

-
- - - {children} - - - ); + return <>{children}; } diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 6ec5586d..0a665e28 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -6,6 +6,8 @@ import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; import RolesTable, { RoleRow } from "./components/RolesTable"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav"; type RolesPageProps = { params: Promise<{ orgId: string }>; @@ -49,9 +51,11 @@ export default async function RolesPage(props: RolesPageProps) { return ( <> - - - + + + + + ); } diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx new file mode 100644 index 00000000..e9d54c5a --- /dev/null +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import api from "@app/api"; +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 } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ListRolesResponse } from "@server/routers/role"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { useParams } from "next/navigation"; +import { Button } from "@app/components/ui/button"; + +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 AccessControlsPage() { + const { toast } = useToast(); + const { orgUser: user } = userOrgUserContext(); + + const { orgId } = useParams(); + + 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(() => { + async function fetchRoles() { + const res = await api + .get>(`/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); + } + } + + fetchRoles(); + + form.setValue("roleId", user.roleId.toString()); + }, []); + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .post>( + `/role/${values.roleId}/add/${user.userId}` + ) + .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 ( + <> +
+

+ Access Controls +

+

+ Manage what this user can access and do in the organization +

+
+ +
+ + ( + + Role + + + + )} + /> + + + + + ); +} diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 82ccdc3e..b7abc544 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -1,11 +1,10 @@ -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"; +import OrgUserProvider from "@app/providers/OrgUserProvider"; interface UserLayoutProps { children: React.ReactNode; @@ -30,28 +29,28 @@ export default async function UserLayoutProps(props: UserLayoutProps) { const sidebarNavItems = [ { - title: "General", - href: "/{orgId}/settings/access/users/{userId}", + title: "Access Controls", + href: "/{orgId}/settings/access/users/{userId}/access-controls", }, ]; return ( <> -
-

- User {user?.email} -

-

- Manage user access and permissions -

-
+ +
+

+ User {user?.email} +

+

Manage user

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

Manage User

-

- Manage user access and permissions -

-
- -
- ); + const { orgId, userId } = await props.params; + redirect(`/${orgId}/settings/access/users/${userId}/access-controls`); + return <>; } diff --git a/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx b/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx deleted file mode 100644 index 49582f3c..00000000 --- a/src/app/[orgId]/settings/access/users/components/ManageUserForm.tsx +++ /dev/null @@ -1,226 +0,0 @@ -"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/UsersTable.tsx b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx index edd6d5ab..679b0010 100644 --- a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx @@ -17,8 +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"; +import { useRouter } from "next/navigation"; export type UserRow = { id: string; @@ -39,6 +39,8 @@ export default function UsersTable({ users: u }: UsersTableProps) { const [users, setUsers] = useState(u); + const router = useRouter(); + const user = useUserContext(); const { org } = useOrgContext(); const { toast } = useToast(); diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 68d85f5e..4c3ffe3b 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -8,6 +8,8 @@ import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav"; type UsersPageProps = { params: Promise<{ orgId: string }>; @@ -62,11 +64,13 @@ export default async function UsersPage(props: UsersPageProps) { return ( <> - - - - - + + + + + + + ); } diff --git a/src/contexts/orgUserContext.ts b/src/contexts/orgUserContext.ts new file mode 100644 index 00000000..8a000f09 --- /dev/null +++ b/src/contexts/orgUserContext.ts @@ -0,0 +1,11 @@ +import { GetOrgUserResponse } from "@server/routers/user"; +import { createContext } from "react"; + +interface OrgUserContext { + orgUser: GetOrgUserResponse; + updateOrgUser: (updateOrgUser: Partial) => void; +} + +const OrgUserContext = createContext(undefined); + +export default OrgUserContext; diff --git a/src/hooks/useOrgUserContext.ts b/src/hooks/useOrgUserContext.ts new file mode 100644 index 00000000..6de4a64d --- /dev/null +++ b/src/hooks/useOrgUserContext.ts @@ -0,0 +1,12 @@ +import OrgUserContext from "@app/contexts/orgUserContext"; +import { useContext } from "react"; + +export function userOrgUserContext() { + const context = useContext(OrgUserContext); + if (context === undefined) { + throw new Error( + "useOrgUserContext must be used within a OrgUserProvider" + ); + } + return context; +} diff --git a/src/providers/OrgUserProvider.tsx b/src/providers/OrgUserProvider.tsx new file mode 100644 index 00000000..1707cec0 --- /dev/null +++ b/src/providers/OrgUserProvider.tsx @@ -0,0 +1,44 @@ +"use client"; + +import OrgUserContext from "@app/contexts/orgUserContext"; +import { GetOrgUserResponse } from "@server/routers/user"; +import { useState } from "react"; + +interface OrgUserProviderProps { + children: React.ReactNode; + orgUser: GetOrgUserResponse | null; +} + +export function OrgUserProvider({ + children, + orgUser: serverOrgUser, +}: OrgUserProviderProps) { + const [orgUser, setOrgUser] = useState( + serverOrgUser + ); + + const updateOrgUser = (updateOrgUser: Partial) => { + if (!orgUser) { + throw new Error("No org to update"); + } + + setOrgUser((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + ...updateOrgUser, + }; + }); + }; + + return ( + + {children} + + ); +} + +export default OrgUserProvider;