diff --git a/messages/en-US.json b/messages/en-US.json index 3f1f8174..7b553976 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2483,5 +2483,31 @@ "signupOrgTip": "Are you trying to sign in through your organization's identity provider?", "signupOrgLink": "Sign in or sign up with your organization instead", "verifyEmailLogInWithDifferentAccount": "Use a Different Account", - "logIn": "Log In" + "logIn": "Log In", + "deviceInformation": "Device Information", + "deviceInformationDescription": "Information about the device and agent", + "platform": "Platform", + "macosVersion": "macOS Version", + "windowsVersion": "Windows Version", + "iosVersion": "iOS Version", + "androidVersion": "Android Version", + "osVersion": "OS Version", + "kernelVersion": "Kernel Version", + "deviceModel": "Device Model", + "serialNumber": "Serial Number", + "hostname": "Hostname", + "firstSeen": "First Seen", + "lastSeen": "Last Seen", + "deviceSettingsDescription": "View device information and settings", + "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.", + "unblockClient": "Unblock Client", + "unblockClientDescription": "The device has been unblocked", + "unarchiveClient": "Unarchive Client", + "unarchiveClientDescription": "The device has been unarchived", + "block": "Block", + "unblock": "Unblock", + "deviceActions": "Device Actions", + "deviceActionsDescription": "Manage device status and access", + "devicePendingApprovalBannerDescription": "This device is pending approval. It won't be able to connect to resources until approved." } diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 709eb1a5..7917e037 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -49,6 +49,20 @@ export type GetClientResponse = NonNullable< Awaited> >["clients"] & { olmId: string | null; + agent: string | null; + olmVersion: string | null; + fingerprint: { + username: string | null; + hostname: string | null; + platform: string | null; + osVersion: string | null; + kernelVersion: string | null; + arch: string | null; + deviceModel: string | null; + serialNumber: string | null; + firstSeen: number | null; + lastSeen: number | null; + } | null; }; registry.registerPath({ @@ -115,10 +129,29 @@ export async function getClient( clientName = getUserDeviceName(model, client.clients.name); } + // Build fingerprint data if available + const fingerprintData = client.fingerprints + ? { + username: client.fingerprints.username || null, + hostname: client.fingerprints.hostname || null, + platform: client.fingerprints.platform || null, + osVersion: client.fingerprints.osVersion || null, + kernelVersion: client.fingerprints.kernelVersion || null, + arch: client.fingerprints.arch || null, + deviceModel: client.fingerprints.deviceModel || null, + serialNumber: client.fingerprints.serialNumber || null, + firstSeen: client.fingerprints.firstSeen || null, + lastSeen: client.fingerprints.lastSeen || null + } + : null; + const data: GetClientResponse = { ...client.clients, name: clientName, - olmId: client.olms ? client.olms.olmId : null + olmId: client.olms ? client.olms.olmId : null, + agent: client.olms?.agent || null, + olmVersion: client.olms?.version || null, + fingerprint: fingerprintData }; return response(res, { diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx index c2ef26e4..0e3d9b09 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx @@ -29,9 +29,11 @@ import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import ActionBanner from "@app/components/ActionBanner"; +import { Shield, ShieldOff } from "lucide-react"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -45,7 +47,9 @@ export default function GeneralPage() { const { client, updateClient } = useClientContext(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); const router = useRouter(); + const [, startTransition] = useTransition(); const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -109,8 +113,54 @@ export default function GeneralPage() { } } + const handleUnblock = async () => { + if (!client?.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/unblock`); + // Optimistically update the client context + updateClient({ blocked: false, approvalState: null }); + toast({ + title: t("unblockClient"), + description: t("unblockClientDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + return ( + {/* Blocked Device Banner */} + {client?.blocked && ( + } + description={t("deviceBlockedDescription")} + actions={ + + } + /> + )} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx new file mode 100644 index 00000000..179756e1 --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import ActionBanner from "@app/components/ActionBanner"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { useState, useEffect, useTransition } from "react"; +import { Check, Ban, Shield, ShieldOff, Clock } from "lucide-react"; +import { useParams } from "next/navigation"; +import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; +import { SiAndroid } from "react-icons/si"; + +function formatTimestamp(timestamp: number | null | undefined): string { + if (!timestamp) return "-"; + return new Date(timestamp * 1000).toLocaleString(); +} + +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; +} + +function getPlatformIcon(platform: string | null | undefined) { + if (!platform) return null; + const normalizedPlatform = platform.toLowerCase(); + switch (normalizedPlatform) { + case "macos": + case "ios": + return ; + case "windows": + return ; + case "linux": + return ; + case "android": + return ; + default: + return null; + } +} + +type FieldConfig = { + show: boolean; + labelKey: string; +}; + +function getPlatformFieldConfig( + platform: string | null | undefined +): Record { + const normalizedPlatform = platform?.toLowerCase() || "unknown"; + + const configs: Record> = { + macos: { + osVersion: { show: true, labelKey: "macosVersion" }, + kernelVersion: { show: false, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + windows: { + osVersion: { show: true, labelKey: "windowsVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + linux: { + osVersion: { show: true, labelKey: "osVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + ios: { + osVersion: { show: true, labelKey: "iosVersion" }, + kernelVersion: { show: false, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + android: { + osVersion: { show: true, labelKey: "androidVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + unknown: { + osVersion: { show: true, labelKey: "osVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + } + }; + + return configs[normalizedPlatform] || configs.unknown; +} + +export default function GeneralPage() { + const { client, updateClient } = useClientContext(); + const { isPaidUser } = usePaidStatus(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const params = useParams(); + const orgId = params.orgId as string; + const [approvalId, setApprovalId] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [, startTransition] = useTransition(); + + const showApprovalFeatures = build !== "oss" && isPaidUser; + + // Fetch approval ID for this client if pending + useEffect(() => { + if (showApprovalFeatures && client.approvalState === "pending" && client.clientId) { + api.get(`/org/${orgId}/approvals?approvalState=pending`) + .then((res) => { + const approval = res.data.data.approvals.find( + (a: any) => a.clientId === client.clientId + ); + if (approval) { + setApprovalId(approval.approvalId); + } + }) + .catch(() => { + // Silently fail - approval might not exist + }); + } + }, [showApprovalFeatures, client.approvalState, client.clientId, orgId, api]); + + const handleApprove = async () => { + if (!approvalId) return; + setIsRefreshing(true); + try { + await api.put(`/org/${orgId}/approvals/${approvalId}`, { + decision: "approved" + }); + // Optimistically update the client context + updateClient({ approvalState: "approved" }); + toast({ + title: t("accessApprovalUpdated"), + description: t("accessApprovalApprovedDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessApprovalErrorUpdate"), + description: formatAxiosError( + e, + t("accessApprovalErrorUpdateDescription") + ) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleDeny = async () => { + if (!approvalId) return; + setIsRefreshing(true); + try { + await api.put(`/org/${orgId}/approvals/${approvalId}`, { + decision: "denied" + }); + // Optimistically update the client context + updateClient({ approvalState: "denied", blocked: true }); + toast({ + title: t("accessApprovalUpdated"), + description: t("accessApprovalDeniedDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessApprovalErrorUpdate"), + description: formatAxiosError( + e, + t("accessApprovalErrorUpdateDescription") + ) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleBlock = async () => { + if (!client.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/block`); + // Optimistically update the client context + updateClient({ blocked: true, approvalState: "denied" }); + toast({ + title: t("blockClient"), + description: t("blockClientMessage") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleUnblock = async () => { + if (!client.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/unblock`); + // Optimistically update the client context + updateClient({ blocked: false, approvalState: null }); + toast({ + title: t("unblockClient"), + description: t("unblockClientDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + + + return ( + + {/* Pending Approval Banner */} + {showApprovalFeatures && client.approvalState === "pending" && ( + } + description={t("devicePendingApprovalBannerDescription")} + actions={ + <> + + + + } + /> + )} + + {/* Blocked Device Banner */} + {client.blocked && client.approvalState !== "pending" && ( + } + description={t("deviceBlockedDescription")} + actions={ + + } + /> + )} + + {/* Device Information Section */} + {(client.fingerprint || + (client.agent && client.olmVersion)) && ( + + + + {t("deviceInformation")} + + + {t("deviceInformationDescription")} + + + + + {client.agent && client.olmVersion && ( +
+ + + {t("agent")} + + + + {client.agent + " v" + client.olmVersion} + + + +
+ )} + + {client.fingerprint && (() => { + const platform = client.fingerprint.platform; + const fieldConfig = getPlatformFieldConfig(platform); + + return ( + + {platform && ( + + + {t("platform")} + + +
+ {getPlatformIcon(platform)} + {formatPlatform(platform)} +
+
+
+ )} + + {client.fingerprint.osVersion && + fieldConfig.osVersion.show && ( + + + {t(fieldConfig.osVersion.labelKey)} + + + {client.fingerprint.osVersion} + + + )} + + {client.fingerprint.kernelVersion && + fieldConfig.kernelVersion.show && ( + + + {t("kernelVersion")} + + + {client.fingerprint.kernelVersion} + + + )} + + {client.fingerprint.arch && + fieldConfig.arch.show && ( + + + {t("architecture")} + + + {client.fingerprint.arch} + + + )} + + {client.fingerprint.deviceModel && + fieldConfig.deviceModel.show && ( + + + {t("deviceModel")} + + + {client.fingerprint.deviceModel} + + + )} + + {client.fingerprint.serialNumber && + fieldConfig.serialNumber.show && ( + + + {t("serialNumber")} + + + {client.fingerprint.serialNumber} + + + )} + + {client.fingerprint.username && + fieldConfig.username.show && ( + + + {t("username")} + + + {client.fingerprint.username} + + + )} + + {client.fingerprint.hostname && + fieldConfig.hostname.show && ( + + + {t("hostname")} + + + {client.fingerprint.hostname} + + + )} + + {client.fingerprint.firstSeen && ( + + + {t("firstSeen")} + + + {formatTimestamp( + client.fingerprint.firstSeen + )} + + + )} + + {client.fingerprint.lastSeen && ( + + + {t("lastSeen")} + + + {formatTimestamp( + client.fingerprint.lastSeen + )} + + + )} +
+ ); + })()} +
+
+ )} +
+ ); +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx new file mode 100644 index 00000000..7d8059aa --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx @@ -0,0 +1,57 @@ +import ClientInfoCard from "@app/components/ClientInfoCard"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import ClientProvider from "@app/providers/ClientProvider"; +import { GetClientResponse } from "@server/routers/client"; +import { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +type SettingsLayoutProps = { + children: React.ReactNode; + params: Promise<{ niceId: number | string; orgId: string }>; +}; + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let client = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/client/${params.niceId}`, + await authCookieHeader() + ); + client = res.data.data; + } catch (error) { + redirect(`/${params.orgId}/settings/clients/user`); + } + + const t = await getTranslations(); + + const navItems = [ + { + title: t("general"), + href: `/${params.orgId}/settings/clients/user/${params.niceId}/general` + } + ]; + + return ( + <> + + + +
+ + {children} +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx new file mode 100644 index 00000000..9ad97186 --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function ClientPage(props: { + params: Promise<{ orgId: string; niceId: number | string }>; +}) { + const params = await props.params; + redirect( + `/${params.orgId}/settings/clients/user/${params.niceId}/general` + ); +} diff --git a/src/components/ActionBanner.tsx b/src/components/ActionBanner.tsx new file mode 100644 index 00000000..6e98b978 --- /dev/null +++ b/src/components/ActionBanner.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React, { type ReactNode } from "react"; +import { Card, CardContent } from "@app/components/ui/card"; +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { cva, type VariantProps } from "class-variance-authority"; + +const actionBannerVariants = cva( + "mb-6 relative overflow-hidden", + { + variants: { + variant: { + warning: "border-yellow-500/30 bg-gradient-to-br from-yellow-500/10 via-background to-background", + info: "border-blue-500/30 bg-gradient-to-br from-blue-500/10 via-background to-background", + success: "border-green-500/30 bg-gradient-to-br from-green-500/10 via-background to-background", + destructive: "border-red-500/30 bg-gradient-to-br from-red-500/10 via-background to-background", + default: "border-primary/30 bg-gradient-to-br from-primary/10 via-background to-background" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +const titleVariants = "text-lg font-semibold flex items-center gap-2"; + +const iconVariants = cva( + "w-5 h-5", + { + variants: { + variant: { + warning: "text-yellow-600 dark:text-yellow-500", + info: "text-blue-600 dark:text-blue-500", + success: "text-green-600 dark:text-green-500", + destructive: "text-red-600 dark:text-red-500", + default: "text-primary" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +type ActionBannerProps = { + title: string; + titleIcon?: ReactNode; + description: string; + actions?: ReactNode; + className?: string; +} & VariantProps; + +export function ActionBanner({ + title, + titleIcon, + description, + actions, + variant = "default", + className +}: ActionBannerProps) { + return ( + + +
+
+

+ {titleIcon && ( + + {titleIcon} + + )} + {title} +

+

+ {description} +

+
+ {actions && ( +
+ {actions} +
+ )} +
+
+
+ ); +} + +export default ActionBanner; diff --git a/src/components/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index 8e7fa5e7..a50b6039 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -19,7 +19,11 @@ export default function SiteInfoCard({}: ClientInfoCardProps) { return ( - + + + {t("name")} + {client.name} + {t("identifier")} {client.niceId} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index b1cc74a8..b00503c3 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -11,7 +11,7 @@ export function InfoSections({ }) { return (
( null ); @@ -152,8 +151,6 @@ export default function MachineClientsTable({ .then(() => { startTransition(() => { router.refresh(); - setIsBlockModalOpen(false); - setSelectedClient(null); }); }); }; @@ -421,8 +418,7 @@ export default function MachineClientsTable({ if (clientRow.blocked) { unblockClient(clientRow.id); } else { - setSelectedClient(clientRow); - setIsBlockModalOpen(true); + blockClient(clientRow.id); } }} > @@ -482,28 +478,6 @@ export default function MachineClientsTable({ title="Delete Client" /> )} - {selectedClient && ( - { - setIsBlockModalOpen(val); - if (!val) { - setSelectedClient(null); - } - }} - dialog={ -
-

{t("blockClientQuestion")}

-

{t("blockClientMessage")}

-
- } - buttonText={t("blockClientConfirm")} - onConfirm={async () => blockClient(selectedClient!.id)} - string={selectedClient.name} - title={t("blockClient")} - /> - )} - ( null ); @@ -152,8 +151,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { .then(() => { startTransition(() => { router.refresh(); - setIsBlockModalOpen(false); - setSelectedClient(null); }); }); }; @@ -457,8 +454,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { if (clientRow.blocked) { unblockClient(clientRow.id); } else { - setSelectedClient(clientRow); - setIsBlockModalOpen(true); + blockClient(clientRow.id); } }} > @@ -484,7 +480,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {