import { ApiKey, PrincipalType, User, UserGroup } from "@vertesia/common"; import { Avatar, Popover, PopoverContent, PopoverTrigger, Table, useFetch } from "@vertesia/ui/core"; import { useUserSession } from "@vertesia/ui/session"; import { Users, Users2 } from "lucide-react"; import { ReactNode } from "react"; import { useUITranslation } from '../../i18n/index.js'; const USER_CACHE: Record> = {}; const GROUP_CACHE: Record> = {}; const APIKEY_CACHE: Record> = {}; function cachedFetch(cache: Record>, key: string, fetcher: () => Promise): Promise { let entry = cache[key]; if (!entry) { entry = fetcher().catch((err) => { delete cache[key]; // evict on failure so retries work throw err; }); cache[key] = entry; } return entry; } /** * Fetch the user information given a user reference. * The reference has the format: `type:id`. A special reference `system` is used to refer to the system user. * @param userRef */ export function useFetchUserInfo(userId: string) { const { client } = useUserSession(); return useFetch(() => cachedFetch(USER_CACHE, userId, () => client.users.retrieve(userId)), [userId]); } /** * Fetch the group information given a group ID. * @param groupId */ export function useFetchGroupInfo(groupId: string) { const { client } = useUserSession(); return useFetch(() => cachedFetch(GROUP_CACHE, groupId, () => client.iam.groups.retrieve(groupId)), [groupId]); } /** * Fetch the API key information given a key ID. * @param keyId */ export function useFetchApiKeyInfo(keyId: string) { const { client } = useUserSession(); return useFetch(() => cachedFetch(APIKEY_CACHE, keyId, () => client.apikeys.retrieve(keyId)), [keyId]); } function AvatarPlaceholder() { return
} interface InfoProps { showTitle?: boolean; size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; } function SystemAvatar({ showTitle = false, size = "md" }: InfoProps) { const { t } = useUITranslation(); return (
{showTitle &&
{t('user.systemUser')}
}
) } function ProjectMembersAvatar({ showTitle = false, size = "md" }: InfoProps) { const { t } = useUITranslation(); return (
{showTitle &&
{t('user.allProjectMembers')}
}
) } interface ServiceInfoProps extends InfoProps { accountId: string; } function ServiceAccountAvatar({ accountId, showTitle = false, size = "md" }: ServiceInfoProps) { const { t } = useUITranslation(); const _type = accountId.split(':')[0]; const _accountId = accountId.split(':')[1]; const description = ( <>
{t('user.serviceAccountDescription')}
Type: {_type}
ID: {_accountId}
) return (
{showTitle &&
{t('user.serviceAccount')} : ~{_accountId.slice(-6)}
}
); } interface EmailAgentAvatarProps extends InfoProps { email: string; } function EmailAgentAvatar({ email, showTitle = false, size = "md" }: EmailAgentAvatarProps) { const { t } = useUITranslation(); const description = ( <>
{t('user.serviceAccountDescription')}
Email: {email}
) return (
{showTitle && (
{t('user.agentOnBehalfOf')} : {email}
)}
); } interface AgentAvatarProps extends InfoProps { agentId: string; // Format: accountId:projectId:timestamp onBehalfOfType?: string; onBehalfOfId?: string; isScheduleAgent?: boolean; } function AgentAvatar({ agentId, onBehalfOfType, onBehalfOfId, showTitle = false, size = "md", isScheduleAgent = false }: AgentAvatarProps) { const { t } = useUITranslation(); // Fetch user info - must call hooks unconditionally per React rules const shouldFetchUser = onBehalfOfType === 'user' && onBehalfOfId; const shouldFetchApiKey = onBehalfOfType === 'apikey' && onBehalfOfId; const userResult = useFetchUserInfo(onBehalfOfId || ''); const apiKeyResult = useFetchApiKeyInfo(onBehalfOfId || ''); // Only use the data if we should fetch it const user = shouldFetchUser ? userResult.data : undefined; const apiKey = shouldFetchApiKey ? apiKeyResult.data : undefined; // Determine title and description const shortenedAgentId = agentId.slice(-6); const title = user ? t('user.agentOnBehalfOf') : apiKey ? t('user.agentOnBehalfOfApiKey') : t('user.serviceAccount') + `~${shortenedAgentId}`; const _title = isScheduleAgent ? t('user.schedule', { title }) : title; const description = (
{user && ( <>
{user.name || user.email}
{user.email && user.name &&
{user.email}
}
)} {apiKey && ( <>
{apiKey.name}
Key ID: {apiKey.id}
)} {!user && !apiKey && ( <>
{t('user.serviceAccountDescription')}
ID: {agentId}
)}
); return (
{user && ( )} {apiKey && ( )}
{showTitle && (
{user ? `Agent (${user.name || user.email})` : apiKey ? `Agent (${apiKey.name})` : title}
)}
); } interface ErrorInfoProps extends InfoProps { title?: string; error: Error | string; } function ErrorAvatar({ title = "Error", error, showTitle = false, size = "md" }: ErrorInfoProps) { return ( ); } interface UserInfoProps extends InfoProps { /** * The user reference is a string that contains the type and id of the user. * * The format is: `type:id`, where "type" is the {@link PrincipalType} and the "id" is the * object ID in the database. * * @example user:123 * @example service_account:123 * @example apikey:123 * @example project:* (all project members — synthetic principal used in ACLs) */ userRef: string | undefined; } export function UserInfo({ userRef, showTitle = false, size = "md" }: UserInfoProps) { const { t } = useUITranslation(); if (!userRef) { return } const parts = userRef.split(':'); const type = parts[0]; switch (type) { case PrincipalType.User: return case PrincipalType.Group: return case "system": return case PrincipalType.ServiceAccount: return case PrincipalType.Agent: { // Parse agent format: agent:accountId:projectId:timestamp[:on_behalf_type:on_behalf_id] // Handle legacy formats: // - agent:agent:accountId:projectId:timestamp (old bug) // - agent:accountId:projectId:timestamp (fixed, no on_behalf_of) // - agent:accountId:projectId:timestamp:user:userId (new format) let agentId: string; let onBehalfOfType: string | undefined; let onBehalfOfId: string | undefined; // Check for old buggy format with double "agent" if (parts[1] === 'agent') { // Old format: agent:agent:accountId:projectId:timestamp agentId = parts.slice(2, 5).join(':'); // Check if there's on_behalf_of info (unlikely but handle it) onBehalfOfType = parts[5]; onBehalfOfId = parts[6]; } else { // New format: agent:accountId:projectId:timestamp[:type:id] agentId = parts.slice(1, 4).join(':'); onBehalfOfType = parts[4]; onBehalfOfId = parts[5]; } return } case PrincipalType.Schedule: { // format: schedule:{scheduleID}:agent:{accountID}:{projectID}:{agentID}:user:{userId} const agentId = parts[2] === 'agent' ? parts.slice(3, 6).join(':') : 'unknown-agent'; const onBehalfOfType = parts[6]; // e.g. "user" const onBehalfOfId = parts[7]; // e.g. userId return } case "email": { // format: email:userEmail return } case PrincipalType.ApiKey: return case "project": // Only the wildcard "project:*" is a valid synthetic principal (all project members). // Any other project:<...> shape falls through to the error default. if (parts[1] === "*") { return } break; default: return } } interface UnknownAvatarProps extends InfoProps { title: string; message: ReactNode; color?: string; } function UnknownAvatar({ title, message, color, size = "md", showTitle = false }: UnknownAvatarProps) { return (
{showTitle &&
{title}
}
) } interface GroupAvatarProps extends InfoProps { userId: string; } function GroupAvatar({ userId, showTitle = false, size = "md" }: GroupAvatarProps) { const { t } = useUITranslation(); const { data: group, error } = useFetchGroupInfo(userId); if (error) { return } if (!group) { return } const description = (
{group.description &&
{group.description}
}
{t('user.groupId', { id: group.id })}
{group.tags && group.tags.length > 0 && (
{group.tags.map(tag => ( {tag} ))}
)}
) return (
{showTitle &&
{group.name || t('user.unnamedGroup')}
}
) } interface UserAvatarProps extends InfoProps { userId: string; } function UserAvatar({ userId, showTitle = false, size = "md" }: UserAvatarProps) { const { t } = useUITranslation(); const { data: user, error } = useFetchUserInfo(userId); if (error) { return } if (!user) { return } const description = (
{user.email}
) return (
{showTitle &&
{user.name || user.email || user.username || t('user.unknown')}
}
) } interface ApiKeyAvatarProps extends InfoProps { keyId: string; } export function ApiKeyAvatar({ keyId, showTitle = false, size = "md" }: ApiKeyAvatarProps) { const { t } = useUITranslation(); const { data, error } = useFetchApiKeyInfo(keyId); if (error) { return } if (!data) { return } const title = t('user.privateKey'); const avatar = ; const description = (
{t('user.key')} {data?.name}
{t('user.account')} {data?.account}
{t('user.project')} {data?.project.name}
); return (
{avatar} {showTitle &&
{data?.name || data?.account || data?.project.name || t('user.unknown')}
}
) } interface UserPopoverPanelProps { title: string; description: ReactNode; children: React.ReactNode; } function UserPopoverPanel({ title, description, children }: UserPopoverPanelProps) { return (
{children}
{title}
{description}
) }