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,
archived: clients.archived,
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)
.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
});
logger.debug("Policy check result:", policyCheck);
if (policyCheck?.error) {
logger.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;
}
if (policyCheck?.policies?.passwordAge?.compliant) {
if (
policyCheck?.policies?.passwordAge &&
!policyCheck.policies.passwordAge.compliant
) {
logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
);
@@ -132,7 +137,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olm.olmId
);
return;
} else if (policyCheck?.policies?.maxSessionLength?.compliant) {
} else if (
policyCheck?.policies?.maxSessionLength &&
!policyCheck.policies.maxSessionLength.compliant
) {
logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
);
@@ -141,7 +149,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olm.olmId
);
return;
} else if (policyCheck?.policies?.requiredTwoFactor) {
} else if (
policyCheck?.policies &&
!policyCheck.policies.requiredTwoFactor
) {
logger.warn(
`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
useEffect(() => {
if (showApprovalFeatures && client.approvalState === "pending" && client.clientId) {
if (
showApprovalFeatures &&
client.approvalState === "pending" &&
client.clientId
) {
api.get(`/org/${orgId}/approvals?approvalState=pending`)
.then((res) => {
const approval = res.data.data.approvals.find(
@@ -168,7 +172,13 @@ export default function GeneralPage() {
// Silently fail - approval might not exist
});
}
}, [showApprovalFeatures, client.approvalState, client.clientId, orgId, api]);
}, [
showApprovalFeatures,
client.approvalState,
client.clientId,
orgId,
api
]);
const handleApprove = async () => {
if (!approvalId) return;
@@ -280,7 +290,6 @@ export default function GeneralPage() {
}
};
return (
<SettingsContainer>
{/* Pending Approval Banner */}
@@ -296,6 +305,7 @@ export default function GeneralPage() {
onClick={handleApprove}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<Check className="size-4" />
@@ -305,7 +315,7 @@ export default function GeneralPage() {
onClick={handleDeny}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="destructive"
variant="outline"
className="gap-2"
>
<Ban className="size-4" />
@@ -339,8 +349,7 @@ export default function GeneralPage() {
)}
{/* Device Information Section */}
{(client.fingerprint ||
(client.agent && client.olmVersion)) && (
{(client.fingerprint || (client.agent && client.olmVersion)) && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -360,145 +369,182 @@ export default function GeneralPage() {
</InfoSectionTitle>
<InfoSectionContent>
<Badge variant="secondary">
{client.agent + " v" + client.olmVersion}
{client.agent +
" v" +
client.olmVersion}
</Badge>
</InfoSectionContent>
</InfoSection>
</div>
)}
{client.fingerprint && (() => {
const platform = client.fingerprint.platform;
const fieldConfig = getPlatformFieldConfig(platform);
{client.fingerprint &&
(() => {
const platform = client.fingerprint.platform;
const fieldConfig =
getPlatformFieldConfig(platform);
return (
<InfoSections cols={3}>
{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 && (
return (
<InfoSections cols={3}>
{platform && (
<InfoSection>
<InfoSectionTitle>
{t(fieldConfig.osVersion.labelKey)}
{t("platform")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.osVersion}
<div className="flex items-center gap-2">
{getPlatformIcon(
platform
)}
<span>
{formatPlatform(
platform
)}
</span>
</div>
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.kernelVersion &&
fieldConfig.kernelVersion.show && (
{client.fingerprint.osVersion &&
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>
<InfoSectionTitle>
{t("kernelVersion")}
{t("firstSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.kernelVersion}
{formatTimestamp(
client.fingerprint
.firstSeen
)}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.arch &&
fieldConfig.arch.show && (
{client.fingerprint.lastSeen && (
<InfoSection>
<InfoSectionTitle>
{t("architecture")}
{t("lastSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{client.fingerprint.arch}
{formatTimestamp(
client.fingerprint
.lastSeen
)}
</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>
<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>
);
})()}
</InfoSections>
);
})()}
</SettingsSectionBody>
</SettingsSection>
)}

View File

@@ -41,6 +41,32 @@ export default async function ClientsPage(props: ClientsPageProps) {
const mapClientToRow = (
client: ListClientsResponse["clients"][0]
): 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 {
name: client.name,
id: client.clientId,
@@ -58,7 +84,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
agent: client.agent,
archived: client.archived || 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 { build } from "@server/build";
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 = {
id: number;
@@ -48,6 +48,16 @@ export type ClientRow = {
approvalState: "approved" | "pending" | "denied" | null;
archived?: 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 = {
@@ -55,10 +65,52 @@ type ClientTableProps = {
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) {
const router = useRouter();
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 [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
@@ -182,7 +234,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -193,16 +245,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Name
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
const fingerprintInfo = r.fingerprint
? formatFingerprintInfo(r.fingerprint)
: null;
return (
<div className="flex items-center gap-2">
<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 && (
<Badge variant="secondary">
{t("archived")}
@@ -250,7 +317,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "userEmail",
friendlyName: "User",
friendlyName: t("users"),
header: ({ column }) => {
return (
<Button
@@ -261,7 +328,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
User
{t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -284,7 +351,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "online",
friendlyName: "Connectivity",
friendlyName: t("online"),
header: ({ column }) => {
return (
<Button
@@ -295,7 +362,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Connectivity
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -306,14 +373,14 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
<span>{t("offline")}</span>
</span>
);
}
@@ -321,7 +388,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
friendlyName: t("dataIn"),
header: ({ column }) => {
return (
<Button
@@ -332,7 +399,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Data In
{t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -340,7 +407,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
friendlyName: t("dataOut"),
header: ({ column }) => {
return (
<Button
@@ -351,7 +418,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Data Out
{t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -399,7 +466,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
},
{
accessorKey: "subnet",
friendlyName: "Address",
friendlyName: t("address"),
header: ({ column }) => {
return (
<Button
@@ -410,7 +477,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)
}
>
Address
{t("address")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -445,8 +512,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
>
<span>
{clientRow.archived
? "Unarchive"
: "Archive"}
? t("actionUnarchiveClient")
: t("actionArchiveClient")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -460,8 +527,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
>
<span>
{clientRow.blocked
? "Unblock"
: "Block"}
? t("actionUnblockClient")
: t("actionBlockClient")}
</span>
</DropdownMenuItem>
{!clientRow.userId && (
@@ -473,7 +540,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}}
>
<span className="text-red-500">
Delete
{t("delete")}
</span>
</DropdownMenuItem>
)}
@@ -483,7 +550,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
href={`/${clientRow.orgId}/settings/clients/user/${clientRow.niceId}`}
>
<Button variant={"outline"}>
View
{t("viewDetails")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -510,10 +577,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
buttonText={t("actionDeleteClient")}
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
title={t("actionDeleteClient")}
/>
)}
<ClientDownloadBanner />

View File

@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState, useRef, useEffect } from "react";
import { Info } from "lucide-react";
import {
Popover,
@@ -17,25 +17,61 @@ interface 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 = (
<Button
variant="ghost"
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" />
<span className="sr-only">Show info</span>
</Button>
);
const triggerElement = trigger ?? defaultTrigger;
return (
<div className="flex items-center space-x-2">
{text && <span>{text}</span>}
<Popover>
<PopoverTrigger asChild>
{trigger ?? defaultTrigger}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
asChild
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{triggerElement}
</PopoverTrigger>
<PopoverContent className="w-80">
<PopoverContent
className="w-80"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children ||
(info && (
<p className="text-sm text-muted-foreground">