mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(ui): migrate from HeroUI to shadcn/ui (#11532)
This commit is contained in:
@@ -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 = ({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user