format device approval message

This commit is contained in:
miloschwartz
2026-01-19 21:00:41 -08:00
parent b299f3d6aa
commit 7305c721a6
5 changed files with 199 additions and 66 deletions

View File

@@ -21,9 +21,10 @@ import type { Request, Response, NextFunction } from "express";
import { build } from "@server/build"; import { build } from "@server/build";
import { getOrgTierData } from "@server/lib/billing"; import { getOrgTierData } from "@server/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; 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 { eq, isNull, sql, not, and, desc } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { getUserDeviceName } from "@server/db/names";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -82,7 +83,16 @@ async function queryApprovals(
userId: users.userId, userId: users.userId,
username: users.username, username: users.username,
email: users.email 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) .from(approvals)
.innerJoin(users, and(eq(approvals.userId, users.userId))) .innerJoin(users, and(eq(approvals.userId, users.userId)))
@@ -93,6 +103,8 @@ async function queryApprovals(
not(isNull(clients.userId)) // only user devices not(isNull(clients.userId)) // only user devices
) )
) )
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.where( .where(
and( and(
eq(approvals.orgId, orgId), eq(approvals.orgId, orgId),
@@ -105,7 +117,57 @@ async function queryApprovals(
) )
.limit(limit) .limit(limit)
.offset(offset); .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 = { export type ListApprovalsResponse = {

View File

@@ -4,17 +4,19 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint";
import { import {
approvalFiltersSchema, approvalFiltersSchema,
approvalQueries, approvalQueries,
type ApprovalItem type ApprovalItem
} from "@app/lib/queries"; } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; 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 { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Fragment, useActionState } from "react"; import { Fragment, useActionState } from "react";
import type { LucideIcon } from "lucide-react";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Card, CardHeader } from "./ui/card"; import { Card, CardHeader } from "./ui/card";
@@ -27,6 +29,7 @@ import {
SelectValue SelectValue
} from "./ui/select"; } from "./ui/select";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { InfoPopup } from "./ui/info-popup";
export type ApprovalFeedProps = { export type ApprovalFeedProps = {
orgId: string; orgId: string;
@@ -183,18 +186,50 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
return ( return (
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div className="inline-flex items-start md:items-center gap-2"> <div className="inline-flex items-start md:items-center gap-2">
<LaptopMinimal className="size-4 text-muted-foreground flex-none relative top-2 sm:top-0" />
<span> <span>
<span className="text-primary"> <Link
href={`/${orgId}/settings/access/users/${approval.user.userId}/access-controls`}
className="text-primary hover:underline cursor-pointer"
>
{getUserDisplayName({ {getUserDisplayName({
email: approval.user.email, email: approval.user.email,
name: approval.user.name, name: approval.user.name,
username: approval.user.username username: approval.user.username
})} })}
</span> </Link>
&nbsp; &nbsp;
{approval.type === "user_device" && ( {approval.type === "user_device" && (
<span>{t("requestingNewDeviceApproval")}</span> <span className="inline-flex items-center gap-1">
{approval.deviceName ? (
<>
{t("requestingNewDeviceApproval")}:{" "}
{approval.clientId ? (
<Link
href={`/${orgId}/settings/clients/user/${approval.clientId}/general`}
className="text-primary hover:underline cursor-pointer"
>
{approval.deviceName}
</Link>
) : (
<span>{approval.deviceName}</span>
)}
{approval.fingerprint && (
<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">
{formatFingerprintInfo(approval.fingerprint, t)}
</div>
</div>
</InfoPopup>
)}
</>
) : (
<span>{t("requestingNewDeviceApproval")}</span>
)}
</span>
)} )}
</span> </span>
</div> </div>
@@ -231,17 +266,20 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
<Badge variant="red">{t("denied")}</Badge> <Badge variant="red">{t("denied")}</Badge>
)} )}
<Button {approval.clientId && (
variant="outline" <Button
onClick={() => {}} variant="outline"
className="gap-2" className="gap-2"
asChild asChild
> >
<Link href={"#"}> <Link
{t("viewDetails")} href={`/${orgId}/settings/clients/user/${approval.clientId}/general`}
<ArrowRight className="size-4 flex-none" /> >
</Link> {t("viewDetails")}
</Button> <ArrowRight className="size-4 flex-none" />
</Link>
</Button>
)}
</div> </div>
</div> </div>
); );

View File

@@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint";
import { import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
@@ -66,56 +67,10 @@ 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
@@ -258,7 +213,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
const fingerprintInfo = r.fingerprint const fingerprintInfo = r.fingerprint
? formatFingerprintInfo(r.fingerprint) ? formatFingerprintInfo(r.fingerprint, t)
: null; : null;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -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<string, string> = {
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");
}

View File

@@ -342,6 +342,17 @@ export type ApprovalItem = {
username: string; username: string;
email: string | null; 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 = { export const approvalQueries = {