mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 18:54:30 +00:00
Users page - new user info pane (#10213)
* feat(user_info_view.tsx): be able to click in and see all teams user is part of makes it easy to see which teams a user belongs to * test(ui/): add unit testing for user info view * fix(user_info_view.tsx): fix linting errors * fix(login.ts): fix login * fix: fix linting error
This commit is contained in:
parent
b0a597c8c6
commit
47765404a8
6 changed files with 442 additions and 2 deletions
|
@ -0,0 +1,82 @@
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginToUI } from "../utils/login";
|
||||||
|
|
||||||
|
test.describe("User Info View", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginToUI(page);
|
||||||
|
// Navigate to users page
|
||||||
|
await page.goto("http://localhost:4000/ui?page=users");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should display user info when clicking on user ID", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Wait for users table to load
|
||||||
|
await page.waitForSelector("table");
|
||||||
|
|
||||||
|
// Get the first user ID cell
|
||||||
|
const firstUserIdCell = page.locator(
|
||||||
|
"table tbody tr:first-child td:first-child"
|
||||||
|
);
|
||||||
|
const userId = await firstUserIdCell.textContent();
|
||||||
|
console.log("Found user ID:", userId);
|
||||||
|
|
||||||
|
// Click on the user ID
|
||||||
|
await firstUserIdCell.click();
|
||||||
|
|
||||||
|
// Wait for user info view to load
|
||||||
|
await page.waitForSelector('h1:has-text("User")');
|
||||||
|
console.log("User info view loaded");
|
||||||
|
|
||||||
|
// Check for tabs
|
||||||
|
await expect(page.locator('button:has-text("Overview")')).toBeVisible();
|
||||||
|
await expect(page.locator('button:has-text("Details")')).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to details tab
|
||||||
|
await page.locator('button:has-text("Details")').click();
|
||||||
|
|
||||||
|
// Check details section
|
||||||
|
await expect(page.locator("text=User ID")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Email")).toBeVisible();
|
||||||
|
|
||||||
|
// Go back to users list
|
||||||
|
await page.locator('button:has-text("Back to Users")').click();
|
||||||
|
|
||||||
|
// Verify we're back on the users page
|
||||||
|
await expect(page.locator('h1:has-text("Users")')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// test("should handle user deletion", async ({ page }) => {
|
||||||
|
// // Wait for users table to load
|
||||||
|
// await page.waitForSelector("table");
|
||||||
|
|
||||||
|
// // Get the first user ID cell
|
||||||
|
// const firstUserIdCell = page.locator(
|
||||||
|
// "table tbody tr:first-child td:first-child"
|
||||||
|
// );
|
||||||
|
// const userId = await firstUserIdCell.textContent();
|
||||||
|
|
||||||
|
// // Click on the user ID
|
||||||
|
// await firstUserIdCell.click();
|
||||||
|
|
||||||
|
// // Wait for user info view to load
|
||||||
|
// await page.waitForSelector('h1:has-text("User")');
|
||||||
|
|
||||||
|
// // Click delete button
|
||||||
|
// await page.locator('button:has-text("Delete User")').click();
|
||||||
|
|
||||||
|
// // Confirm deletion in modal
|
||||||
|
// await page.locator('button:has-text("Delete")').click();
|
||||||
|
|
||||||
|
// // Verify success message
|
||||||
|
// await expect(page.locator("text=User deleted successfully")).toBeVisible();
|
||||||
|
|
||||||
|
// // Verify we're back on the users page
|
||||||
|
// await expect(page.locator('h1:has-text("Users")')).toBeVisible();
|
||||||
|
|
||||||
|
// // Verify user is no longer in the table
|
||||||
|
// if (userId) {
|
||||||
|
// await expect(page.locator(`text=${userId}`)).not.toBeVisible();
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
});
|
23
tests/proxy_admin_ui_tests/utils/login.ts
Normal file
23
tests/proxy_admin_ui_tests/utils/login.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { Page, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function loginToUI(page: Page) {
|
||||||
|
// Login first
|
||||||
|
await page.goto("http://localhost:4000/ui");
|
||||||
|
console.log("Navigated to login page");
|
||||||
|
|
||||||
|
// Wait for login form to be visible
|
||||||
|
await page.waitForSelector('input[name="username"]', { timeout: 10000 });
|
||||||
|
console.log("Login form is visible");
|
||||||
|
|
||||||
|
await page.fill('input[name="username"]', "admin");
|
||||||
|
await page.fill('input[name="password"]', "gm");
|
||||||
|
console.log("Filled login credentials");
|
||||||
|
|
||||||
|
const loginButton = page.locator('input[type="submit"]');
|
||||||
|
await expect(loginButton).toBeEnabled();
|
||||||
|
await loginButton.click();
|
||||||
|
console.log("Clicked login button");
|
||||||
|
|
||||||
|
// Wait for navigation to complete
|
||||||
|
await page.waitForURL("**/*");
|
||||||
|
}
|
|
@ -766,8 +766,10 @@ export const userInfoCall = async (
|
||||||
userRole: String,
|
userRole: String,
|
||||||
viewAll: Boolean = false,
|
viewAll: Boolean = false,
|
||||||
page: number | null,
|
page: number | null,
|
||||||
page_size: number | null
|
page_size: number | null,
|
||||||
|
lookup_user_id: boolean = false
|
||||||
) => {
|
) => {
|
||||||
|
console.log(`userInfoCall: ${userID}, ${userRole}, ${viewAll}, ${page}, ${page_size}, ${lookup_user_id}`)
|
||||||
try {
|
try {
|
||||||
let url: string;
|
let url: string;
|
||||||
|
|
||||||
|
@ -781,7 +783,7 @@ export const userInfoCall = async (
|
||||||
} else {
|
} else {
|
||||||
// Use /user/info endpoint for individual user info
|
// Use /user/info endpoint for individual user info
|
||||||
url = proxyBaseUrl ? `${proxyBaseUrl}/user/info` : `/user/info`;
|
url = proxyBaseUrl ? `${proxyBaseUrl}/user/info` : `/user/info`;
|
||||||
if (userRole === "Admin" || userRole === "Admin Viewer") {
|
if ((userRole === "Admin" || userRole === "Admin Viewer") && !lookup_user_id) {
|
||||||
// do nothing
|
// do nothing
|
||||||
} else if (userID) {
|
} else if (userID) {
|
||||||
url += `?user_id=${userID}`;
|
url += `?user_id=${userID}`;
|
||||||
|
|
|
@ -644,6 +644,8 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
data={userListResponse?.users || []}
|
data={userListResponse?.users || []}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
isLoading={!userListResponse}
|
isLoading={!userListResponse}
|
||||||
|
accessToken={accessToken}
|
||||||
|
userRole={userRole}
|
||||||
onSortChange={handleSortChange}
|
onSortChange={handleSortChange}
|
||||||
currentSort={{
|
currentSort={{
|
||||||
sortBy: filters.sort_by,
|
sortBy: filters.sort_by,
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from "@tremor/react";
|
} from "@tremor/react";
|
||||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||||
import { UserInfo } from "./types";
|
import { UserInfo } from "./types";
|
||||||
|
import UserInfoView from "./user_info_view";
|
||||||
|
|
||||||
interface UserDataTableProps {
|
interface UserDataTableProps {
|
||||||
data: UserInfo[];
|
data: UserInfo[];
|
||||||
|
@ -28,6 +29,8 @@ interface UserDataTableProps {
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
|
accessToken: string | null;
|
||||||
|
userRole: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserDataTable({
|
export function UserDataTable({
|
||||||
|
@ -36,6 +39,8 @@ export function UserDataTable({
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
currentSort,
|
currentSort,
|
||||||
|
accessToken,
|
||||||
|
userRole,
|
||||||
}: UserDataTableProps) {
|
}: UserDataTableProps) {
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([
|
const [sorting, setSorting] = React.useState<SortingState>([
|
||||||
{
|
{
|
||||||
|
@ -43,6 +48,7 @@ export function UserDataTable({
|
||||||
desc: currentSort?.sortOrder === "desc"
|
desc: currentSort?.sortOrder === "desc"
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
const [selectedUserId, setSelectedUserId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
|
@ -64,6 +70,14 @@ export function UserDataTable({
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleUserClick = (userId: string) => {
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseUserInfo = () => {
|
||||||
|
setSelectedUserId(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Update local sorting state when currentSort prop changes
|
// Update local sorting state when currentSort prop changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentSort) {
|
if (currentSort) {
|
||||||
|
@ -74,6 +88,17 @@ export function UserDataTable({
|
||||||
}
|
}
|
||||||
}, [currentSort]);
|
}, [currentSort]);
|
||||||
|
|
||||||
|
if (selectedUserId) {
|
||||||
|
return (
|
||||||
|
<UserInfoView
|
||||||
|
userId={selectedUserId}
|
||||||
|
onClose={handleCloseUserInfo}
|
||||||
|
accessToken={accessToken}
|
||||||
|
userRole={userRole}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg custom-border relative">
|
<div className="rounded-lg custom-border relative">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
@ -138,6 +163,15 @@ export function UserDataTable({
|
||||||
? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]'
|
? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.column.id === 'user_id') {
|
||||||
|
handleUserClick(cell.getValue() as string);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: cell.column.id === 'user_id' ? 'pointer' : 'default',
|
||||||
|
color: cell.column.id === 'user_id' ? '#3b82f6' : 'inherit',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
@ -0,0 +1,297 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Col,
|
||||||
|
Tab,
|
||||||
|
TabList,
|
||||||
|
TabGroup,
|
||||||
|
TabPanel,
|
||||||
|
TabPanels,
|
||||||
|
Title,
|
||||||
|
Badge,
|
||||||
|
} from "@tremor/react";
|
||||||
|
import { ArrowLeftIcon, TrashIcon } from "@heroicons/react/outline";
|
||||||
|
import { userInfoCall, userDeleteCall } from "../networking";
|
||||||
|
import { message } from "antd";
|
||||||
|
import { rolesWithWriteAccess } from '../../utils/roles';
|
||||||
|
|
||||||
|
interface UserInfoViewProps {
|
||||||
|
userId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
accessToken: string | null;
|
||||||
|
userRole: string | null;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
user_id: string;
|
||||||
|
user_info: {
|
||||||
|
user_email: string | null;
|
||||||
|
user_role: string | null;
|
||||||
|
teams: any[] | null;
|
||||||
|
models: string[] | null;
|
||||||
|
max_budget: number | null;
|
||||||
|
spend: number | null;
|
||||||
|
metadata: Record<string, any> | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
keys: any[] | null;
|
||||||
|
teams: any[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserInfoView({ userId, onClose, accessToken, userRole, onDelete }: UserInfoViewProps) {
|
||||||
|
const [userData, setUserData] = useState<UserInfo | null>(null);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log(`userId: ${userId}, userRole: ${userRole}, accessToken: ${accessToken}`)
|
||||||
|
const fetchUserData = async () => {
|
||||||
|
try {
|
||||||
|
if (!accessToken) return;
|
||||||
|
const data = await userInfoCall(accessToken, userId, userRole || "", false, null, null, true);
|
||||||
|
setUserData(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user data:", error);
|
||||||
|
message.error("Failed to fetch user data");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserData();
|
||||||
|
}, [accessToken, userId, userRole]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
if (!accessToken) return;
|
||||||
|
await userDeleteCall(accessToken, [userId]);
|
||||||
|
message.success("User deleted successfully");
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting user:", error);
|
||||||
|
message.error("Failed to delete user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Button
|
||||||
|
icon={ArrowLeftIcon}
|
||||||
|
variant="light"
|
||||||
|
onClick={onClose}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
Back to Users
|
||||||
|
</Button>
|
||||||
|
<Text>Loading user data...</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Button
|
||||||
|
icon={ArrowLeftIcon}
|
||||||
|
variant="light"
|
||||||
|
onClick={onClose}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
Back to Users
|
||||||
|
</Button>
|
||||||
|
<Text>User not found</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
icon={ArrowLeftIcon}
|
||||||
|
variant="light"
|
||||||
|
onClick={onClose}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
Back to Users
|
||||||
|
</Button>
|
||||||
|
<Title>{userData.user_info?.user_email || "User"}</Title>
|
||||||
|
<Text className="text-gray-500 font-mono">{userData.user_id}</Text>
|
||||||
|
</div>
|
||||||
|
{userRole && rolesWithWriteAccess.includes(userRole) && (
|
||||||
|
<Button
|
||||||
|
icon={TrashIcon}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{isDeleteModalOpen && (
|
||||||
|
<div className="fixed z-10 inset-0 overflow-y-auto">
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||||
|
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<div className="sm:flex sm:items-start">
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Delete User
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Are you sure you want to delete this user?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
color="red"
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsDeleteModalOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TabGroup>
|
||||||
|
<TabList className="mb-4">
|
||||||
|
<Tab>Overview</Tab>
|
||||||
|
<Tab>Details</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanels>
|
||||||
|
{/* Overview Panel */}
|
||||||
|
<TabPanel>
|
||||||
|
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
|
||||||
|
<Card>
|
||||||
|
<Text>Spend</Text>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Title>${Number(userData.user_info?.spend || 0).toFixed(4)}</Title>
|
||||||
|
<Text>of {userData.user_info?.max_budget !== null ? `$${userData.user_info.max_budget}` : "Unlimited"}</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Text>Teams</Text>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Text>{userData.teams?.length || 0} teams</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Text>API Keys</Text>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Text>{userData.keys?.length || 0} keys</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Details Panel */}
|
||||||
|
<TabPanel>
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">User ID</Text>
|
||||||
|
<Text className="font-mono">{userData.user_id}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Email</Text>
|
||||||
|
<Text>{userData.user_info?.user_email || "Not Set"}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Role</Text>
|
||||||
|
<Text>{userData.user_info?.user_role || "Not Set"}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Created</Text>
|
||||||
|
<Text>{userData.user_info?.created_at ? new Date(userData.user_info.created_at).toLocaleString() : "Unknown"}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Last Updated</Text>
|
||||||
|
<Text>{userData.user_info?.updated_at ? new Date(userData.user_info.updated_at).toLocaleString() : "Unknown"}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Teams</Text>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{userData.teams?.length && userData.teams?.length > 0 ? (
|
||||||
|
userData.teams?.map((team, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-2 py-1 bg-blue-100 rounded text-xs"
|
||||||
|
>
|
||||||
|
{team.team_alias || team.team_id}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text>No teams</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">API Keys</Text>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{userData.keys?.length && userData.keys?.length > 0 ? (
|
||||||
|
userData.keys.map((key, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-2 py-1 bg-green-100 rounded text-xs"
|
||||||
|
>
|
||||||
|
{key.key_alias || key.token}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text>No API keys</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Metadata</Text>
|
||||||
|
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">
|
||||||
|
{JSON.stringify(userData.user_info?.metadata || {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</TabGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue