mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-05 02:06:41 +00:00
show fingerprint popup and fix policy check errors
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user