diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts
index 76a895a6..d518555f 100644
--- a/server/private/routers/approvals/listApprovals.ts
+++ b/server/private/routers/approvals/listApprovals.ts
@@ -21,9 +21,10 @@ import type { Request, Response, NextFunction } from "express";
import { build } from "@server/build";
import { getOrgTierData } from "@server/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
-import { approvals, clients, db, users, type Approval } from "@server/db";
+import { approvals, clients, db, users, olms, fingerprints, type Approval } from "@server/db";
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
import response from "@server/lib/response";
+import { getUserDeviceName } from "@server/db/names";
const paramsSchema = z.strictObject({
orgId: z.string()
@@ -82,7 +83,16 @@ async function queryApprovals(
userId: users.userId,
username: users.username,
email: users.email
- }
+ },
+ clientName: clients.name,
+ 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(approvals)
.innerJoin(users, and(eq(approvals.userId, users.userId)))
@@ -93,6 +103,8 @@ async function queryApprovals(
not(isNull(clients.userId)) // only user devices
)
)
+ .leftJoin(olms, eq(clients.clientId, olms.clientId))
+ .leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.where(
and(
eq(approvals.orgId, orgId),
@@ -105,7 +117,57 @@ async function queryApprovals(
)
.limit(limit)
.offset(offset);
- return res;
+
+ // Process results to format device names and build fingerprint objects
+ return res.map((approval) => {
+ const model = approval.deviceModel || null;
+ const deviceName = approval.clientName
+ ? getUserDeviceName(model, approval.clientName)
+ : null;
+
+ // Build fingerprint object if any fingerprint data exists
+ const hasFingerprintData =
+ approval.fingerprintPlatform ||
+ approval.fingerprintOsVersion ||
+ approval.fingerprintKernelVersion ||
+ approval.fingerprintArch ||
+ approval.fingerprintSerialNumber ||
+ approval.fingerprintUsername ||
+ approval.fingerprintHostname ||
+ approval.deviceModel;
+
+ const fingerprint = hasFingerprintData
+ ? {
+ platform: approval.fingerprintPlatform || null,
+ osVersion: approval.fingerprintOsVersion || null,
+ kernelVersion: approval.fingerprintKernelVersion || null,
+ arch: approval.fingerprintArch || null,
+ deviceModel: approval.deviceModel || null,
+ serialNumber: approval.fingerprintSerialNumber || null,
+ username: approval.fingerprintUsername || null,
+ hostname: approval.fingerprintHostname || null
+ }
+ : null;
+
+ const {
+ clientName,
+ deviceModel,
+ fingerprintPlatform,
+ fingerprintOsVersion,
+ fingerprintKernelVersion,
+ fingerprintArch,
+ fingerprintSerialNumber,
+ fingerprintUsername,
+ fingerprintHostname,
+ ...rest
+ } = approval;
+
+ return {
+ ...rest,
+ deviceName,
+ fingerprint
+ };
+ });
}
export type ListApprovalsResponse = {
diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx
index 974a02b2..fdc3c1aa 100644
--- a/src/components/ApprovalFeed.tsx
+++ b/src/components/ApprovalFeed.tsx
@@ -4,17 +4,19 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cn } from "@app/lib/cn";
+import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint";
import {
approvalFiltersSchema,
approvalQueries,
type ApprovalItem
} from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
-import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
+import { ArrowRight, Ban, Check, Laptop, Smartphone, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Fragment, useActionState } from "react";
+import type { LucideIcon } from "lucide-react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardHeader } from "./ui/card";
@@ -27,6 +29,7 @@ import {
SelectValue
} from "./ui/select";
import { Separator } from "./ui/separator";
+import { InfoPopup } from "./ui/info-popup";
export type ApprovalFeedProps = {
orgId: string;
@@ -183,18 +186,50 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
return (
-
-
+
{getUserDisplayName({
email: approval.user.email,
name: approval.user.name,
username: approval.user.username
})}
-
+
{approval.type === "user_device" && (
- {t("requestingNewDeviceApproval")}
+
+ {approval.deviceName ? (
+ <>
+ {t("requestingNewDeviceApproval")}:{" "}
+ {approval.clientId ? (
+
+ {approval.deviceName}
+
+ ) : (
+ {approval.deviceName}
+ )}
+ {approval.fingerprint && (
+
+
+
+ {t("deviceInformation")}
+
+
+ {formatFingerprintInfo(approval.fingerprint, t)}
+
+
+
+ )}
+ >
+ ) : (
+ {t("requestingNewDeviceApproval")}
+ )}
+
)}
@@ -231,17 +266,20 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
{t("denied")}
)}
-
+ {approval.clientId && (
+
+ )}
);
diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx
index 11e5bead..0e84f619 100644
--- a/src/components/UserDevicesTable.tsx
+++ b/src/components/UserDevicesTable.tsx
@@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
+import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint";
import {
ArrowRight,
ArrowUpDown,
@@ -66,56 +67,10 @@ type ClientTableProps = {
orgId: string;
};
-function formatPlatform(platform: string | null | undefined): string {
- if (!platform) return "-";
- const platformMap: Record = {
- 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(
null
@@ -258,7 +213,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
cell: ({ row }) => {
const r = row.original;
const fingerprintInfo = r.fingerprint
- ? formatFingerprintInfo(r.fingerprint)
+ ? formatFingerprintInfo(r.fingerprint, t)
: null;
return (
diff --git a/src/lib/formatDeviceFingerprint.ts b/src/lib/formatDeviceFingerprint.ts
new file mode 100644
index 00000000..3bd4a99b
--- /dev/null
+++ b/src/lib/formatDeviceFingerprint.ts
@@ -0,0 +1,67 @@
+type DeviceFingerprint = {
+ 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;
+
+/**
+ * Formats a platform string to a human-readable format
+ */
+export function formatPlatform(platform: string | null | undefined): string {
+ if (!platform) return "-";
+ const platformMap: Record = {
+ macos: "macOS",
+ windows: "Windows",
+ linux: "Linux",
+ ios: "iOS",
+ android: "Android",
+ unknown: "Unknown"
+ };
+ return platformMap[platform.toLowerCase()] || platform;
+}
+
+/**
+ * Formats device fingerprint information into a human-readable string
+ *
+ * @param fingerprint - The device fingerprint object
+ * @param t - Translation function from next-intl
+ * @returns Formatted string with device information
+ */
+export function formatFingerprintInfo(
+ fingerprint: DeviceFingerprint,
+ t: (key: string) => string
+): 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");
+}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index d016ec77..f471c5a2 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -342,6 +342,17 @@ export type ApprovalItem = {
username: string;
email: string | null;
};
+ deviceName: string | null;
+ 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;
};
export const approvalQueries = {