add display info for device posture

This commit is contained in:
miloschwartz
2026-01-20 17:46:50 -08:00
parent 93bc6ba615
commit 3aa58fdc8f
6 changed files with 432 additions and 32 deletions

View File

@@ -2489,6 +2489,8 @@
"logIn": "Log In", "logIn": "Log In",
"deviceInformation": "Device Information", "deviceInformation": "Device Information",
"deviceInformationDescription": "Information about the device and agent", "deviceInformationDescription": "Information about the device and agent",
"deviceSecurity": "Device Security",
"deviceSecurityDescription": "Device security posture information",
"platform": "Platform", "platform": "Platform",
"macosVersion": "macOS Version", "macosVersion": "macOS Version",
"windowsVersion": "Windows Version", "windowsVersion": "Windows Version",
@@ -2501,6 +2503,17 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "First Seen", "firstSeen": "First Seen",
"lastSeen": "Last Seen", "lastSeen": "Last Seen",
"biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM Available",
"windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "View device information and settings", "deviceSettingsDescription": "View device information and settings",
"devicePendingApprovalDescription": "This device is waiting for approval", "devicePendingApprovalDescription": "This device is waiting for approval",
"deviceBlockedDescription": "This device is currently blocked. It won't be able to connect to any resources unless unblocked.", "deviceBlockedDescription": "This device is currently blocked. It won't be able to connect to any resources unless unblocked.",

View File

@@ -0,0 +1,51 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
export async function verifyValidLicense(
req: Request,
res: Response,
next: NextFunction
) {
try {
if (build != "saas") {
return next();
}
const { tier, active } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
return next();
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying subscription"
)
);
}
}

View File

@@ -11,6 +11,7 @@ import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { getUserDeviceName } from "@server/db/names"; import { getUserDeviceName } from "@server/db/names";
import { build } from "@server/build";
const getClientSchema = z.strictObject({ const getClientSchema = z.strictObject({
clientId: z clientId: z
@@ -51,6 +52,106 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
} }
} }
type PostureData = {
biometricsEnabled?: boolean | null;
diskEncrypted?: boolean | null;
firewallEnabled?: boolean | null;
autoUpdatesEnabled?: boolean | null;
tpmAvailable?: boolean | null;
windowsDefenderEnabled?: boolean | null;
macosSipEnabled?: boolean | null;
macosGatekeeperEnabled?: boolean | null;
macosFirewallStealthMode?: boolean | null;
linuxAppArmorEnabled?: boolean | null;
linuxSELinuxEnabled?: boolean | null;
};
function getPlatformPostureData(
platform: string | null | undefined,
fingerprint: typeof currentFingerprint.$inferSelect | null
): PostureData | null {
if (!fingerprint) return null;
const normalizedPlatform = platform?.toLowerCase() || "unknown";
const posture: PostureData = {};
// Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Defender
if (normalizedPlatform === "windows") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined) {
posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled;
}
if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
posture.tpmAvailable = fingerprint.tpmAvailable;
}
if (fingerprint.windowsDefenderEnabled !== null && fingerprint.windowsDefenderEnabled !== undefined) {
posture.windowsDefenderEnabled = fingerprint.windowsDefenderEnabled;
}
}
// macOS: Hard drive encryption, Biometric configuration, Firewall, System Integrity Protection (SIP), Gatekeeper, Firewall stealth mode
else if (normalizedPlatform === "macos") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.macosSipEnabled !== null && fingerprint.macosSipEnabled !== undefined) {
posture.macosSipEnabled = fingerprint.macosSipEnabled;
}
if (fingerprint.macosGatekeeperEnabled !== null && fingerprint.macosGatekeeperEnabled !== undefined) {
posture.macosGatekeeperEnabled = fingerprint.macosGatekeeperEnabled;
}
if (fingerprint.macosFirewallStealthMode !== null && fingerprint.macosFirewallStealthMode !== undefined) {
posture.macosFirewallStealthMode = fingerprint.macosFirewallStealthMode;
}
}
// Linux: Hard drive encryption, Firewall, AppArmor, SELinux, TPM availability
else if (normalizedPlatform === "linux") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.linuxAppArmorEnabled !== null && fingerprint.linuxAppArmorEnabled !== undefined) {
posture.linuxAppArmorEnabled = fingerprint.linuxAppArmorEnabled;
}
if (fingerprint.linuxSELinuxEnabled !== null && fingerprint.linuxSELinuxEnabled !== undefined) {
posture.linuxSELinuxEnabled = fingerprint.linuxSELinuxEnabled;
}
if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
posture.tpmAvailable = fingerprint.tpmAvailable;
}
}
// iOS: Biometric configuration
else if (normalizedPlatform === "ios") {
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
}
// Android: Screen lock, Biometric configuration, Hard drive encryption
else if (normalizedPlatform === "android") {
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
}
// Only return if we have at least one posture field
return Object.keys(posture).length > 0 ? posture : null;
}
export type GetClientResponse = NonNullable< export type GetClientResponse = NonNullable<
Awaited<ReturnType<typeof query>> Awaited<ReturnType<typeof query>>
>["clients"] & { >["clients"] & {
@@ -69,6 +170,7 @@ export type GetClientResponse = NonNullable<
firstSeen: number | null; firstSeen: number | null;
lastSeen: number | null; lastSeen: number | null;
} | null; } | null;
posture: PostureData | null;
}; };
registry.registerPath({ registry.registerPath({
@@ -152,13 +254,23 @@ export async function getClient(
} }
: null; : null;
// Build posture data if available (platform-specific)
let postureData: PostureData | null = null;
if (build !== "oss") {
postureData = getPlatformPostureData(
client.currentFingerprint?.platform || null,
client.currentFingerprint
);
}
const data: GetClientResponse = { const data: GetClientResponse = {
...client.clients, ...client.clients,
name: clientName, name: clientName,
olmId: client.olms ? client.olms.olmId : null, olmId: client.olms ? client.olms.olmId : null,
agent: client.olms?.agent || null, agent: client.olms?.agent || null,
olmVersion: client.olms?.version || null, olmVersion: client.olms?.version || null,
fingerprint: fingerprintData fingerprint: fingerprintData,
posture: postureData
}; };
return response<GetClientResponse>(res, { return response<GetClientResponse>(res, {

View File

@@ -22,12 +22,13 @@ import {
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import ActionBanner from "@app/components/ActionBanner"; import ActionBanner from "@app/components/ActionBanner";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useEffect, useTransition } from "react"; import { useState, useEffect, useTransition } from "react";
import { Check, Ban, Shield, ShieldOff, Clock } from "lucide-react"; import { Check, Ban, Shield, ShieldOff, Clock, CheckCircle2, XCircle } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
import { SiAndroid } from "react-icons/si"; import { SiAndroid } from "react-icons/si";
@@ -111,18 +112,12 @@ function getPlatformFieldConfig(
kernelVersion: { show: false, labelKey: "kernelVersion" }, kernelVersion: { show: false, labelKey: "kernelVersion" },
arch: { show: true, labelKey: "architecture" }, arch: { show: true, labelKey: "architecture" },
deviceModel: { show: true, labelKey: "deviceModel" }, deviceModel: { show: true, labelKey: "deviceModel" },
serialNumber: { show: true, labelKey: "serialNumber" },
username: { show: true, labelKey: "username" },
hostname: { show: true, labelKey: "hostname" }
}, },
android: { android: {
osVersion: { show: true, labelKey: "androidVersion" }, osVersion: { show: true, labelKey: "androidVersion" },
kernelVersion: { show: true, labelKey: "kernelVersion" }, kernelVersion: { show: true, labelKey: "kernelVersion" },
arch: { show: true, labelKey: "architecture" }, arch: { show: true, labelKey: "architecture" },
deviceModel: { show: true, labelKey: "deviceModel" }, deviceModel: { show: true, labelKey: "deviceModel" },
serialNumber: { show: true, labelKey: "serialNumber" },
username: { show: true, labelKey: "username" },
hostname: { show: true, labelKey: "hostname" }
}, },
unknown: { unknown: {
osVersion: { show: true, labelKey: "osVersion" }, osVersion: { show: true, labelKey: "osVersion" },
@@ -138,6 +133,7 @@ function getPlatformFieldConfig(
return configs[normalizedPlatform] || configs.unknown; return configs[normalizedPlatform] || configs.unknown;
} }
export default function GeneralPage() { export default function GeneralPage() {
const { client, updateClient } = useClientContext(); const { client, updateClient } = useClientContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
@@ -152,6 +148,20 @@ export default function GeneralPage() {
const showApprovalFeatures = build !== "oss" && isPaidUser; const showApprovalFeatures = build !== "oss" && isPaidUser;
const formatPostureValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) return "-";
return (
<div className="flex items-center gap-2">
{value ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span>{value ? t("enabled") : t("disabled")}</span>
</div>
);
};
// Fetch approval ID for this client if pending // Fetch approval ID for this client if pending
useEffect(() => { useEffect(() => {
if ( if (
@@ -407,13 +417,13 @@ export default function GeneralPage() {
)} )}
{client.fingerprint.osVersion && {client.fingerprint.osVersion &&
fieldConfig.osVersion.show && ( fieldConfig.osVersion?.show && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t( {t(
fieldConfig fieldConfig
.osVersion .osVersion
.labelKey ?.labelKey || "osVersion"
)} )}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
@@ -426,7 +436,7 @@ export default function GeneralPage() {
)} )}
{client.fingerprint.kernelVersion && {client.fingerprint.kernelVersion &&
fieldConfig.kernelVersion.show && ( fieldConfig.kernelVersion?.show && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("kernelVersion")} {t("kernelVersion")}
@@ -456,7 +466,7 @@ export default function GeneralPage() {
)} )}
{client.fingerprint.deviceModel && {client.fingerprint.deviceModel &&
fieldConfig.deviceModel.show && ( fieldConfig.deviceModel?.show && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("deviceModel")} {t("deviceModel")}
@@ -486,7 +496,7 @@ export default function GeneralPage() {
)} )}
{client.fingerprint.username && {client.fingerprint.username &&
fieldConfig.username.show && ( fieldConfig.username?.show && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("username")} {t("username")}
@@ -501,7 +511,7 @@ export default function GeneralPage() {
)} )}
{client.fingerprint.hostname && {client.fingerprint.hostname &&
fieldConfig.hostname.show && ( fieldConfig.hostname?.show && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("hostname")} {t("hostname")}
@@ -548,6 +558,218 @@ export default function GeneralPage() {
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}
{/* Device Security Section */}
{build !== "oss" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("deviceSecurity")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("deviceSecurityDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{client.posture && Object.keys(client.posture).length > 0 ? (
<>
{!isPaidUser && <PaidFeaturesAlert />}
<InfoSections cols={3}>
{client.posture.biometricsEnabled !== null &&
client.posture.biometricsEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("biometricsEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.biometricsEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.diskEncrypted !== null &&
client.posture.diskEncrypted !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("diskEncrypted")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.diskEncrypted
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.firewallEnabled !== null &&
client.posture.firewallEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("firewallEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.firewallEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.autoUpdatesEnabled !== null &&
client.posture.autoUpdatesEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("autoUpdatesEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.autoUpdatesEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.tpmAvailable !== null &&
client.posture.tpmAvailable !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("tpmAvailable")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.tpmAvailable
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.windowsDefenderEnabled !== null &&
client.posture.windowsDefenderEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("windowsDefenderEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.windowsDefenderEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosSipEnabled !== null &&
client.posture.macosSipEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosSipEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture.macosSipEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosGatekeeperEnabled !== null &&
client.posture.macosGatekeeperEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosGatekeeperEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.macosGatekeeperEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosFirewallStealthMode !== null &&
client.posture.macosFirewallStealthMode !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosFirewallStealthMode")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.macosFirewallStealthMode
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.linuxAppArmorEnabled !== null &&
client.posture.linuxAppArmorEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("linuxAppArmorEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.linuxAppArmorEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.linuxSELinuxEnabled !== null &&
client.posture.linuxSELinuxEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("linuxSELinuxEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.linuxSELinuxEnabled
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</>
) : (
<div className="text-muted-foreground">
{t("noData")}
</div>
)}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer> </SettingsContainer>
); );
} }

View File

@@ -145,10 +145,10 @@ function CollapsibleNavItem({
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2"> <div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
{notificationCount !== undefined && {notificationCount !== undefined &&
notificationCount > 0 && ( notificationCount > 0 && (
<Badge <Badge variant="secondary">
variant="secondary" {notificationCount > 99
> ? "99+"
{notificationCount > 99 ? "99+" : notificationCount} : notificationCount}
</Badge> </Badge>
)} )}
{build === "enterprise" && {build === "enterprise" &&
@@ -321,9 +321,7 @@ export function SidebarNav({
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{notificationCount !== undefined && {notificationCount !== undefined &&
notificationCount > 0 && ( notificationCount > 0 && (
<Badge <Badge variant="secondary">
variant="secondary"
>
{notificationCount > 99 {notificationCount > 99
? "99+" ? "99+"
: notificationCount} : notificationCount}
@@ -346,8 +344,8 @@ export function SidebarNav({
notificationCount !== undefined && notificationCount !== undefined &&
notificationCount > 0 && ( notificationCount > 0 && (
<Badge <Badge
variant="default" variant="secondary"
className="absolute -top-1 -right-1 h-5 min-w-5 px-1.5 flex items-center justify-center text-xs bg-primary text-primary-foreground" className="absolute -top-1 -right-1 h-5 min-w-5 px-1.5 flex items-center justify-center text-xs"
> >
{notificationCount > 99 ? "99+" : notificationCount} {notificationCount > 99 ? "99+" : notificationCount}
</Badge> </Badge>
@@ -379,7 +377,7 @@ export function SidebarNav({
{notificationCount !== undefined && {notificationCount !== undefined &&
notificationCount > 0 && ( notificationCount > 0 && (
<Badge <Badge
variant="default" variant="secondary"
className="flex-shrink-0 bg-primary text-primary-foreground" className="flex-shrink-0 bg-primary text-primary-foreground"
> >
{notificationCount > 99 {notificationCount > 99

View File

@@ -38,6 +38,7 @@ export function formatFingerprintInfo(
): string { ): string {
if (!fingerprint) return ""; if (!fingerprint) return "";
const parts: string[] = []; const parts: string[] = [];
const normalizedPlatform = fingerprint.platform?.toLowerCase() || "unknown";
if (fingerprint.platform) { if (fingerprint.platform) {
parts.push( parts.push(
@@ -53,14 +54,17 @@ export function formatFingerprintInfo(
if (fingerprint.arch) { if (fingerprint.arch) {
parts.push(`${t("architecture")}: ${fingerprint.arch}`); parts.push(`${t("architecture")}: ${fingerprint.arch}`);
} }
if (fingerprint.hostname) {
parts.push(`${t("hostname")}: ${fingerprint.hostname}`); if (normalizedPlatform !== "ios" && normalizedPlatform !== "android") {
} if (fingerprint.hostname) {
if (fingerprint.username) { parts.push(`${t("hostname")}: ${fingerprint.hostname}`);
parts.push(`${t("username")}: ${fingerprint.username}`); }
} if (fingerprint.username) {
if (fingerprint.serialNumber) { parts.push(`${t("username")}: ${fingerprint.username}`);
parts.push(`${t("serialNumber")}: ${fingerprint.serialNumber}`); }
if (fingerprint.serialNumber) {
parts.push(`${t("serialNumber")}: ${fingerprint.serialNumber}`);
}
} }
return parts.join("\n"); return parts.join("\n");