mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-09 20:35:28 +02:00
Add support for menu children and moved invitations under users
This commit is contained in:
parent
c7f3c9da92
commit
7a55c9ad03
8 changed files with 105 additions and 58 deletions
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue