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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,8 @@ export default async function RolesPage(props: RolesPageProps) {
const params = await props.params;
let roles: ListRolesResponse["roles"] = [];
let hasInvitations = false;
const res = await internal
.get<
AxiosResponse<ListRolesResponse>
@ -29,6 +31,21 @@ export default async function RolesPage(props: RolesPageProps) {
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;
const getOrg = cache(async () =>
internal
@ -47,7 +64,7 @@ export default async function RolesPage(props: RolesPageProps) {
return (
<>
<AccessPageHeaderAndNav>
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<OrgProvider org={org}>
<RolesTable roles={roleRows} />
</OrgProvider>

View file

@ -23,6 +23,8 @@ export default async function UsersPage(props: UsersPageProps) {
const user = await getUser();
let users: ListUsersResponse["users"] = [];
let hasInvitations = false;
const res = await internal
.get<
AxiosResponse<ListUsersResponse>
@ -33,6 +35,21 @@ export default async function UsersPage(props: UsersPageProps) {
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;
const getOrg = cache(async () =>
internal
@ -61,7 +78,7 @@ export default async function UsersPage(props: UsersPageProps) {
return (
<>
<AccessPageHeaderAndNav>
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<UserProvider user={user!}>
<OrgProvider org={org}>
<UsersTable users={userRows} />

View file

@ -10,15 +10,18 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@/components/ui/select";
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
interface SidebarNavItem {
href: string;
title: string;
icon?: React.ReactNode;
}[];
children?: SidebarNavItem[];
}
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: SidebarNavItem[];
disabled?: boolean;
}
@ -35,7 +38,8 @@ export function SidebarNav({
const resourceId = params.resourceId as string;
const userId = params.userId as string;
const [selectedValue, setSelectedValue] = React.useState<string>(getSelectedValue());
const [selectedValue, setSelectedValue] =
React.useState<string>(getSelectedValue());
useEffect(() => {
setSelectedValue(getSelectedValue());
@ -62,6 +66,43 @@ export function SidebarNav({
.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 (
<div>
<div className="block lg:hidden">
@ -94,35 +135,7 @@ export function SidebarNav({
)}
{...props}
>
{items.map((item) => (
<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>
))}
{renderItems(items)}
</nav>
</div>
);