mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat(ui): show sign-in methods in the Cloud users table (#12268)
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import type { CellContext } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
|
||||
import type { UserProps } from "@/types";
|
||||
import type { UserSignInMethod } from "@/types/users";
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Badge: ({ children, variant }: { children: ReactNode; variant?: string }) => (
|
||||
<span data-variant={variant}>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/entities", () => ({
|
||||
DateWithTime: () => <time />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/table", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("./data-table-row-actions", () => ({
|
||||
DataTableRowActions: () => null,
|
||||
}));
|
||||
|
||||
import { getColumnsUser } from "./column-users";
|
||||
|
||||
const getColumnId = (column: ReturnType<typeof getColumnsUser>[number]) => {
|
||||
if ("accessorKey" in column) {
|
||||
return column.accessorKey;
|
||||
}
|
||||
|
||||
return column.id;
|
||||
};
|
||||
|
||||
const renderSignInMethodsCell = (signInMethods?: UserSignInMethod[]) => {
|
||||
const signInMethodsColumn = getColumnsUser(true).find(
|
||||
(column) => getColumnId(column) === "sign_in_methods",
|
||||
);
|
||||
|
||||
if (typeof signInMethodsColumn?.cell !== "function") {
|
||||
throw new Error("Sign-in methods cell is not configured");
|
||||
}
|
||||
|
||||
const user = {
|
||||
attributes: {
|
||||
sign_in_methods: signInMethods,
|
||||
},
|
||||
} as unknown as UserProps;
|
||||
|
||||
return render(
|
||||
signInMethodsColumn.cell({
|
||||
row: { original: user },
|
||||
} as CellContext<UserProps, unknown>) as ReactElement,
|
||||
);
|
||||
};
|
||||
|
||||
describe("getColumnsUser", () => {
|
||||
it("should only allow domains on SAML sign-in methods", () => {
|
||||
// Given
|
||||
type NonSamlMethodWithDomain = {
|
||||
method: "google";
|
||||
domain: string;
|
||||
};
|
||||
type SamlMethodWithoutDomain = {
|
||||
method: "saml";
|
||||
};
|
||||
|
||||
// When
|
||||
const nonSamlMethod = expectTypeOf<NonSamlMethodWithDomain>();
|
||||
const samlMethod = expectTypeOf<SamlMethodWithoutDomain>();
|
||||
|
||||
// Then
|
||||
nonSamlMethod.not.toExtend<UserSignInMethod>();
|
||||
samlMethod.toExtend<UserSignInMethod>();
|
||||
});
|
||||
|
||||
it("should include non-sortable sign-in methods only in Cloud", () => {
|
||||
// Given
|
||||
const signInMethodsColumnId = "sign_in_methods";
|
||||
|
||||
// When
|
||||
const cloudColumns = getColumnsUser(true);
|
||||
const ossColumns = getColumnsUser(false);
|
||||
|
||||
// Then
|
||||
expect(cloudColumns.map(getColumnId)).toContain(signInMethodsColumnId);
|
||||
expect(ossColumns.map(getColumnId)).not.toContain(signInMethodsColumnId);
|
||||
|
||||
const signInMethodsColumn = cloudColumns.find(
|
||||
(column) => getColumnId(column) === signInMethodsColumnId,
|
||||
);
|
||||
expect(signInMethodsColumn?.enableSorting).toBe(false);
|
||||
expect(signInMethodsColumn?.enableColumnFilter).toBe(false);
|
||||
});
|
||||
|
||||
it("should render every sign-in method in API order", () => {
|
||||
// Given
|
||||
const signInMethods: UserSignInMethod[] = [
|
||||
{ method: "email_password" },
|
||||
{ method: "google" },
|
||||
{ method: "github" },
|
||||
{ method: "saml" },
|
||||
{ method: "saml", domain: "acme.com" },
|
||||
{ method: "saml", domain: "subsidiary.com" },
|
||||
{ method: "partner_sso" },
|
||||
];
|
||||
|
||||
// When
|
||||
renderSignInMethodsCell(signInMethods);
|
||||
|
||||
// Then
|
||||
const renderedMethods = screen.getAllByRole("listitem");
|
||||
expect(renderedMethods.map((item) => item.textContent)).toEqual([
|
||||
"Email/password",
|
||||
"Google",
|
||||
"GitHub",
|
||||
"SAML",
|
||||
"SAML (acme.com)",
|
||||
"SAML (subsidiary.com)",
|
||||
"Partner SSO",
|
||||
]);
|
||||
expect(
|
||||
renderedMethods.map(
|
||||
(item) => item.querySelector('[data-variant="tag"]') !== null,
|
||||
),
|
||||
).toEqual(Array.from({ length: signInMethods.length }, () => true));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", undefined],
|
||||
["empty", []],
|
||||
] as const)(
|
||||
"should render a hyphen when sign-in methods are %s",
|
||||
(_state, signInMethods) => {
|
||||
// Given
|
||||
const methods = signInMethods as UserSignInMethod[] | undefined;
|
||||
|
||||
// When
|
||||
renderSignInMethodsCell(methods);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("-")).toBeVisible();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2,9 +2,16 @@
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { DateWithTime } from "@/components/shadcn/entities";
|
||||
import { DataTableColumnHeader } from "@/components/shadcn/table";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { UserProps } from "@/types";
|
||||
import {
|
||||
USER_SIGN_IN_METHOD,
|
||||
type UserSignInMethod,
|
||||
type UserSignInMethodType,
|
||||
} from "@/types/users";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
|
||||
@@ -12,7 +19,29 @@ const getUserData = (row: { original: UserProps }) => {
|
||||
return row.original.attributes;
|
||||
};
|
||||
|
||||
export const ColumnsUser: ColumnDef<UserProps>[] = [
|
||||
type NonSamlSignInMethodType = Exclude<
|
||||
UserSignInMethodType,
|
||||
typeof USER_SIGN_IN_METHOD.SAML
|
||||
>;
|
||||
|
||||
const SIGN_IN_METHOD_LABELS = {
|
||||
[USER_SIGN_IN_METHOD.EMAIL_PASSWORD]: "Email/password",
|
||||
[USER_SIGN_IN_METHOD.GOOGLE]: "Google",
|
||||
[USER_SIGN_IN_METHOD.GITHUB]: "GitHub",
|
||||
[USER_SIGN_IN_METHOD.PARTNER_SSO]: "Partner SSO",
|
||||
} as const satisfies Record<NonSamlSignInMethodType, string>;
|
||||
|
||||
const getSignInMethodLabel = (signInMethod: UserSignInMethod) => {
|
||||
if (signInMethod.method === USER_SIGN_IN_METHOD.SAML) {
|
||||
return signInMethod.domain ? `SAML (${signInMethod.domain})` : "SAML";
|
||||
}
|
||||
|
||||
return SIGN_IN_METHOD_LABELS[signInMethod.method];
|
||||
};
|
||||
|
||||
export const getColumnsUser = (
|
||||
isCloudEnvironment: boolean,
|
||||
): ColumnDef<UserProps>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
@@ -44,6 +73,39 @@ export const ColumnsUser: ColumnDef<UserProps>[] = [
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
...(isCloudEnvironment
|
||||
? [
|
||||
{
|
||||
id: "sign_in_methods",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Sign-In Methods" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { sign_in_methods: signInMethods } = getUserData(row);
|
||||
|
||||
if (!signInMethods?.length) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul aria-label="Sign-in methods" className="flex flex-wrap gap-1">
|
||||
{signInMethods.map((signInMethod) => (
|
||||
<li
|
||||
key={`${signInMethod.method}:${signInMethod.domain ?? ""}`}
|
||||
>
|
||||
<Badge variant="tag">
|
||||
{getSignInMethodLabel(signInMethod)}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableColumnFilter: false,
|
||||
} satisfies ColumnDef<UserProps>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: "company_name",
|
||||
header: ({ column }) => (
|
||||
@@ -84,3 +146,5 @@ export const ColumnsUser: ColumnDef<UserProps>[] = [
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const ColumnsUser = getColumnsUser(isCloud());
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { isCloudMock } = vi.hoisted(() => ({
|
||||
isCloudMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
Skeleton: () => <span />,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
import { SkeletonTableUser } from "./skeleton-table-user";
|
||||
|
||||
const getColumnCounts = (container: HTMLElement) => ({
|
||||
header: container.querySelectorAll("thead th").length,
|
||||
row: container.querySelector("tbody tr")?.querySelectorAll("td").length,
|
||||
});
|
||||
|
||||
describe("SkeletonTableUser", () => {
|
||||
it("should render seven columns in Cloud", () => {
|
||||
// Given
|
||||
isCloudMock.mockReturnValue(true);
|
||||
|
||||
// When
|
||||
const { container } = render(<SkeletonTableUser />);
|
||||
|
||||
// Then
|
||||
expect(getColumnCounts(container)).toEqual({ header: 7, row: 7 });
|
||||
});
|
||||
|
||||
it("should render six columns in OSS", () => {
|
||||
// Given
|
||||
isCloudMock.mockReturnValue(false);
|
||||
|
||||
// When
|
||||
const { container } = render(<SkeletonTableUser />);
|
||||
|
||||
// Then
|
||||
expect(getColumnCounts(container)).toEqual({ header: 6, row: 6 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
const SkeletonTableRow = () => {
|
||||
interface SkeletonTableRowProps {
|
||||
isCloudEnvironment: boolean;
|
||||
}
|
||||
|
||||
const SkeletonTableRow = ({ isCloudEnvironment }: SkeletonTableRowProps) => {
|
||||
return (
|
||||
<tr className="border-border-neutral-secondary border-b last:border-b-0">
|
||||
{/* Name */}
|
||||
@@ -15,6 +20,11 @@ const SkeletonTableRow = () => {
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
</td>
|
||||
{isCloudEnvironment && (
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-5 w-28 rounded-full" />
|
||||
</td>
|
||||
)}
|
||||
{/* Company name */}
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
@@ -33,6 +43,7 @@ const SkeletonTableRow = () => {
|
||||
|
||||
export const SkeletonTableUser = () => {
|
||||
const rows = 10;
|
||||
const isCloudEnvironment = isCloud();
|
||||
|
||||
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">
|
||||
@@ -60,6 +71,11 @@ export const SkeletonTableUser = () => {
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-10 rounded" />
|
||||
</th>
|
||||
{isCloudEnvironment && (
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
</th>
|
||||
)}
|
||||
{/* Company name */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
@@ -74,7 +90,7 @@ export const SkeletonTableUser = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<SkeletonTableRow key={i} />
|
||||
<SkeletonTableRow key={i} isCloudEnvironment={isCloudEnvironment} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user