feat(ui): migrate from HeroUI to shadcn/ui (#11532)

This commit is contained in:
Alejandro Bailo
2026-07-08 16:57:02 +02:00
committed by GitHub
parent c665005790
commit 52875b5c7c
519 changed files with 4814 additions and 7013 deletions
@@ -19,7 +19,8 @@ vi.mock("@/actions/users/tenants", () => ({
}));
const mockToast = vi.fn();
vi.mock("@/components/ui", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast: mockToast }),
}));
@@ -8,9 +8,9 @@ import {
switchTenant,
SwitchTenantState,
} from "@/actions/users/tenants";
import { useToast } from "@/components/ui";
import { CustomServerInput } from "@/components/ui/custom";
import { FormButtons } from "@/components/ui/form";
import { useToast } from "@/components/shadcn";
import { CustomServerInput } from "@/components/shadcn/custom";
import { FormButtons } from "@/components/shadcn/form";
import { reloadPage } from "@/lib/navigation";
export const CreateTenantForm = ({
+2 -2
View File
@@ -8,8 +8,8 @@ import * as z from "zod";
import { deleteUser } from "@/actions/users/users";
import { DeleteIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { Form } from "@/components/ui/form";
import { useToast } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
const formSchema = z.object({
userId: z.string(),
@@ -23,7 +23,8 @@ vi.mock("@/actions/users/tenants", () => ({
}));
const mockToast = vi.fn();
vi.mock("@/components/ui", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast: mockToast }),
}));
@@ -15,6 +15,8 @@ import {
deleteTenantThenSignOut,
switchThenDeleteTenant,
} from "@/actions/users/tenants";
import { useToast } from "@/components/shadcn";
import { FormButtons } from "@/components/shadcn/form";
import { Input } from "@/components/shadcn/input/input";
import {
Select,
@@ -23,8 +25,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { FormButtons } from "@/components/ui/form";
import { reloadPage } from "@/lib/navigation";
import { TenantOption } from "@/types/users";
+5 -5
View File
@@ -8,6 +8,9 @@ import * as z from "zod";
import { updateUser, updateUserRole } from "@/actions/users/users";
import { Card } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form, FormButtons } from "@/components/shadcn/form";
import {
Select,
SelectContent,
@@ -15,9 +18,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
import { editUserFormSchema } from "@/types";
export const EditForm = ({
@@ -121,12 +121,12 @@ export const EditForm = ({
variant="inner"
className="flex flex-row items-center justify-center gap-4"
>
<div className="text-small flex items-center">
<div className="flex items-center text-sm">
<UserIcon className="mr-2 h-4 w-4" />
<span className="text-text-neutral-secondary">Name:</span>
<span className="ml-2 font-semibold">{userName}</span>
</div>
<div className="text-small flex items-center">
<div className="flex items-center text-sm">
<ShieldIcon className="mr-2 h-4 w-4" />
<span className="text-text-neutral-secondary">Role:</span>
<span className="ml-2 font-semibold">
@@ -3,9 +3,9 @@
import { Dispatch, SetStateAction, useActionState, useEffect } from "react";
import { updateTenantName } from "@/actions/users/tenants";
import { useToast } from "@/components/ui";
import { CustomServerInput } from "@/components/ui/custom";
import { FormButtons } from "@/components/ui/form";
import { useToast } from "@/components/shadcn";
import { CustomServerInput } from "@/components/shadcn/custom";
import { FormButtons } from "@/components/shadcn/form";
export const EditTenantForm = ({
tenantId,
@@ -5,7 +5,7 @@ import { Dispatch, SetStateAction, useTransition } from "react";
import { removeUserFromTenant } from "@/actions/users/users";
import { DeleteIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { useToast } from "@/components/shadcn";
interface ExpelUserFormProps {
userId: string;
@@ -14,7 +14,8 @@ vi.mock("@/actions/users/tenants", () => ({
}));
const mockToast = vi.fn();
vi.mock("@/components/ui", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast: mockToast }),
}));
@@ -4,8 +4,8 @@ import { useSession } from "next-auth/react";
import { Dispatch, SetStateAction, useActionState, useEffect } from "react";
import { switchTenant } from "@/actions/users/tenants";
import { useToast } from "@/components/ui";
import { FormButtons } from "@/components/ui/form";
import { useToast } from "@/components/shadcn";
import { FormButtons } from "@/components/shadcn/form";
import { reloadPage } from "@/lib/navigation";
export const SwitchTenantForm = ({
@@ -1,9 +1,11 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/shadcn";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { Modal } from "@/components/shadcn/modal";
import { Alert, AlertDescription } from "@/components/ui/alert/Alert";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
interface ApiKeySuccessModalProps {
isOpen: boolean;
@@ -23,7 +25,8 @@ export const ApiKeySuccessModal = ({
title="API Key Created Successfully"
>
<div className="flex flex-col gap-6">
<Alert variant="destructive">
<Alert variant="error">
<AlertTriangle />
<AlertDescription>
This is the only time you will see this API key. Please copy it now
and store it securely. Once you close this dialog, the key cannot be
@@ -1,6 +1,5 @@
"use client";
import { useDisclosure } from "@heroui/use-disclosure";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -12,8 +11,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/shadcn";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DataTable } from "@/components/ui/table";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { DataTable } from "@/components/shadcn/table";
import { MetaDataProps } from "@/types";
import { ApiKeySuccessModal } from "./api-key-success-modal";
@@ -38,14 +37,14 @@ export const ApiKeysCardClient = ({
);
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
const createModal = useDisclosure();
const successModal = useDisclosure();
const revokeModal = useDisclosure();
const editModal = useDisclosure();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false);
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const handleCreateSuccess = (apiKey: string) => {
setCreatedApiKey(apiKey);
successModal.onOpen();
setIsSuccessModalOpen(true);
router.refresh();
};
@@ -59,19 +58,19 @@ export const ApiKeysCardClient = ({
const handleRevokeClick = (apiKey: EnrichedApiKey) => {
setSelectedApiKey(apiKey);
revokeModal.onOpen();
setIsRevokeModalOpen(true);
};
const handleEditClick = (apiKey: EnrichedApiKey) => {
setSelectedApiKey(apiKey);
editModal.onOpen();
setIsEditModalOpen(true);
};
const columns = createApiKeyColumns(handleEditClick, handleRevokeClick);
return (
<>
<Card variant="base" padding="none" className="p-4">
<Card variant="inner" padding="none" className="gap-4 p-4 md:p-5">
<CardHeader>
<div className="flex flex-col gap-2">
<CardTitle>API Keys</CardTitle>
@@ -83,7 +82,11 @@ export const ApiKeysCardClient = ({
</p>
</div>
<CardAction>
<Button variant="default" size="sm" onClick={createModal.onOpen}>
<Button
variant="default"
size="sm"
onClick={() => setIsCreateModalOpen(true)}
>
Create API Key
</Button>
</CardAction>
@@ -105,29 +108,29 @@ export const ApiKeysCardClient = ({
{/* Modals */}
<CreateApiKeyModal
isOpen={createModal.isOpen}
onClose={createModal.onClose}
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
{createdApiKey && (
<ApiKeySuccessModal
isOpen={successModal.isOpen}
onClose={successModal.onClose}
isOpen={isSuccessModalOpen}
onClose={() => setIsSuccessModalOpen(false)}
apiKey={createdApiKey}
/>
)}
<RevokeApiKeyModal
isOpen={revokeModal.isOpen}
onClose={revokeModal.onClose}
isOpen={isRevokeModalOpen}
onClose={() => setIsRevokeModalOpen(false)}
apiKey={selectedApiKey}
onSuccess={handleRevokeSuccess}
/>
<EditApiKeyNameModal
isOpen={editModal.isOpen}
onClose={editModal.onClose}
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
apiKey={selectedApiKey}
onSuccess={handleEditSuccess}
existingApiKeys={initialApiKeys}
@@ -2,7 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { DataTableColumnHeader } from "@/components/ui/table";
import { DataTableColumnHeader } from "@/components/shadcn/table";
import { DataTableRowActions } from "./data-table-row-actions";
import {
@@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { DateCell, NameCell } from "./table-cells";
import type { EnrichedApiKey } from "./types";
const apiKey: EnrichedApiKey = {
type: "api-keys",
id: "api-key-1",
attributes: {
name: "Production access key with a very long human readable name",
prefix: "pk_12345678",
expires_at: "2099-01-01T00:00:00Z",
revoked: false,
inserted_at: "2026-01-01T00:00:00Z",
last_used_at: null,
},
relationships: {
entity: {
data: {
type: "users",
id: "user-1",
},
},
},
userEmail: "user@example.com",
};
describe("NameCell", () => {
it("keeps long API key names on one line with a tooltip", async () => {
// Given - A long API key name
const user = userEvent.setup();
render(<NameCell apiKey={apiKey} />);
// When - Reading and hovering the displayed name
const name = screen.getByText(apiKey.attributes.name!);
await user.hover(name);
// Then - The cell has a fixed width, truncates, and exposes the full name
expect(name).toHaveClass("w-64", "truncate", "whitespace-nowrap");
expect(await screen.findByRole("tooltip")).toHaveTextContent(
apiKey.attributes.name!,
);
});
});
describe("DateCell", () => {
it("keeps API key dates on one line", () => {
// Given - A rendered API key date
render(<DateCell date={null} />);
// When - Reading the date cell
const date = screen.getByText("Never");
// Then - The table date cannot wrap to a second line
expect(date).toHaveClass("whitespace-nowrap");
});
});
@@ -1,14 +1,35 @@
import { Chip } from "@heroui/chip";
import {
Badge,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { FALLBACK_VALUES } from "./constants";
import { EnrichedApiKey, getApiKeyStatus } from "./types";
import { formatRelativeTime, getStatusColor, getStatusLabel } from "./utils";
export const NameCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<p className="text-sm font-medium">
{apiKey.attributes.name || FALLBACK_VALUES.UNNAMED}
</p>
);
// Maps HeroUI status colors to shadcn Badge variants
const STATUS_BADGE_VARIANT = {
success: "success",
danger: "error",
warning: "warning",
} as const;
export const NameCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => {
const name = apiKey.attributes.name || FALLBACK_VALUES.UNNAMED;
return (
<Tooltip>
<TooltipTrigger asChild>
<p className="w-64 truncate text-sm font-medium whitespace-nowrap">
{name}
</p>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
);
};
export const PrefixCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<code className="rounded px-2 py-1 font-mono text-xs">
@@ -17,7 +38,7 @@ export const PrefixCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
);
export const DateCell = ({ date }: { date: string | null }) => (
<p className="text-sm">{formatRelativeTime(date)}</p>
<p className="text-sm whitespace-nowrap">{formatRelativeTime(date)}</p>
);
export const LastUsedCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
@@ -27,9 +48,9 @@ export const LastUsedCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
export const StatusCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => {
const status = getApiKeyStatus(apiKey);
return (
<Chip color={getStatusColor(status)} size="sm" variant="flat">
<Badge variant={STATUS_BADGE_VARIANT[getStatusColor(status)]}>
{getStatusLabel(status)}
</Chip>
</Badge>
);
};
@@ -5,11 +5,11 @@ import { useForm } from "react-hook-form";
import * as z from "zod";
import { createApiKey } from "@/actions/api-keys/api-keys";
import { useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Form, FormButtons } from "@/components/shadcn/form";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form, FormButtons } from "@/components/ui/form";
import { DEFAULT_EXPIRY_DAYS } from "./api-keys/constants";
import { calculateExpiryDate } from "./api-keys/utils";
@@ -6,10 +6,10 @@ import { useForm } from "react-hook-form";
import * as z from "zod";
import { updateApiKey } from "@/actions/api-keys/api-keys";
import { useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form, FormButtons } from "@/components/shadcn/form";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
import { EnrichedApiKey } from "./api-keys/types";
import { isApiKeyNameDuplicate } from "./api-keys/utils";
-1
View File
@@ -3,7 +3,6 @@ export * from "./api-keys-card";
export * from "./api-keys-card-client";
export * from "./create-api-key-modal";
export * from "./edit-api-key-name-modal";
export * from "./membership-item";
export * from "./memberships-card";
export * from "./revoke-api-key-modal";
export * from "./role-item";
@@ -1,169 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MembershipItem } from "./membership-item";
vi.mock("next-auth/react", () => ({
useSession: () => ({ update: vi.fn() }),
}));
vi.mock("@/auth.config", () => ({
auth: vi.fn(),
}));
vi.mock("@/actions/users/tenants", () => ({
switchTenant: vi.fn(),
updateTenantName: vi.fn(),
deleteTenant: vi.fn(),
deleteTenantThenSignOut: vi.fn(),
switchThenDeleteTenant: vi.fn(),
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
const baseMembership = {
id: "mem-1",
type: "memberships" as const,
attributes: { role: "owner", date_joined: "2025-05-19T11:31:00Z" },
relationships: {
tenant: { data: { type: "tenants", id: "tenant-1" } },
user: { data: { type: "users", id: "user-1" } },
},
};
describe("MembershipItem", () => {
it("shows Switch button when not active tenant", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="different-tenant"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByRole("button", { name: /switch/i })).toBeInTheDocument();
expect(screen.queryByText("Active")).not.toBeInTheDocument();
});
it("shows Active badge when active tenant", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByText("Active")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /switch/i }),
).not.toBeInTheDocument();
});
it("shows Edit button when user is owner", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={true}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument();
});
it("hides Edit button when user is not owner", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(
screen.queryByRole("button", { name: /edit/i }),
).not.toBeInTheDocument();
});
it("displays membership role as badge", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByText("owner")).toBeInTheDocument();
});
it("shows Delete button when isOrgOwner and membershipCount > 1", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={true}
sessionTenantId="tenant-1"
availableTenants={[{ id: "tenant-2", name: "Other Org" }]}
membershipCount={2}
/>,
);
expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument();
});
it("shows Delete button when isOrgOwner and membershipCount === 1", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={true}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument();
});
it("hides Delete button when not isOrgOwner", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="tenant-1"
availableTenants={[{ id: "tenant-2", name: "Other Org" }]}
membershipCount={2}
/>,
);
expect(
screen.queryByRole("button", { name: /delete/i }),
).not.toBeInTheDocument();
});
});
@@ -1,167 +0,0 @@
"use client";
import { useState } from "react";
import {
Badge,
Button,
Card,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Modal } from "@/components/shadcn/modal";
import { DateWithTime } from "@/components/ui/entities";
import { EditTenantForm } from "@/components/users/forms";
import { DeleteTenantForm } from "@/components/users/forms/delete-tenant-form";
import { SwitchTenantForm } from "@/components/users/forms/switch-tenant-form";
import { MembershipDetailData, TenantOption } from "@/types/users";
export const MembershipItem = ({
membership,
tenantName,
tenantId,
isOrgOwner,
sessionTenantId,
availableTenants,
membershipCount,
}: {
membership: MembershipDetailData;
tenantName: string;
tenantId: string;
isOrgOwner: boolean;
sessionTenantId: string | undefined;
availableTenants: TenantOption[];
membershipCount: number;
}) => {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isSwitchingOpen, setIsSwitchingOpen] = useState(false);
const [isDeletingOpen, setIsDeletingOpen] = useState(false);
const isActiveTenant = tenantId === sessionTenantId;
const isLastTenant = membershipCount === 1;
return (
<>
<Modal open={isEditOpen} onOpenChange={setIsEditOpen} title="">
<EditTenantForm
tenantId={tenantId}
tenantName={tenantName}
setIsOpen={setIsEditOpen}
/>
</Modal>
<Modal
open={isSwitchingOpen}
onOpenChange={setIsSwitchingOpen}
title="Confirm organization switch"
description="The session will be updated and the page will reload to apply the change."
>
<SwitchTenantForm tenantId={tenantId} setIsOpen={setIsSwitchingOpen} />
</Modal>
<Modal
open={isDeletingOpen}
onOpenChange={setIsDeletingOpen}
title="Delete organization"
description={
isLastTenant
? "This will permanently delete the organization and all its data. This action cannot be undone."
: "This will permanently delete the organization and all its data. Users with no other organizations will lose access. This action cannot be undone."
}
>
<DeleteTenantForm
tenantId={tenantId}
tenantName={tenantName}
isActiveTenant={isActiveTenant}
isLastTenant={isLastTenant}
availableTenants={availableTenants}
setIsOpen={setIsDeletingOpen}
/>
</Modal>
<Card variant="inner" className="p-2">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<span className="flex w-16 shrink-0">
<Badge variant="secondary">{membership.attributes.role}</Badge>
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<InfoField
label="Name"
inline
variant="transparent"
className="min-w-0 [&>div]:min-w-0"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate font-semibold">
{tenantName}
</span>
</TooltipTrigger>
<TooltipContent>{tenantName}</TooltipContent>
</Tooltip>
</InfoField>
{isActiveTenant && (
<Badge variant="success" className="shrink-0">
Active
</Badge>
)}
</div>
<InfoField
label="Joined on"
inline
variant="transparent"
className="shrink-0"
>
<DateWithTime
inline
showTime={false}
dateTime={membership.attributes.date_joined}
/>
</InfoField>
{/* Fixed-width slots keep Edit/Delete/Switch aligned across rows */}
<div className="flex shrink-0 items-center gap-2 self-end sm:self-auto">
<span className="flex w-18 justify-center">
{isOrgOwner && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsEditOpen(true)}
>
Edit
</Button>
)}
</span>
<span className="flex w-18 justify-center">
{isOrgOwner && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-text-error-primary"
onClick={() => setIsDeletingOpen(true)}
>
Delete
</Button>
)}
</span>
<span className="flex w-18 justify-center">
{!isActiveTenant && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsSwitchingOpen(true)}
>
Switch
</Button>
)}
</span>
</div>
</div>
</Card>
</>
);
};
@@ -0,0 +1,157 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { MembershipDetailData, TenantDetailData } from "@/types/users";
import { MembershipsCardClient } from "./memberships-card-client";
vi.mock("next/navigation", () => ({
usePathname: () => "/profile",
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/contexts", () => ({
useFilterTransitionOptional: () => null,
}));
vi.mock("@/lib", () => ({
getPaginationInfo: () => ({
currentPage: 1,
totalPages: 1,
totalEntries: 1,
itemsPerPageOptions: [10, 20, 50],
}),
}));
vi.mock("@/components/users/forms/create-tenant-form", () => ({
CreateTenantForm: () => <div>Create organization form</div>,
}));
vi.mock("@/components/users/forms", () => ({
EditTenantForm: () => <div>Edit organization form</div>,
}));
vi.mock("@/components/users/forms/delete-tenant-form", () => ({
DeleteTenantForm: () => <div>Delete organization form</div>,
}));
vi.mock("@/components/users/forms/switch-tenant-form", () => ({
SwitchTenantForm: () => <div>Switch organization form</div>,
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast: vi.fn() }),
}));
const memberships = [
{
id: "membership-1",
type: "memberships",
attributes: {
role: "owner",
date_joined: "2026-06-09T10:00:00Z",
},
relationships: {
tenant: {
data: {
type: "tenants",
id: "tenant-1",
},
},
},
},
] satisfies MembershipDetailData[];
const tenantsMap = {
"tenant-1": {
id: "tenant-1",
type: "tenants",
attributes: {
name: "Prowler Labs",
},
relationships: {
memberships: {
meta: {
count: 1,
},
data: [],
},
},
},
} satisfies Record<string, TenantDetailData>;
describe("MembershipsCardClient", () => {
it("renders organizations in a table with the active status before the name", () => {
// When
render(
<MembershipsCardClient
memberships={memberships}
tenantsMap={tenantsMap}
hasManageAccount
sessionTenantId="tenant-1"
/>,
);
// Then
expect(screen.getByRole("table")).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Role" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Status" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Name" }),
).toBeInTheDocument();
const row = screen.getByRole("row", {
name: /owner active prowler labs/i,
});
const cells = within(row).getAllByRole("cell");
expect(cells[0]).toHaveTextContent("owner");
expect(cells[1]).toHaveTextContent("Active");
expect(cells[2]).toHaveTextContent("Prowler Labs");
});
it("keeps organization edit and delete actions inside the actions menu", async () => {
// Given
const user = userEvent.setup();
render(
<MembershipsCardClient
memberships={memberships}
tenantsMap={tenantsMap}
hasManageAccount
sessionTenantId="tenant-1"
/>,
);
// When
expect(
screen.queryByRole("button", { name: "Edit" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Delete" }),
).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", {
name: "Open actions menu",
}),
);
// Then
expect(
screen.getByRole("menuitem", { name: /edit organization/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /delete organization/i }),
).toBeInTheDocument();
});
});
@@ -1,21 +1,51 @@
"use client";
import { ColumnDef, Row } from "@tanstack/react-table";
import { ArrowRightLeft, Pencil, Trash2 } from "lucide-react";
import { useState } from "react";
import {
Badge,
Button,
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { DateWithTime } from "@/components/shadcn/entities";
import { Modal } from "@/components/shadcn/modal";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DataTable, DataTableColumnHeader } from "@/components/shadcn/table";
import { EditTenantForm } from "@/components/users/forms";
import { CreateTenantForm } from "@/components/users/forms/create-tenant-form";
import { MembershipDetailData, TenantDetailData } from "@/types/users";
import { DeleteTenantForm } from "@/components/users/forms/delete-tenant-form";
import { SwitchTenantForm } from "@/components/users/forms/switch-tenant-form";
import {
MembershipDetailData,
TenantDetailData,
TenantOption,
} from "@/types/users";
import { MembershipItem } from "./membership-item";
interface MembershipRow {
id: string;
tenantId: string;
tenantName: string;
role: string;
dateJoined: string;
isActiveTenant: boolean;
isOrgOwner: boolean;
availableTenants: TenantOption[];
membershipCount: number;
}
interface MembershipsCardClientProps {
memberships: MembershipDetailData[];
@@ -24,6 +54,153 @@ interface MembershipsCardClientProps {
sessionTenantId: string | undefined;
}
const OrganizationNameCell = ({ name }: { name: string }) => (
<Tooltip>
<TooltipTrigger asChild>
<span className="block w-64 truncate text-sm font-medium whitespace-nowrap">
{name}
</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
);
const MembershipRowActions = ({ row }: { row: Row<MembershipRow> }) => {
const membership = row.original;
const [isEditOpen, setIsEditOpen] = useState(false);
const [isSwitchingOpen, setIsSwitchingOpen] = useState(false);
const [isDeletingOpen, setIsDeletingOpen] = useState(false);
const isLastTenant = membership.membershipCount === 1;
const hasActions = membership.isOrgOwner || !membership.isActiveTenant;
if (!hasActions) {
return null;
}
return (
<>
<Modal open={isEditOpen} onOpenChange={setIsEditOpen} title="">
<EditTenantForm
tenantId={membership.tenantId}
tenantName={membership.tenantName}
setIsOpen={setIsEditOpen}
/>
</Modal>
<Modal
open={isSwitchingOpen}
onOpenChange={setIsSwitchingOpen}
title="Confirm organization switch"
description="The session will be updated and the page will reload to apply the change."
>
<SwitchTenantForm
tenantId={membership.tenantId}
setIsOpen={setIsSwitchingOpen}
/>
</Modal>
<Modal
open={isDeletingOpen}
onOpenChange={setIsDeletingOpen}
title="Delete organization"
description={
isLastTenant
? "This will permanently delete the organization and all its data. This action cannot be undone."
: "This will permanently delete the organization and all its data. Users with no other organizations will lose access. This action cannot be undone."
}
>
<DeleteTenantForm
tenantId={membership.tenantId}
tenantName={membership.tenantName}
isActiveTenant={membership.isActiveTenant}
isLastTenant={isLastTenant}
availableTenants={membership.availableTenants}
setIsOpen={setIsDeletingOpen}
/>
</Modal>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown>
{membership.isOrgOwner && (
<ActionDropdownItem
icon={<Pencil />}
label="Edit organization"
onSelect={() => setIsEditOpen(true)}
/>
)}
{!membership.isActiveTenant && (
<ActionDropdownItem
icon={<ArrowRightLeft />}
label="Switch organization"
onSelect={() => setIsSwitchingOpen(true)}
/>
)}
{membership.isOrgOwner && (
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Delete organization"
destructive
onSelect={() => setIsDeletingOpen(true)}
/>
</ActionDropdownDangerZone>
)}
</ActionDropdown>
</div>
</>
);
};
const membershipColumns: ColumnDef<MembershipRow>[] = [
{
accessorKey: "role",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Role" />
),
cell: ({ row }) => <Badge variant="tag">{row.original.role}</Badge>,
enableSorting: false,
},
{
accessorKey: "isActiveTenant",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) =>
row.original.isActiveTenant ? (
<Badge variant="success">Active</Badge>
) : (
<Badge variant="outline">Inactive</Badge>
),
enableSorting: false,
},
{
accessorKey: "tenantName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => <OrganizationNameCell name={row.original.tenantName} />,
enableSorting: false,
},
{
accessorKey: "dateJoined",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Joined on" />
),
cell: ({ row }) => (
<DateWithTime
inline
showTime={false}
dateTime={row.original.dateJoined}
/>
),
enableSorting: false,
},
{
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => <MembershipRowActions row={row} />,
enableSorting: false,
},
];
export const MembershipsCardClient = ({
memberships,
tenantsMap,
@@ -38,6 +215,23 @@ export const MembershipsCardClient = ({
return { id, name: tenantsMap[id]?.attributes.name || id };
});
const rows = memberships.map((membership) => {
const tenantId = membership.relationships.tenant.data.id;
const tenantName = tenantsMap[tenantId]?.attributes.name || tenantId;
return {
id: membership.id,
tenantId,
tenantName,
role: membership.attributes.role,
dateJoined: membership.attributes.date_joined,
isActiveTenant: tenantId === sessionTenantId,
isOrgOwner: hasManageAccount && membership.attributes.role === "owner",
availableTenants: availableTenants.filter((t) => t.id !== tenantId),
membershipCount: memberships.length,
};
});
return (
<>
<Modal
@@ -47,7 +241,7 @@ export const MembershipsCardClient = ({
>
<CreateTenantForm setIsOpen={setIsCreateOpen} />
</Modal>
<Card variant="base" padding="none" className="p-4">
<Card variant="inner" padding="none" className="gap-4 p-4 md:p-5">
<CardHeader>
<div className="flex flex-col gap-1">
<CardTitle>Organizations</CardTitle>
@@ -72,27 +266,7 @@ export const MembershipsCardClient = ({
{memberships.length === 0 ? (
<div className="text-sm text-gray-500">No memberships found.</div>
) : (
<div className="flex flex-col gap-2">
{memberships.map((membership) => {
const tenantId = membership.relationships.tenant.data.id;
return (
<MembershipItem
key={membership.id}
membership={membership}
tenantId={tenantId}
tenantName={tenantsMap[tenantId]?.attributes.name}
isOrgOwner={
hasManageAccount && membership.attributes.role === "owner"
}
sessionTenantId={sessionTenantId}
availableTenants={availableTenants.filter(
(t) => t.id !== tenantId,
)}
membershipCount={memberships.length}
/>
);
})}
</div>
<DataTable columns={membershipColumns} data={rows} />
)}
</CardContent>
</Card>
@@ -1,16 +1,12 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { AlertTriangle, Trash2Icon } from "lucide-react";
import { revokeApiKey } from "@/actions/api-keys/api-keys";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { ModalButtons } from "@/components/shadcn/custom/custom-modal-buttons";
import { Modal } from "@/components/shadcn/modal";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert/Alert";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { ModalButtons } from "@/components/ui/custom/custom-modal-buttons";
import { FALLBACK_VALUES } from "./api-keys/constants";
import { EnrichedApiKey } from "./api-keys/types";
@@ -56,8 +52,9 @@ export const RevokeApiKeyModal = ({
size="lg"
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle> Warning</AlertTitle>
<Alert variant="error">
<AlertTriangle />
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
This action cannot be undone. This API key will be revoked and will
no longer work.
@@ -76,7 +73,8 @@ export const RevokeApiKeyModal = ({
</div>
{error && (
<Alert variant="destructive">
<Alert variant="error">
<AlertTriangle />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -15,6 +15,7 @@ const roleDetail = {
type: "roles",
attributes: {
name: "Cloud admin",
permission_state: "unlimited",
manage_users: false,
manage_account: false,
manage_providers: false,
@@ -52,4 +53,31 @@ describe("RoleItem", () => {
// Then
expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument();
});
it("displays the permission state as a badge", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
render(<RoleItem role={role} roleDetail={roleDetail} />);
// Then
expect(screen.getByText("unlimited")).toHaveClass("bg-bg-tag");
});
it("does not render the details toggle", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
render(<RoleItem role={role} roleDetail={roleDetail} />);
// Then
expect(
screen.queryByRole("button", { name: /hide details/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /show details/i }),
).not.toBeInTheDocument();
});
});
+20 -35
View File
@@ -1,10 +1,8 @@
"use client";
import { Chip } from "@heroui/chip";
import { Ban, Check } from "lucide-react";
import { useState } from "react";
import { Button, Card } from "@/components/shadcn";
import { Badge, Card } from "@/components/shadcn";
import { getRolePermissions } from "@/lib/permissions";
import { RoleData, RoleDetail } from "@/types/users";
@@ -35,13 +33,11 @@ export const RoleItem = ({
role: RoleData;
roleDetail?: RoleDetail;
}) => {
const [isExpanded, setIsExpanded] = useState(true);
if (!roleDetail) {
return (
<Chip key={role.id} size="sm" variant="flat" color="primary">
<Badge key={role.id} variant="info">
{role.id}
</Chip>
</Badge>
);
}
@@ -56,38 +52,27 @@ export const RoleItem = ({
<Card variant="inner">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Chip size="sm" variant="flat" color="primary">
{roleName}
</Chip>
<span className="text-xs text-gray-500 capitalize">
{permissionState}
</span>
<Badge variant="info">{roleName}</Badge>
{permissionState && (
<Badge variant="tag" className="capitalize">
{permissionState}
</Badge>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="px-0"
>
{isExpanded ? "Hide details" : "Show details"}
</Button>
</div>
{isExpanded && (
<div
id={detailsId}
className="animate-fadeIn border-border-neutral-primary border-t pt-4"
role="region"
aria-label={`Details for role ${roleName}`}
>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-2">
{permissions.map(({ key, label, enabled }) => (
<PermissionItem key={key} label={label} enabled={enabled} />
))}
</div>
<div
id={detailsId}
className="border-border-neutral-primary border-t pt-4"
role="region"
aria-label={`Details for role ${roleName}`}
>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-2">
{permissions.map(({ key, label, enabled }) => (
<PermissionItem key={key} label={label} enabled={enabled} />
))}
</div>
)}
</div>
</Card>
);
};
+7 -5
View File
@@ -1,4 +1,4 @@
import { Card, CardContent } from "@/components/shadcn";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { RoleData, RoleDetail } from "@/types/users";
import { RoleItem } from "./role-item";
@@ -11,14 +11,16 @@ export const RolesCard = ({
roleDetails: Record<string, RoleDetail>;
}) => {
return (
<Card variant="base" padding="none" className="p-4">
<CardContent>
<div className="mb-6 flex flex-col gap-1">
<h4 className="text-lg font-bold">Active roles</h4>
<Card variant="inner" padding="none" className="gap-4 p-4 md:p-5">
<CardHeader>
<div className="flex flex-col gap-1">
<CardTitle>Active roles</CardTitle>
<p className="text-xs text-gray-500">
Roles assigned to this user account
</p>
</div>
</CardHeader>
<CardContent>
{roles.length === 0 ? (
<div className="text-sm text-gray-500">No roles assigned.</div>
) : (
@@ -1,83 +1,152 @@
import { Card, CardBody } from "@heroui/card";
import { Skeleton } from "@heroui/skeleton";
import {
Card,
CardAction,
CardContent,
CardHeader,
Separator,
Skeleton,
} from "@/components/shadcn";
// Mirrors the real profile layout: one large card with sections stacked
// vertically. Conditional cards (SAML / API keys) depend on permissions not
// known while loading, so we mirror only the always-present structure.
export const SkeletonUserInfo = () => {
return (
<div className="flex flex-col gap-6">
{/* User Information */}
<Card>
<CardBody>
<div className="flex flex-col gap-3">
{/* Name */}
<div className="flex items-center justify-between">
<p className="text-default-600 text-sm font-semibold">Name:</p>
<Skeleton className="h-5 w-24 rounded-lg">
<div className="bg-default-200 h-5 w-24"></div>
</Skeleton>
</div>
{/* Email */}
<div className="flex items-center justify-between">
<p className="text-default-600 text-sm font-semibold">Email:</p>
<Skeleton className="h-5 w-32 rounded-lg">
<div className="bg-default-200 h-5 w-32"></div>
</Skeleton>
</div>
{/* Company */}
<div className="flex items-center justify-between">
<p className="text-default-600 text-sm font-semibold">Company:</p>
<Skeleton className="h-5 w-28 rounded-lg">
<div className="bg-default-200 h-5 w-28"></div>
</Skeleton>
</div>
{/* Date Joined */}
<div className="flex items-center justify-between">
<p className="text-default-600 text-sm font-semibold">
Date Joined:
</p>
<Skeleton className="h-5 w-36 rounded-lg">
<div className="bg-default-200 h-5 w-36"></div>
</Skeleton>
</div>
{/* Tenant ID */}
<div className="flex items-center justify-between">
<p className="text-default-600 text-sm font-semibold">
Tenant ID:
</p>
<Skeleton className="h-5 w-32 rounded-lg">
<div className="bg-default-200 h-5 w-32"></div>
</Skeleton>
</div>
</div>
</CardBody>
</Card>
<Card
variant="base"
padding="none"
role="region"
aria-label="User profile settings loading"
className="w-full gap-4 p-4 md:p-5"
>
<UserBasicInfoSkeleton />
<RolesCardSkeleton />
<OrganizationsCardSkeleton />
</Card>
);
};
{/* Roles */}
<Card>
<CardBody>
<h4 className="mb-3 text-sm font-semibold">Roles</h4>
<div className="flex flex-wrap gap-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-6 w-20 rounded-full">
<div className="bg-default-200 h-6 w-20 rounded-full"></div>
</Skeleton>
))}
const UserBasicInfoSkeleton = () => {
return (
<Card variant="inner" padding="none" className="p-4 md:p-5">
<CardContent>
{/* Avatar + name / email */}
<div className="flex items-center gap-4">
<Skeleton className="size-10 rounded-full" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3 w-48 rounded" />
</div>
</CardBody>
</Card>
</div>
<Separator className="my-4" />
{/* Date Joined + Organization ID */}
<div className="flex flex-row gap-4 md:gap-8">
<FieldSkeleton labelWidth="w-20" valueWidth="w-24" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton className="h-3 w-28 rounded" />
<Skeleton className="h-8 w-full max-w-xs rounded-md" />
</div>
</div>
</CardContent>
</Card>
);
};
{/* Memberships */}
<Card>
<CardBody>
<h4 className="mb-3 text-sm font-semibold">Memberships</h4>
<div className="flex flex-col gap-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-md">
<div className="bg-default-200 h-16 w-full rounded-md"></div>
</Skeleton>
))}
const RolesCardSkeleton = () => {
return (
<Card variant="inner" padding="none" className="gap-4 p-4 md:p-5">
<CardHeader>
<div className="flex flex-col gap-1.5">
<Skeleton className="h-6 w-28 rounded" />
<Skeleton className="h-3 w-48 rounded" />
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<RoleItemSkeleton />
<RoleItemSkeleton />
</div>
</CardContent>
</Card>
);
};
// Mirrors <RoleItem>: inner card with role badges and permission rows below.
const RoleItemSkeleton = () => {
return (
<Card variant="inner">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="border-border-neutral-primary mt-4 grid grid-cols-1 gap-3 border-t pt-4 md:grid-cols-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="h-3 w-24 rounded" />
</div>
</CardBody>
</Card>
))}
</div>
</Card>
);
};
const OrganizationsCardSkeleton = () => {
return (
<Card variant="inner" padding="none" className="gap-4 p-4 md:p-5">
<CardHeader>
<div className="flex flex-col gap-1.5">
<Skeleton className="h-5 w-32 rounded" />
<Skeleton className="h-3 w-52 rounded" />
</div>
<CardAction>
<Skeleton className="h-8 w-36 rounded-md" />
</CardAction>
</CardHeader>
<CardContent>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 rounded-[14px] border p-4 shadow-sm">
<div className="bg-bg-neutral-tertiary border-border-neutral-primary grid h-11 grid-cols-[88px_96px_minmax(0,1fr)_120px_48px] items-center gap-4 rounded-full border px-4">
<Skeleton className="h-3 w-10 rounded" />
<Skeleton className="h-3 w-12 rounded" />
<Skeleton className="h-3 w-12 rounded" />
<Skeleton className="h-3 w-16 rounded" />
<span className="sr-only">Actions</span>
</div>
{[0, 1].map((i) => (
<div
key={i}
className="grid h-12 grid-cols-[88px_96px_minmax(0,1fr)_120px_48px] items-center gap-4 px-4"
>
<Skeleton className="h-5 w-14 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-4 w-40 rounded" />
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="ml-auto size-7 rounded-full" />
</div>
))}
</div>
</CardContent>
</Card>
);
};
// Label + value pair. `inline` lays them side by side (info fields inside a
// row); otherwise stacked (a standalone field).
const FieldSkeleton = ({
labelWidth,
valueWidth,
inline = false,
}: {
labelWidth: string;
valueWidth: string;
inline?: boolean;
}) => {
return (
<div
className={inline ? "flex items-center gap-2" : "flex flex-col gap-1.5"}
>
<Skeleton className={`h-3 ${labelWidth} rounded`} />
<Skeleton className={`h-4 ${valueWidth} rounded`} />
</div>
);
};
@@ -1,12 +1,10 @@
"use client";
import { Divider } from "@heroui/divider";
import { ProwlerShort } from "@/components/icons";
import { Card, CardContent } from "@/components/shadcn";
import { Card, CardContent, Separator } from "@/components/shadcn";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { DateWithTime } from "@/components/shadcn/entities";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime } from "@/components/ui/entities";
import { UserDataWithRoles } from "@/types/users";
const TenantIdCopy = ({ id }: { id: string }) => {
@@ -27,7 +25,7 @@ export const UserBasicInfoCard = ({
const { name, email, company_name, date_joined } = user.attributes;
return (
<Card variant="base" padding="none" className="p-4">
<Card variant="inner" padding="none" className="p-4 md:p-5">
<CardContent>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full border-3 border-black p-1 dark:border-white">
@@ -41,7 +39,7 @@ export const UserBasicInfoCard = ({
</span>
</div>
</div>
<Divider className="my-4" />
<Separator className="my-4" />
<div className="flex flex-row gap-4 md:items-start md:justify-start md:gap-8">
<div className="flex gap-2 whitespace-nowrap md:flex-col md:items-start md:justify-start">
<div className="flex items-center gap-2">
+2 -2
View File
@@ -2,8 +2,8 @@
import { ColumnDef } from "@tanstack/react-table";
import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { DateWithTime } from "@/components/shadcn/entities";
import { DataTableColumnHeader } from "@/components/shadcn/table";
import { UserProps } from "@/types";
import { DataTableRowActions } from "./data-table-row-actions";
@@ -1,37 +1,102 @@
import React from "react";
import { Card } from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export const SkeletonTableUser = () => {
const SkeletonTableRow = () => {
return (
<Card variant="base" padding="md" className="flex flex-col gap-4">
{/* Table headers */}
<div className="hidden gap-4 md:flex">
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-1/12" />
</div>
{/* Table body */}
<div className="flex flex-col gap-3">
{[...Array(10)].map((_, index) => (
<div
key={index}
className="flex flex-col gap-4 md:flex-row md:items-center"
>
<Skeleton className="h-12 w-full md:w-2/12" />
<Skeleton className="h-12 w-full md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-1/12" />
</div>
))}
</div>
</Card>
<tr className="border-border-neutral-secondary border-b last:border-b-0">
{/* Name */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-28 rounded" />
</td>
{/* Email */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-32 rounded" />
</td>
{/* Role */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-20 rounded" />
</td>
{/* Company name */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-24 rounded" />
</td>
{/* Joined - date */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-24 rounded" />
</td>
{/* Actions */}
<td className="px-2 py-4">
<Skeleton className="size-6 rounded" />
</td>
</tr>
);
};
export const SkeletonTableUser = () => {
const rows = 10;
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden rounded-[14px] border p-4 shadow-sm">
{/* Toolbar: Search + Total entries */}
<div className="flex items-center justify-between">
{/* Search icon button */}
<Skeleton className="size-10 rounded-md" />
{/* Total entries */}
<Skeleton className="h-4 w-28 rounded" />
</div>
{/* Table */}
<table className="w-full">
<thead>
<tr className="border-border-neutral-secondary border-b">
{/* Name */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-12 rounded" />
</th>
{/* Email */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
{/* Role */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-10 rounded" />
</th>
{/* Company name */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-28 rounded" />
</th>
{/* Joined */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-16 rounded" />
</th>
{/* Actions - empty header */}
<th className="w-10 py-3" />
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<SkeletonTableRow key={i} />
))}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between pt-2">
{/* Rows per page */}
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-9 w-16 rounded-md" />
</div>
{/* Page info + navigation */}
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-24 rounded" />
<div className="flex gap-1">
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
</div>
</div>
</div>
</div>
);
};