Add support for menu children and moved invitations under users

This commit is contained in:
grokdesigns 2025-04-09 09:23:47 -07:00
parent c7f3c9da92
commit 7a55c9ad03
No known key found for this signature in database
GPG key ID: 1084CD111FEE75DD
8 changed files with 105 additions and 58 deletions

View file

@ -72,7 +72,6 @@ export async function listInvitations(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
// Validate query parameters
const parsedQuery = listInvitationsQuerySchema.safeParse(req.query); const parsedQuery = listInvitationsQuerySchema.safeParse(req.query);
if (!parsedQuery.success) { if (!parsedQuery.success) {
return next( return next(
@ -84,7 +83,6 @@ export async function listInvitations(
} }
const { limit, offset } = parsedQuery.data; const { limit, offset } = parsedQuery.data;
// Validate path parameters
const parsedParams = listInvitationsParamsSchema.safeParse(req.params); const parsedParams = listInvitationsParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
@ -96,16 +94,13 @@ export async function listInvitations(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
// Query invitations
const invitations = await queryInvitations(orgId, limit, offset); const invitations = await queryInvitations(orgId, limit, offset);
// Get total count of invitations
const [{ count }] = await db const [{ count }] = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(userInvites) .from(userInvites)
.where(sql`${userInvites.orgId} = ${orgId}`); .where(sql`${userInvites.orgId} = ${orgId}`);
// Return response
return response<ListInvitationsResponse>(res, { return response<ListInvitationsResponse>(res, {
data: { data: {
invitations, invitations,

View file

@ -22,7 +22,6 @@ export async function removeInvitation(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
// Validate path parameters
const parsedParams = removeInvitationParamsSchema.safeParse(req.params); const parsedParams = removeInvitationParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
@ -35,7 +34,6 @@ export async function removeInvitation(
const { orgId, inviteId } = parsedParams.data; const { orgId, inviteId } = parsedParams.data;
// Delete the invitation from the database
const deletedInvitation = await db const deletedInvitation = await db
.delete(userInvites) .delete(userInvites)
.where( .where(
@ -46,7 +44,6 @@ export async function removeInvitation(
) )
.returning(); .returning();
// If no rows were deleted, the invitation was not found
if (deletedInvitation.length === 0) { if (deletedInvitation.length === 0) {
return next( return next(
createHttpError( createHttpError(
@ -56,7 +53,6 @@ export async function removeInvitation(
); );
} }
// Return success response
return response(res, { return response(res, {
data: null, data: null,
success: true, success: true,

View file

@ -5,19 +5,25 @@ import { SidebarSettings } from "@app/components/SidebarSettings";
type AccessPageHeaderAndNavProps = { type AccessPageHeaderAndNavProps = {
children: React.ReactNode; children: React.ReactNode;
hasInvitations: boolean;
}; };
export default function AccessPageHeaderAndNav({ export default function AccessPageHeaderAndNav({
children children,
hasInvitations
}: AccessPageHeaderAndNavProps) { }: AccessPageHeaderAndNavProps) {
const sidebarNavItems = [ const sidebarNavItems = [
{ {
title: "Users", title: "Users",
href: `/{orgId}/settings/access/users` href: `/{orgId}/settings/access/users`,
}, children: hasInvitations
? [
{ {
title: "Invitations", title: "Invitations",
href: `/{orgId}/settings/access/invitations` href: `/{orgId}/settings/access/invitations`
}
]
: []
}, },
{ {
title: "Roles", title: "Roles",
@ -29,8 +35,7 @@ export default function AccessPageHeaderAndNav({
<> <>
<SettingsSectionTitle <SettingsSectionTitle
title="Manage Users & Roles" title="Manage Users & Roles"
description="Invite users and add them to roles to manage access to your description="Invite users and add them to roles to manage access to your organization"
organization"
/> />
<SidebarSettings sidebarNavItems={sidebarNavItems}> <SidebarSettings sidebarNavItems={sidebarNavItems}>

View file

@ -20,7 +20,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
export type InvitationRow = { export type InvitationRow = {
id: string; id: string;
email: string; email: string;
expiresAt: string; // ISO string or timestamp expiresAt: string;
role: string; role: string;
}; };

View file

@ -28,16 +28,20 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
roleId: string; roleId: string;
roleName?: string; roleName?: string;
}[] = []; }[] = [];
let hasInvitations = false;
const res = await internal const res = await internal
.get< .get<
AxiosResponse<{ AxiosResponse<{
invitations: typeof invitations; invitations: typeof invitations;
pagination: { total: number };
}> }>
>(`/org/${params.orgId}/invitations`, await authCookieHeader()) >(`/org/${params.orgId}/invitations`, await authCookieHeader())
.catch((e) => {}); .catch((e) => {});
if (res && res.status === 200) { if (res && res.status === 200) {
invitations = res.data.data.invitations; invitations = res.data.data.invitations;
hasInvitations = res.data.data.pagination.total > 0;
} }
let org: GetOrgResponse | null = null; let org: GetOrgResponse | null = null;
@ -61,13 +65,13 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
id: invite.inviteId, id: invite.inviteId,
email: invite.email, email: invite.email,
expiresAt: new Date(Number(invite.expiresAt)).toISOString(), expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
role: invite.roleName || "Unknown Role" // Use roleName if available role: invite.roleName || "Unknown Role"
}; };
}); });
return ( return (
<> <>
<AccessPageHeaderAndNav> <AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<UserProvider user={user!}> <UserProvider user={user!}>
<OrgProvider org={org}> <OrgProvider org={org}>
<InvitationsTable invitations={invitationRows} /> <InvitationsTable invitations={invitationRows} />

View file

@ -19,6 +19,8 @@ export default async function RolesPage(props: RolesPageProps) {
const params = await props.params; const params = await props.params;
let roles: ListRolesResponse["roles"] = []; let roles: ListRolesResponse["roles"] = [];
let hasInvitations = false;
const res = await internal const res = await internal
.get< .get<
AxiosResponse<ListRolesResponse> AxiosResponse<ListRolesResponse>
@ -29,6 +31,21 @@ export default async function RolesPage(props: RolesPageProps) {
roles = res.data.data.roles; roles = res.data.data.roles;
} }
const invitationsRes = await internal
.get<
AxiosResponse<{
pagination: { total: number };
}>
>(
`/org/${params.orgId}/invitations?limit=1&offset=0`,
await authCookieHeader()
)
.catch((e) => {});
if (invitationsRes && invitationsRes.status === 200) {
hasInvitations = invitationsRes.data.data.pagination.total > 0;
}
let org: GetOrgResponse | null = null; let org: GetOrgResponse | null = null;
const getOrg = cache(async () => const getOrg = cache(async () =>
internal internal
@ -47,7 +64,7 @@ export default async function RolesPage(props: RolesPageProps) {
return ( return (
<> <>
<AccessPageHeaderAndNav> <AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<OrgProvider org={org}> <OrgProvider org={org}>
<RolesTable roles={roleRows} /> <RolesTable roles={roleRows} />
</OrgProvider> </OrgProvider>

View file

@ -23,6 +23,8 @@ export default async function UsersPage(props: UsersPageProps) {
const user = await getUser(); const user = await getUser();
let users: ListUsersResponse["users"] = []; let users: ListUsersResponse["users"] = [];
let hasInvitations = false;
const res = await internal const res = await internal
.get< .get<
AxiosResponse<ListUsersResponse> AxiosResponse<ListUsersResponse>
@ -33,6 +35,21 @@ export default async function UsersPage(props: UsersPageProps) {
users = res.data.data.users; users = res.data.data.users;
} }
const invitationsRes = await internal
.get<
AxiosResponse<{
pagination: { total: number };
}>
>(
`/org/${params.orgId}/invitations?limit=1&offset=0`,
await authCookieHeader()
)
.catch((e) => {});
if (invitationsRes && invitationsRes.status === 200) {
hasInvitations = invitationsRes.data.data.pagination.total > 0;
}
let org: GetOrgResponse | null = null; let org: GetOrgResponse | null = null;
const getOrg = cache(async () => const getOrg = cache(async () =>
internal internal
@ -61,7 +78,7 @@ export default async function UsersPage(props: UsersPageProps) {
return ( return (
<> <>
<AccessPageHeaderAndNav> <AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<UserProvider user={user!}> <UserProvider user={user!}>
<OrgProvider org={org}> <OrgProvider org={org}>
<UsersTable users={userRows} /> <UsersTable users={userRows} />

View file

@ -10,15 +10,18 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue
} from "@/components/ui/select"; } from "@/components/ui/select";
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> { interface SidebarNavItem {
items: {
href: string; href: string;
title: string; title: string;
icon?: React.ReactNode; icon?: React.ReactNode;
}[]; children?: SidebarNavItem[];
}
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: SidebarNavItem[];
disabled?: boolean; disabled?: boolean;
} }
@ -35,7 +38,8 @@ export function SidebarNav({
const resourceId = params.resourceId as string; const resourceId = params.resourceId as string;
const userId = params.userId as string; const userId = params.userId as string;
const [selectedValue, setSelectedValue] = React.useState<string>(getSelectedValue()); const [selectedValue, setSelectedValue] =
React.useState<string>(getSelectedValue());
useEffect(() => { useEffect(() => {
setSelectedValue(getSelectedValue()); setSelectedValue(getSelectedValue());
@ -62,6 +66,43 @@ export function SidebarNav({
.replace("{userId}", userId); .replace("{userId}", userId);
} }
function renderItems(items: SidebarNavItem[]) {
return items.map((item) => (
<div key={hydrateHref(item.href)}>
<Link
href={hydrateHref(item.href)}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === hydrateHref(item.href) &&
!pathname.includes("create")
? "bg-accent hover:bg-accent dark:bg-border dark:hover:bg-border"
: "hover:bg-transparent hover:underline",
"justify-start",
disabled && "cursor-not-allowed"
)}
onClick={disabled ? (e) => e.preventDefault() : undefined}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
{item.icon ? (
<div className="flex items-center space-x-2">
{item.icon}
<span>{item.title}</span>
</div>
) : (
item.title
)}
</Link>
{item.children && (
<div className="ml-4 space-y-2">
{renderItems(item.children)}{" "}
{/* Recursively render children */}
</div>
)}
</div>
));
}
return ( return (
<div> <div>
<div className="block lg:hidden"> <div className="block lg:hidden">
@ -94,35 +135,7 @@ export function SidebarNav({
)} )}
{...props} {...props}
> >
{items.map((item) => ( {renderItems(items)}
<Link
key={hydrateHref(item.href)}
href={hydrateHref(item.href)}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === hydrateHref(item.href) &&
!pathname.includes("create")
? "bg-accent hover:bg-accent dark:bg-border dark:hover:bg-border"
: "hover:bg-transparent hover:underline",
"justify-start",
disabled && "cursor-not-allowed"
)}
onClick={
disabled ? (e) => e.preventDefault() : undefined
}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
{item.icon ? (
<div className="flex items-center space-x-2">
{item.icon}
<span>{item.title}</span>
</div>
) : (
item.title
)}
</Link>
))}
</nav> </nav>
</div> </div>
); );