change user role

This commit is contained in:
Milo Schwartz 2024-11-10 21:19:41 -05:00
parent e141263b7e
commit 1a3d7705d9
No known key found for this signature in database
14 changed files with 320 additions and 306 deletions

View file

@ -38,7 +38,7 @@ export async function verifyUserAccess(
req.userOrg = res[0]; req.userOrg = res[0];
} }
if (req.userOrg) { if (!req.userOrg) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,

View file

@ -8,10 +8,11 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import stoi from "@server/utils/stoi";
const addUserRoleParamsSchema = z.object({ const addUserRoleParamsSchema = z.object({
userId: z.string(), userId: z.string(),
roleId: z.number().int().positive(), roleId: z.string().transform(stoi).pipe(z.number()),
}); });
export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>; export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>;
@ -22,17 +23,17 @@ export async function addUserRole(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedBody = addUserRoleParamsSchema.safeParse(req.body); const parsedParams = addUserRoleParamsSchema.safeParse(req.params);
if (!parsedBody.success) { if (!parsedParams.success) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString() fromError(parsedParams.error).toString()
) )
); );
} }
const { userId, roleId } = parsedBody.data; const { userId, roleId } = parsedParams.data;
if (!req.userOrg) { if (!req.userOrg) {
return next( return next(

View file

@ -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 (
<>
{" "}
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
Users & Roles
</h2>
<p className="text-muted-foreground">
Invite users and add them to roles to manage access to your
organization
</p>
</div>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
{children}
</SidebarSettings>
</>
);
}

View file

@ -1,40 +1,14 @@
import { SidebarSettings } from "@app/components/SidebarSettings";
interface AccessLayoutProps { interface AccessLayoutProps {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ resourceId: number | string; orgId: string }>; params: Promise<{
resourceId: number | string;
orgId: string;
}>;
} }
export default async function ResourceLayout(props: AccessLayoutProps) { export default async function ResourceLayout(props: AccessLayoutProps) {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
const sidebarNavItems = [ return <>{children}</>;
{
title: "Users",
href: `/{orgId}/settings/access/users`,
},
{
title: "Roles",
href: `/{orgId}/settings/access/roles`,
},
];
return (
<>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
Users & Roles
</h2>
<p className="text-muted-foreground">
Invite users and add them to roles to manage access to your
organization.
</p>
</div>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
{children}
</SidebarSettings>
</>
);
} }

View file

@ -6,6 +6,8 @@ import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import RolesTable, { RoleRow } from "./components/RolesTable"; import RolesTable, { RoleRow } from "./components/RolesTable";
import { SidebarSettings } from "@app/components/SidebarSettings";
import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav";
type RolesPageProps = { type RolesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -49,9 +51,11 @@ export default async function RolesPage(props: RolesPageProps) {
return ( return (
<> <>
<OrgProvider org={org}> <AccessPageHeaderAndNav>
<RolesTable roles={roleRows} /> <OrgProvider org={org}>
</OrgProvider> <RolesTable roles={roleRows} />
</OrgProvider>
</AccessPageHeaderAndNav>
</> </>
); );
} }

View file

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: user.email!,
roleId: user.roleId?.toString(),
},
});
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/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<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/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 (
<>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
Access Controls
</h2>
<p className="text-muted-foreground">
Manage what this user can access and do in the organization
</p>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={loading} disabled={loading}>
Save Changes
</Button>
</form>
</Form>
</>
);
}

View file

@ -1,11 +1,10 @@
import SiteProvider from "@app/providers/SiteProvider";
import { internal } from "@app/api"; import { internal } from "@app/api";
import { GetSiteResponse } from "@server/routers/site";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
import { GetOrgUserResponse } from "@server/routers/user"; import { GetOrgUserResponse } from "@server/routers/user";
import OrgUserProvider from "@app/providers/OrgUserProvider";
interface UserLayoutProps { interface UserLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -30,28 +29,28 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const sidebarNavItems = [ const sidebarNavItems = [
{ {
title: "General", title: "Access Controls",
href: "/{orgId}/settings/access/users/{userId}", href: "/{orgId}/settings/access/users/{userId}/access-controls",
}, },
]; ];
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <OrgUserProvider orgUser={user}>
<h2 className="text-2xl font-bold tracking-tight"> <div className="space-y-0.5 select-none mb-6">
User {user?.email} <h2 className="text-2xl font-bold tracking-tight">
</h2> User {user?.email}
<p className="text-muted-foreground"> </h2>
Manage user access and permissions <p className="text-muted-foreground">Manage user</p>
</p> </div>
</div>
<SidebarSettings <SidebarSettings
sidebarNavItems={sidebarNavItems} sidebarNavItems={sidebarNavItems}
limitWidth={true} limitWidth={true}
> >
{children} {children}
</SidebarSettings> </SidebarSettings>
</OrgUserProvider>
</> </>
); );
} }

View file

@ -1,20 +1,9 @@
import React from "react"; import { redirect } from "next/navigation";
import { Separator } from "@/components/ui/separator";
export default async function UserPage(props: { export default async function UserPage(props: {
params: Promise<{ niceId: string }>; params: Promise<{ orgId: string; userId: string }>;
}) { }) {
const params = await props.params; const { orgId, userId } = await props.params;
redirect(`/${orgId}/settings/access/users/${userId}/access-controls`);
return ( return <></>;
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Manage User</h3>
<p className="text-sm text-muted-foreground">
Manage user access and permissions
</p>
</div>
<Separator />
</div>
);
} }

View file

@ -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<ListUsersResponse["users"]>;
onUserUpdate(): (
user: ArrayElement<ListUsersResponse["users"]>
) => Promise<void>;
};
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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: user.email,
roleId: user.roleId?.toString(),
},
});
useEffect(() => {
if (!open) {
return;
}
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(
`/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<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/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 (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Manage User</CredenzaTitle>
<CredenzaDescription>
Update the role of the user in the organization.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="manage-user-form"
>
<FormField
control={form.control}
name="email"
disabled={true}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="User's email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
type="submit"
form="manage-user-form"
loading={loading}
disabled={loading}
>
Save User
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View file

@ -17,8 +17,8 @@ import { useUserContext } from "@app/hooks/useUserContext";
import api from "@app/api"; import api from "@app/api";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import ManageUserForm from "./ManageUserForm";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
export type UserRow = { export type UserRow = {
id: string; id: string;
@ -39,6 +39,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const [users, setUsers] = useState<UserRow[]>(u); const [users, setUsers] = useState<UserRow[]>(u);
const router = useRouter();
const user = useUserContext(); const user = useUserContext();
const { org } = useOrgContext(); const { org } = useOrgContext();
const { toast } = useToast(); const { toast } = useToast();

View file

@ -8,6 +8,8 @@ import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { SidebarSettings } from "@app/components/SidebarSettings";
import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav";
type UsersPageProps = { type UsersPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -62,11 +64,13 @@ export default async function UsersPage(props: UsersPageProps) {
return ( return (
<> <>
<UserProvider user={user!}> <AccessPageHeaderAndNav>
<OrgProvider org={org}> <UserProvider user={user!}>
<UsersTable users={userRows} /> <OrgProvider org={org}>
</OrgProvider> <UsersTable users={userRows} />
</UserProvider> </OrgProvider>
</UserProvider>
</AccessPageHeaderAndNav>
</> </>
); );
} }

View file

@ -0,0 +1,11 @@
import { GetOrgUserResponse } from "@server/routers/user";
import { createContext } from "react";
interface OrgUserContext {
orgUser: GetOrgUserResponse;
updateOrgUser: (updateOrgUser: Partial<GetOrgUserResponse>) => void;
}
const OrgUserContext = createContext<OrgUserContext | undefined>(undefined);
export default OrgUserContext;

View file

@ -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;
}

View file

@ -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<GetOrgUserResponse | null>(
serverOrgUser
);
const updateOrgUser = (updateOrgUser: Partial<GetOrgUserResponse>) => {
if (!orgUser) {
throw new Error("No org to update");
}
setOrgUser((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
...updateOrgUser,
};
});
};
return (
<OrgUserContext.Provider value={{ orgUser, updateOrgUser }}>
{children}
</OrgUserContext.Provider>
);
}
export default OrgUserProvider;