show fingerprint popup and fix policy check errors

This commit is contained in:
miloschwartz
2026-01-18 11:55:24 -08:00
parent 34e2fbefb9
commit 6a45151741
6 changed files with 344 additions and 150 deletions

View File

@@ -143,7 +143,14 @@ function queryClients(
olmArchived: olms.archived, olmArchived: olms.archived,
archived: clients.archived, archived: clients.archived,
blocked: clients.blocked, blocked: clients.blocked,
deviceModel: fingerprints.deviceModel deviceModel: fingerprints.deviceModel,
fingerprintPlatform: fingerprints.platform,
fingerprintOsVersion: fingerprints.osVersion,
fingerprintKernelVersion: fingerprints.kernelVersion,
fingerprintArch: fingerprints.arch,
fingerprintSerialNumber: fingerprints.serialNumber,
fingerprintUsername: fingerprints.username,
fingerprintHostname: fingerprints.hostname
}) })
.from(clients) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))

View File

@@ -115,6 +115,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sessionId // this is the user token passed in the message sessionId // this is the user token passed in the message
}); });
logger.debug("Policy check result:", policyCheck);
if (policyCheck?.error) { if (policyCheck?.error) {
logger.error( logger.error(
`Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}` `Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`
@@ -123,7 +125,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
if (policyCheck?.policies?.passwordAge?.compliant) { if (
policyCheck?.policies?.passwordAge &&
!policyCheck.policies.passwordAge.compliant
) {
logger.warn( logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}` `Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
); );
@@ -132,7 +137,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olm.olmId olm.olmId
); );
return; return;
} else if (policyCheck?.policies?.maxSessionLength?.compliant) { } else if (
policyCheck?.policies?.maxSessionLength &&
!policyCheck.policies.maxSessionLength.compliant
) {
logger.warn( logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}` `Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
); );
@@ -141,7 +149,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olm.olmId olm.olmId
); );
return; return;
} else if (policyCheck?.policies?.requiredTwoFactor) { } else if (
policyCheck?.policies &&
!policyCheck.policies.requiredTwoFactor
) {
logger.warn( logger.warn(
`Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}` `Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`
); );

View File

@@ -154,7 +154,11 @@ export default function GeneralPage() {
// Fetch approval ID for this client if pending // Fetch approval ID for this client if pending
useEffect(() => { useEffect(() => {
if (showApprovalFeatures && client.approvalState === "pending" && client.clientId) { if (
showApprovalFeatures &&
client.approvalState === "pending" &&
client.clientId
) {
api.get(`/org/${orgId}/approvals?approvalState=pending`) api.get(`/org/${orgId}/approvals?approvalState=pending`)
.then((res) => { .then((res) => {
const approval = res.data.data.approvals.find( const approval = res.data.data.approvals.find(
@@ -168,7 +172,13 @@ export default function GeneralPage() {
// Silently fail - approval might not exist // Silently fail - approval might not exist
}); });
} }
}, [showApprovalFeatures, client.approvalState, client.clientId, orgId, api]); }, [
showApprovalFeatures,
client.approvalState,
client.clientId,
orgId,
api
]);
const handleApprove = async () => { const handleApprove = async () => {
if (!approvalId) return; if (!approvalId) return;
@@ -280,7 +290,6 @@ export default function GeneralPage() {
} }
}; };
return ( return (
<SettingsContainer> <SettingsContainer>
{/* Pending Approval Banner */} {/* Pending Approval Banner */}
@@ -296,6 +305,7 @@ export default function GeneralPage() {
onClick={handleApprove} onClick={handleApprove}
disabled={isRefreshing || !approvalId} disabled={isRefreshing || !approvalId}
loading={isRefreshing} loading={isRefreshing}
variant="outline"
className="gap-2" className="gap-2"
> >
<Check className="size-4" /> <Check className="size-4" />
@@ -305,7 +315,7 @@ export default function GeneralPage() {
onClick={handleDeny} onClick={handleDeny}
disabled={isRefreshing || !approvalId} disabled={isRefreshing || !approvalId}
loading={isRefreshing} loading={isRefreshing}
variant="destructive" variant="outline"
className="gap-2" className="gap-2"
> >
<Ban className="size-4" /> <Ban className="size-4" />
@@ -339,8 +349,7 @@ export default function GeneralPage() {
)} )}
{/* Device Information Section */} {/* Device Information Section */}
{(client.fingerprint || {(client.fingerprint || (client.agent && client.olmVersion)) && (
(client.agent && client.olmVersion)) && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -360,145 +369,182 @@ export default function GeneralPage() {
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<Badge variant="secondary"> <Badge variant="secondary">
{client.agent + " v" + client.olmVersion} {client.agent +
" v" +
client.olmVersion}
</Badge> </Badge>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</div> </div>
)} )}
{client.fingerprint && (() => { {client.fingerprint &&
const platform = client.fingerprint.platform; (() => {
const fieldConfig = getPlatformFieldConfig(platform); const platform = client.fingerprint.platform;
const fieldConfig =
getPlatformFieldConfig(platform);
return ( return (
<InfoSections cols={3}> <InfoSections cols={3}>
{platform && ( {platform && (
<InfoSection>
<InfoSectionTitle>
{t("platform")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex items-center gap-2">
{getPlatformIcon(platform)}
<span>{formatPlatform(platform)}</span>
</div>
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.osVersion &&
fieldConfig.osVersion.show && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t(fieldConfig.osVersion.labelKey)} {t("platform")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{client.fingerprint.osVersion} <div className="flex items-center gap-2">
{getPlatformIcon(
platform
)}
<span>
{formatPlatform(
platform
)}
</span>
</div>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
)} )}
{client.fingerprint.kernelVersion && {client.fingerprint.osVersion &&
fieldConfig.kernelVersion.show && ( fieldConfig.osVersion.show && (
<InfoSection>
<InfoSectionTitle>
{t(
fieldConfig
.osVersion
.labelKey
)}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.osVersion
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.kernelVersion &&
fieldConfig.kernelVersion.show && (
<InfoSection>
<InfoSectionTitle>
{t("kernelVersion")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.kernelVersion
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.arch &&
fieldConfig.arch.show && (
<InfoSection>
<InfoSectionTitle>
{t("architecture")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.arch
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.deviceModel &&
fieldConfig.deviceModel.show && (
<InfoSection>
<InfoSectionTitle>
{t("deviceModel")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.deviceModel
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.serialNumber &&
fieldConfig.serialNumber.show && (
<InfoSection>
<InfoSectionTitle>
{t("serialNumber")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.serialNumber
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.username &&
fieldConfig.username.show && (
<InfoSection>
<InfoSectionTitle>
{t("username")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.username
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.hostname &&
fieldConfig.hostname.show && (
<InfoSection>
<InfoSectionTitle>
{t("hostname")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.hostname
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.firstSeen && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("kernelVersion")} {t("firstSeen")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{client.fingerprint.kernelVersion} {formatTimestamp(
client.fingerprint
.firstSeen
)}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
)} )}
{client.fingerprint.arch && {client.fingerprint.lastSeen && (
fieldConfig.arch.show && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("architecture")} {t("lastSeen")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{client.fingerprint.arch} {formatTimestamp(
client.fingerprint
.lastSeen
)}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
)} )}
</InfoSections>
{client.fingerprint.deviceModel && );
fieldConfig.deviceModel.show && ( })()}
<InfoSection>
<InfoSectionTitle>
{t("deviceModel")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.deviceModel}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.serialNumber &&
fieldConfig.serialNumber.show && (
<InfoSection>
<InfoSectionTitle>
{t("serialNumber")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.serialNumber}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.username &&
fieldConfig.username.show && (
<InfoSection>
<InfoSectionTitle>
{t("username")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.username}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.hostname &&
fieldConfig.hostname.show && (
<InfoSection>
<InfoSectionTitle>
{t("hostname")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.hostname}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.firstSeen && (
<InfoSection>
<InfoSectionTitle>
{t("firstSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{formatTimestamp(
client.fingerprint.firstSeen
)}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.lastSeen && (
<InfoSection>
<InfoSectionTitle>
{t("lastSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{formatTimestamp(
client.fingerprint.lastSeen
)}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
);
})()}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}

View File

@@ -41,6 +41,32 @@ export default async function ClientsPage(props: ClientsPageProps) {
const mapClientToRow = ( const mapClientToRow = (
client: ListClientsResponse["clients"][0] client: ListClientsResponse["clients"][0]
): ClientRow => { ): ClientRow => {
// Build fingerprint object if any fingerprint data exists
const hasFingerprintData =
(client as any).fingerprintPlatform ||
(client as any).fingerprintOsVersion ||
(client as any).fingerprintKernelVersion ||
(client as any).fingerprintArch ||
(client as any).fingerprintSerialNumber ||
(client as any).fingerprintUsername ||
(client as any).fingerprintHostname ||
(client as any).deviceModel;
const fingerprint = hasFingerprintData
? {
platform: (client as any).fingerprintPlatform || null,
osVersion: (client as any).fingerprintOsVersion || null,
kernelVersion:
(client as any).fingerprintKernelVersion || null,
arch: (client as any).fingerprintArch || null,
deviceModel: (client as any).deviceModel || null,
serialNumber:
(client as any).fingerprintSerialNumber || null,
username: (client as any).fingerprintUsername || null,
hostname: (client as any).fingerprintHostname || null
}
: null;
return { return {
name: client.name, name: client.name,
id: client.clientId, id: client.clientId,
@@ -58,7 +84,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
agent: client.agent, agent: client.agent,
archived: client.archived || false, archived: client.archived || false,
blocked: client.blocked || false, blocked: client.blocked || false,
approvalState: client.approvalState approvalState: client.approvalState,
fingerprint
}; };
}; };

View File

@@ -27,7 +27,7 @@ import ClientDownloadBanner from "./ClientDownloadBanner";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { build } from "@server/build"; import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { t } from "@faker-js/faker/dist/airline-DF6RqYmq"; import { InfoPopup } from "@app/components/ui/info-popup";
export type ClientRow = { export type ClientRow = {
id: number; id: number;
@@ -48,6 +48,16 @@ export type ClientRow = {
approvalState: "approved" | "pending" | "denied" | null; approvalState: "approved" | "pending" | "denied" | null;
archived?: boolean; archived?: boolean;
blocked?: boolean; blocked?: boolean;
fingerprint?: {
platform: string | null;
osVersion: string | null;
kernelVersion: string | null;
arch: string | null;
deviceModel: string | null;
serialNumber: string | null;
username: string | null;
hostname: string | null;
} | null;
}; };
type ClientTableProps = { type ClientTableProps = {
@@ -55,10 +65,52 @@ type ClientTableProps = {
orgId: string; orgId: string;
}; };
function formatPlatform(platform: string | null | undefined): string {
if (!platform) return "-";
const platformMap: Record<string, string> = {
macos: "macOS",
windows: "Windows",
linux: "Linux",
ios: "iOS",
android: "Android",
unknown: "Unknown"
};
return platformMap[platform.toLowerCase()] || platform;
}
export default function UserDevicesTable({ userClients }: ClientTableProps) { export default function UserDevicesTable({ userClients }: ClientTableProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const formatFingerprintInfo = (fingerprint: ClientRow["fingerprint"]): string => {
if (!fingerprint) return "";
const parts: string[] = [];
if (fingerprint.platform) {
parts.push(`${t("platform")}: ${formatPlatform(fingerprint.platform)}`);
}
if (fingerprint.deviceModel) {
parts.push(`${t("deviceModel")}: ${fingerprint.deviceModel}`);
}
if (fingerprint.osVersion) {
parts.push(`${t("osVersion")}: ${fingerprint.osVersion}`);
}
if (fingerprint.arch) {
parts.push(`${t("architecture")}: ${fingerprint.arch}`);
}
if (fingerprint.hostname) {
parts.push(`${t("hostname")}: ${fingerprint.hostname}`);
}
if (fingerprint.username) {
parts.push(`${t("username")}: ${fingerprint.username}`);
}
if (fingerprint.serialNumber) {
parts.push(`${t("serialNumber")}: ${fingerprint.serialNumber}`);
}
return parts.join("\n");
};
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>( const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null null
@@ -182,7 +234,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: "Name", friendlyName: t("name"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -193,16 +245,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Name {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
const fingerprintInfo = r.fingerprint
? formatFingerprintInfo(r.fingerprint)
: null;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{r.name}</span> <span>{r.name}</span>
{fingerprintInfo && (
<InfoPopup>
<div className="space-y-1 text-sm">
<div className="font-semibold mb-2">
{t("deviceInformation")}
</div>
<div className="text-muted-foreground whitespace-pre-line">
{fingerprintInfo}
</div>
</div>
</InfoPopup>
)}
{r.archived && ( {r.archived && (
<Badge variant="secondary"> <Badge variant="secondary">
{t("archived")} {t("archived")}
@@ -250,7 +317,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "userEmail", accessorKey: "userEmail",
friendlyName: "User", friendlyName: t("users"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -261,7 +328,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
User {t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -284,7 +351,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "online", accessorKey: "online",
friendlyName: "Connectivity", friendlyName: t("online"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -295,7 +362,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Connectivity {t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -306,14 +373,14 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span> <span>{t("online")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div> <div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span> <span>{t("offline")}</span>
</span> </span>
); );
} }
@@ -321,7 +388,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "mbIn", accessorKey: "mbIn",
friendlyName: "Data In", friendlyName: t("dataIn"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -332,7 +399,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Data In {t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -340,7 +407,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "mbOut", accessorKey: "mbOut",
friendlyName: "Data Out", friendlyName: t("dataOut"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -351,7 +418,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Data Out {t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -399,7 +466,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "subnet", accessorKey: "subnet",
friendlyName: "Address", friendlyName: t("address"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -410,7 +477,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Address {t("address")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -445,8 +512,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
> >
<span> <span>
{clientRow.archived {clientRow.archived
? "Unarchive" ? t("actionUnarchiveClient")
: "Archive"} : t("actionArchiveClient")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
@@ -460,8 +527,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
> >
<span> <span>
{clientRow.blocked {clientRow.blocked
? "Unblock" ? t("actionUnblockClient")
: "Block"} : t("actionBlockClient")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
{!clientRow.userId && ( {!clientRow.userId && (
@@ -473,7 +540,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}} }}
> >
<span className="text-red-500"> <span className="text-red-500">
Delete {t("delete")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
@@ -483,7 +550,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
href={`/${clientRow.orgId}/settings/clients/user/${clientRow.niceId}`} href={`/${clientRow.orgId}/settings/clients/user/${clientRow.niceId}`}
> >
<Button variant={"outline"}> <Button variant={"outline"}>
View {t("viewDetails")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>
@@ -510,10 +577,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
<p>{t("clientMessageRemove")}</p> <p>{t("clientMessageRemove")}</p>
</div> </div>
} }
buttonText="Confirm Delete Client" buttonText={t("actionDeleteClient")}
onConfirm={async () => deleteClient(selectedClient!.id)} onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name} string={selectedClient.name}
title="Delete Client" title={t("actionDeleteClient")}
/> />
)} )}
<ClientDownloadBanner /> <ClientDownloadBanner />

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import React from "react"; import React, { useState, useRef, useEffect } from "react";
import { Info } from "lucide-react"; import { Info } from "lucide-react";
import { import {
Popover, Popover,
@@ -17,25 +17,61 @@ interface InfoPopupProps {
} }
export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) { export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setOpen(true);
};
const handleMouseLeave = () => {
// Add a small delay to prevent flickering when moving between trigger and content
timeoutRef.current = setTimeout(() => {
setOpen(false);
}, 100);
};
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const defaultTrigger = ( const defaultTrigger = (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 rounded-full p-0" className="h-6 w-6 rounded-full p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
> >
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<span className="sr-only">Show info</span> <span className="sr-only">Show info</span>
</Button> </Button>
); );
const triggerElement = trigger ?? defaultTrigger;
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{text && <span>{text}</span>} {text && <span>{text}</span>}
<Popover> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger
{trigger ?? defaultTrigger} asChild
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{triggerElement}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-80"> <PopoverContent
className="w-80"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children || {children ||
(info && ( (info && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">