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