feat(ui): show sign-in methods in the Cloud users table (#12268)

This commit is contained in:
Josema Camacho
2026-08-05 14:41:59 +02:00
committed by GitHub
parent dd61c417b7
commit d7816f1179
7 changed files with 308 additions and 3 deletions
@@ -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();
},
);
});
+65 -1
View File
@@ -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>