From b122eb257bec8ff61fe8757cd6d8acc98ad0c463 Mon Sep 17 00:00:00 2001 From: beLIEai Date: Fri, 10 Apr 2026 17:28:02 +0300 Subject: [PATCH 01/37] feat(device-security): add TypeScript interfaces and DeviceSecurityProvider --- src/contexts/DeviceSecurityProvider.tsx | 176 ++++++++++++++++++++++++ src/interfaces/DeviceSecurity.ts | 43 ++++++ 2 files changed, 219 insertions(+) create mode 100644 src/contexts/DeviceSecurityProvider.tsx create mode 100644 src/interfaces/DeviceSecurity.ts diff --git a/src/contexts/DeviceSecurityProvider.tsx b/src/contexts/DeviceSecurityProvider.tsx new file mode 100644 index 00000000..6fe765e7 --- /dev/null +++ b/src/contexts/DeviceSecurityProvider.tsx @@ -0,0 +1,176 @@ +"use client"; + +import useFetchApi, { useApiCall } from "@utils/api"; +import React, { useCallback, useMemo } from "react"; +import { useSWRConfig } from "swr"; +import type { + DeviceAuthSettings, + DeviceCert, + DeviceEnrollment, + TrustedCA, +} from "@/interfaces/DeviceSecurity"; + +type DeviceSecurityContextValue = { + // Settings + settings: DeviceAuthSettings | undefined; + settingsLoading: boolean; + updateSettings: (s: Partial) => Promise; + + // Enrollments + enrollments: DeviceEnrollment[] | undefined; + enrollmentsLoading: boolean; + approveEnrollment: (id: string) => Promise; + rejectEnrollment: (id: string, reason?: string) => Promise; + + // Devices (issued certs) + devices: DeviceCert[] | undefined; + devicesLoading: boolean; + revokeDevice: (id: string) => Promise; + renewDevice: (id: string) => Promise; + + // Trusted CAs + trustedCAs: TrustedCA[] | undefined; + trustedCAsLoading: boolean; + addTrustedCA: (name: string, pem: string) => Promise; + deleteTrustedCA: (id: string) => Promise; +}; + +const DeviceSecurityContext = React.createContext( + {} as DeviceSecurityContextValue, +); + +export function DeviceSecurityProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { mutate } = useSWRConfig(); + + const { data: settings, isLoading: settingsLoading } = + useFetchApi("/device-auth/settings"); + + const { data: enrollments, isLoading: enrollmentsLoading } = + useFetchApi( + "/device-auth/enrollments", + false, + true, + true, + { refreshInterval: 10_000 }, + ); + + const { data: devices, isLoading: devicesLoading } = + useFetchApi("/device-auth/devices"); + + const { data: trustedCAs, isLoading: trustedCAsLoading } = + useFetchApi("/device-auth/trusted-cas"); + + const settingsRequest = useApiCall( + "/device-auth/settings", + ); + const enrollmentRequest = useApiCall("/device-auth/enrollments"); + const deviceRequest = useApiCall("/device-auth/devices"); + const caRequest = useApiCall("/device-auth/trusted-cas"); + + const updateSettings = useCallback( + async (s: Partial) => { + const updated = await settingsRequest.put(s); + await mutate("/device-auth/settings"); + return updated; + }, + [settingsRequest, mutate], + ); + + const approveEnrollment = useCallback( + async (id: string) => { + await enrollmentRequest.post(null, `/${id}/approve`); + await mutate("/device-auth/enrollments"); + }, + [enrollmentRequest, mutate], + ); + + const rejectEnrollment = useCallback( + async (id: string, reason?: string) => { + await enrollmentRequest.post(reason ? { reason } : null, `/${id}/reject`); + await mutate("/device-auth/enrollments"); + }, + [enrollmentRequest, mutate], + ); + + const revokeDevice = useCallback( + async (id: string) => { + await deviceRequest.post(null, `/${id}/revoke`); + await mutate("/device-auth/devices"); + }, + [deviceRequest, mutate], + ); + + const renewDevice = useCallback( + async (id: string) => { + await deviceRequest.post(null, `/${id}/cert/renew`); + await mutate("/device-auth/devices"); + }, + [deviceRequest, mutate], + ); + + const addTrustedCA = useCallback( + async (name: string, pem: string) => { + const created = await caRequest.post({ name, pem }); + await mutate("/device-auth/trusted-cas"); + return created; + }, + [caRequest, mutate], + ); + + const deleteTrustedCA = useCallback( + async (id: string) => { + await caRequest.del(null, `/${id}`); + await mutate("/device-auth/trusted-cas"); + }, + [caRequest, mutate], + ); + + const value = useMemo( + () => ({ + settings, + settingsLoading, + updateSettings, + enrollments, + enrollmentsLoading, + approveEnrollment, + rejectEnrollment, + devices, + devicesLoading, + revokeDevice, + renewDevice, + trustedCAs, + trustedCAsLoading, + addTrustedCA, + deleteTrustedCA, + }), + [ + settings, + settingsLoading, + updateSettings, + enrollments, + enrollmentsLoading, + approveEnrollment, + rejectEnrollment, + devices, + devicesLoading, + revokeDevice, + renewDevice, + trustedCAs, + trustedCAsLoading, + addTrustedCA, + deleteTrustedCA, + ], + ); + + return ( + + {children} + + ); +} + +export const useDeviceSecurity = () => React.useContext(DeviceSecurityContext); diff --git a/src/interfaces/DeviceSecurity.ts b/src/interfaces/DeviceSecurity.ts new file mode 100644 index 00000000..a79de97d --- /dev/null +++ b/src/interfaces/DeviceSecurity.ts @@ -0,0 +1,43 @@ +export type DeviceAuthMode = + | "disabled" + | "optional" + | "cert-only" + | "cert-and-sso"; +export type EnrollmentMode = "manual" | "attestation" | "both"; +export type CAType = "builtin" | "external"; + +export interface DeviceAuthSettings { + mode: DeviceAuthMode; + enrollment_mode: EnrollmentMode; + ca_type: CAType; + cert_validity_days: number; + ocsp_enabled: boolean; + fail_open_on_ocsp_unavailable: boolean; + inventory_type: string; +} + +export interface DeviceEnrollment { + id: string; + peer_id: string; + wg_public_key: string; + status: "pending" | "approved" | "rejected"; + reason?: string; + created_at: string; +} + +export interface DeviceCert { + id: string; + peer_id: string; + wg_public_key: string; + serial: string; + not_before: string; + not_after: string; + revoked: boolean; + revoked_at?: string; +} + +export interface TrustedCA { + id: string; + name: string; + created_at: string; +} From b69c6eff5c2e6fea53e686bf3ee488a4f8041c07 Mon Sep 17 00:00:00 2001 From: beLIEai Date: Fri, 10 Apr 2026 17:48:45 +0300 Subject: [PATCH 02/37] feat: add device security settings page Add DeviceSecuritySettings form component with controls for authentication mode, enrollment mode, CA type, certificate validity, and OCSP settings. Includes cert-only mode warning when no devices have active certificates. --- .../device-security/settings/page.tsx | 12 + .../DeviceSecuritySettings.tsx | 330 ++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 src/app/(dashboard)/device-security/settings/page.tsx create mode 100644 src/modules/device-security/DeviceSecuritySettings.tsx diff --git a/src/app/(dashboard)/device-security/settings/page.tsx b/src/app/(dashboard)/device-security/settings/page.tsx new file mode 100644 index 00000000..3917d8c8 --- /dev/null +++ b/src/app/(dashboard)/device-security/settings/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import DeviceSecuritySettings from "@/modules/device-security/DeviceSecuritySettings"; +import { DeviceSecurityProvider } from "@/contexts/DeviceSecurityProvider"; + +export default function SettingsPage() { + return ( + + + + ); +} diff --git a/src/modules/device-security/DeviceSecuritySettings.tsx b/src/modules/device-security/DeviceSecuritySettings.tsx new file mode 100644 index 00000000..e907e50d --- /dev/null +++ b/src/modules/device-security/DeviceSecuritySettings.tsx @@ -0,0 +1,330 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Button from "@components/Button"; +import { Callout } from "@components/Callout"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { notify } from "@components/Notification"; +import Paragraph from "@components/Paragraph"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/Select"; +import { useHasChanges } from "@hooks/useHasChanges"; +import { + AlertTriangleIcon, + KeyIcon, + ShieldCheckIcon, +} from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; +import Skeleton from "react-loading-skeleton"; +import { useDeviceSecurity } from "@/contexts/DeviceSecurityProvider"; +import type { + CAType, + DeviceAuthMode, + EnrollmentMode, +} from "@/interfaces/DeviceSecurity"; +import PageContainer from "@/layouts/PageContainer"; + +const MODE_LABELS: Record = { + disabled: "Disabled", + optional: "Optional", + "cert-only": "Certificate Only", + "cert-and-sso": "Certificate + SSO", +}; + +const MODE_DESCRIPTIONS: Record = { + disabled: "Device certificate authentication is not used", + optional: "Devices may authenticate with certificates but it is not required", + "cert-only": "Only devices with valid certificates can connect", + "cert-and-sso": "Devices must have a valid certificate and SSO authentication", +}; + +const ENROLLMENT_LABELS: Record = { + manual: "Manual", + attestation: "Attestation", + both: "Both", +}; + +const CA_TYPE_LABELS: Record = { + builtin: "Built-in CA", + external: "External CA", +}; + +export default function DeviceSecuritySettings() { + const { settings, settingsLoading, updateSettings, devices } = + useDeviceSecurity(); + + const [mode, setMode] = useState("disabled"); + const [enrollmentMode, setEnrollmentMode] = useState("manual"); + const [caType, setCaType] = useState("builtin"); + const [certValidityDays, setCertValidityDays] = useState(365); + const [ocspEnabled, setOcspEnabled] = useState(false); + const [failOpenOnOcsp, setFailOpenOnOcsp] = useState(false); + + useEffect(() => { + if (!settings) return; + setMode(settings.mode); + setEnrollmentMode(settings.enrollment_mode); + setCaType(settings.ca_type); + setCertValidityDays(settings.cert_validity_days); + setOcspEnabled(settings.ocsp_enabled); + setFailOpenOnOcsp(settings.fail_open_on_ocsp_unavailable); + }, [settings]); + + const { hasChanges, updateRef } = useHasChanges([ + mode, + enrollmentMode, + caType, + certValidityDays, + ocspEnabled, + failOpenOnOcsp, + ]); + + const activeCertCount = + devices?.filter((d) => !d.revoked).length ?? 0; + + const showCertOnlyWarning = + mode === "cert-only" && activeCertCount === 0; + + const handleSave = useCallback(async () => { + notify({ + title: "Device Security Settings", + description: "Settings saved successfully.", + promise: updateSettings({ + mode, + enrollment_mode: enrollmentMode, + ca_type: caType, + cert_validity_days: certValidityDays, + ocsp_enabled: ocspEnabled, + fail_open_on_ocsp_unavailable: failOpenOnOcsp, + }).then(() => { + updateRef([ + mode, + enrollmentMode, + caType, + certValidityDays, + ocspEnabled, + failOpenOnOcsp, + ]); + }), + loadingMessage: "Saving device security settings...", + }); + }, [ + mode, + enrollmentMode, + caType, + certValidityDays, + ocspEnabled, + failOpenOnOcsp, + updateSettings, + updateRef, + ]); + + if (settingsLoading) { + return ( + +
+ + +
+ + +
+
+ + +
+ +
+
+ ); + } + + return ( + +
+ + } + /> + } + /> + + +
+
+

Device Security Settings

+ + Configure device certificate authentication and enrollment. + +
+ +
+ +
+ {/* Authentication Mode */} + + + + + {showCertOnlyWarning && ( + }> + No devices currently have active certificates. Switching to + certificate-only mode may lock out all devices. + + )} + + {/* Enrollment Mode */} + + + + + {/* CA Type */} + + + + + {/* Certificate Validity */} + + + setCertValidityDays(parseInt(e.target.value, 10) || 0) + } + data-cy={"cert-validity-days"} + /> + + + {/* OCSP Enabled */} + + + Enable OCSP + + } + helpText={ + "Enable Online Certificate Status Protocol for real-time certificate revocation checking" + } + /> + + {/* Fail Open on OCSP Unavailable */} + {ocspEnabled && ( + + )} +
+
+
+ ); +} + +function SettingRow({ + label, + help, + children, +}: Readonly<{ + label: string; + help: string; + children: React.ReactNode; +}>) { + return ( +
+
+ + {help} +
+
{children}
+
+ ); +} From eeed710e5c15bddc6f34cb965d2cf0cc387188d0 Mon Sep 17 00:00:00 2001 From: beLIEai Date: Fri, 10 Apr 2026 17:51:21 +0300 Subject: [PATCH 03/37] feat: add device security enrollments page Add enrollments table with approve/reject actions for pending device enrollment requests. Follows existing DataTable patterns with Badge status indicators, truncated IDs, and auto-refresh via provider. --- .../device-security/enrollments/page.tsx | 45 ++++ .../device-security/EnrollmentsTable.tsx | 217 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/app/(dashboard)/device-security/enrollments/page.tsx create mode 100644 src/modules/device-security/EnrollmentsTable.tsx diff --git a/src/app/(dashboard)/device-security/enrollments/page.tsx b/src/app/(dashboard)/device-security/enrollments/page.tsx new file mode 100644 index 00000000..5e9d7297 --- /dev/null +++ b/src/app/(dashboard)/device-security/enrollments/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import { ShieldCheckIcon } from "lucide-react"; +import React, { lazy, Suspense } from "react"; +import { DeviceSecurityProvider } from "@/contexts/DeviceSecurityProvider"; +import PageContainer from "@/layouts/PageContainer"; + +const EnrollmentsTable = lazy( + () => import("@/modules/device-security/EnrollmentsTable"), +); + +export default function EnrollmentsPage() { + return ( + + +
+ + } + /> + + +

Device Enrollments

+ + Review and manage device enrollment requests. Approve or reject + pending requests to control which devices can connect to your + network. + +
+ }> + + +
+
+ ); +} diff --git a/src/modules/device-security/EnrollmentsTable.tsx b/src/modules/device-security/EnrollmentsTable.tsx new file mode 100644 index 00000000..fb3bb80d --- /dev/null +++ b/src/modules/device-security/EnrollmentsTable.tsx @@ -0,0 +1,217 @@ +"use client"; + +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import NoResults from "@components/ui/NoResults"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import dayjs from "dayjs"; +import { CheckCircleIcon, ShieldAlertIcon, XCircleIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import React, { useCallback } from "react"; +import { useSWRConfig } from "swr"; +import { useDeviceSecurity } from "@/contexts/DeviceSecurityProvider"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; +import type { DeviceEnrollment } from "@/interfaces/DeviceSecurity"; + +const STATUS_BADGE_VARIANT = { + pending: "yellow", + approved: "green", + rejected: "red", +} as const; + +const STATUS_LABEL = { + pending: "Pending", + approved: "Approved", + rejected: "Rejected", +} as const; + +function truncate(value: string, length = 12): string { + if (value.length <= length) return value; + return `${value.slice(0, length)}...`; +} + +function formatDate(iso: string): string { + return dayjs(iso).format("MMM D, YYYY HH:mm"); +} + +function StatusBadge({ + status, +}: Readonly<{ status: DeviceEnrollment["status"] }>) { + return ( + + {STATUS_LABEL[status]} + + ); +} + +function ActionsCell({ + enrollment, + onApprove, + onReject, +}: Readonly<{ + enrollment: DeviceEnrollment; + onApprove: (id: string) => void; + onReject: (id: string) => void; +}>) { + if (enrollment.status !== "pending") { + return null; + } + + return ( +
+ + +
+ ); +} + +function buildColumns( + onApprove: (id: string) => void, + onReject: (id: string) => void, +): ColumnDef[] { + return [ + { + accessorKey: "id", + header: ({ column }) => ( + ID + ), + sortingFn: "text", + cell: ({ row }) => ( + {truncate(row.original.id)} + ), + }, + { + accessorKey: "peer_id", + header: ({ column }) => ( + Peer ID + ), + sortingFn: "text", + cell: ({ row }) => ( + + {truncate(row.original.peer_id)} + + ), + }, + { + accessorKey: "wg_public_key", + header: ({ column }) => ( + WG Public Key + ), + sortingFn: "text", + cell: ({ row }) => ( + + {truncate(row.original.wg_public_key, 16)} + + ), + }, + { + accessorKey: "status", + header: ({ column }) => ( + Status + ), + cell: ({ row }) => , + }, + { + accessorKey: "created_at", + header: ({ column }) => ( + Created + ), + sortingFn: "datetime", + cell: ({ row }) => {formatDate(row.original.created_at)}, + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( + + ), + }, + ]; +} + +export default function EnrollmentsTable() { + const { + enrollments, + enrollmentsLoading, + approveEnrollment, + rejectEnrollment, + } = useDeviceSecurity(); + const { mutate } = useSWRConfig(); + const path = usePathname(); + + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort" + path, + [{ id: "created_at", desc: true }], + ); + + const handleApprove = useCallback( + async (id: string) => { + await approveEnrollment(id); + }, + [approveEnrollment], + ); + + const handleReject = useCallback( + async (id: string) => { + await rejectEnrollment(id); + }, + [rejectEnrollment], + ); + + const columns = React.useMemo( + () => buildColumns(handleApprove, handleReject), + [handleApprove, handleReject], + ); + + if (!enrollmentsLoading && (!enrollments || enrollments.length === 0)) { + return ( + } + /> + ); + } + + return ( + + {() => ( + { + mutate("/device-auth/enrollments"); + }} + /> + )} + + ); +} From 5e51fb1f80a573d743cd0ba066189b4381596c07 Mon Sep 17 00:00:00 2001 From: beLIEai Date: Fri, 10 Apr 2026 17:54:54 +0300 Subject: [PATCH 04/37] feat: add device security trusted CAs page Add AddTrustedCAModal, TrustedCAsTable, and trusted-cas page for managing trusted Certificate Authorities in the device security dashboard section. --- .../device-security/devices/page.tsx | 12 + .../device-security/trusted-cas/page.tsx | 12 + .../device-security/AddTrustedCAModal.tsx | 136 ++++++++++++ src/modules/device-security/DevicesTable.tsx | 205 ++++++++++++++++++ .../device-security/RevokeDeviceModal.tsx | 93 ++++++++ .../device-security/TrustedCAsTable.tsx | 152 +++++++++++++ 6 files changed, 610 insertions(+) create mode 100644 src/app/(dashboard)/device-security/devices/page.tsx create mode 100644 src/app/(dashboard)/device-security/trusted-cas/page.tsx create mode 100644 src/modules/device-security/AddTrustedCAModal.tsx create mode 100644 src/modules/device-security/DevicesTable.tsx create mode 100644 src/modules/device-security/RevokeDeviceModal.tsx create mode 100644 src/modules/device-security/TrustedCAsTable.tsx diff --git a/src/app/(dashboard)/device-security/devices/page.tsx b/src/app/(dashboard)/device-security/devices/page.tsx new file mode 100644 index 00000000..4757659a --- /dev/null +++ b/src/app/(dashboard)/device-security/devices/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { DeviceSecurityProvider } from "@/contexts/DeviceSecurityProvider"; +import DevicesTable from "@/modules/device-security/DevicesTable"; + +export default function DevicesPage() { + return ( + + + + ); +} diff --git a/src/app/(dashboard)/device-security/trusted-cas/page.tsx b/src/app/(dashboard)/device-security/trusted-cas/page.tsx new file mode 100644 index 00000000..d18ef565 --- /dev/null +++ b/src/app/(dashboard)/device-security/trusted-cas/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { DeviceSecurityProvider } from "@/contexts/DeviceSecurityProvider"; +import TrustedCAsTable from "@/modules/device-security/TrustedCAsTable"; + +export default function TrustedCAsPage() { + return ( + + + + ); +} diff --git a/src/modules/device-security/AddTrustedCAModal.tsx b/src/modules/device-security/AddTrustedCAModal.tsx new file mode 100644 index 00000000..d9915151 --- /dev/null +++ b/src/modules/device-security/AddTrustedCAModal.tsx @@ -0,0 +1,136 @@ +"use client"; + +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalTrigger, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { notify } from "@components/Notification"; +import Separator from "@components/Separator"; +import { Textarea } from "@components/Textarea"; +import { ShieldCheckIcon, PlusCircle } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useDeviceSecurity } from "@/contexts/DeviceSecurityProvider"; + +const PEM_HEADER = "-----BEGIN CERTIFICATE-----"; + +type Props = { + children: React.ReactNode; +}; + +export default function AddTrustedCAModal({ children }: Readonly) { + const [open, setOpen] = useState(false); + + return ( + + {children} + setOpen(false)} /> + + ); +} + +type ContentProps = { + onSuccess: () => void; +}; + +function AddTrustedCAModalContent({ onSuccess }: Readonly) { + const { addTrustedCA } = useDeviceSecurity(); + + const [name, setName] = useState(""); + const [pem, setPem] = useState(""); + + const pemError = useMemo(() => { + const trimmed = pem.trim(); + if (trimmed.length === 0) return undefined; + if (!trimmed.startsWith(PEM_HEADER)) { + return `PEM certificate must start with "${PEM_HEADER}"`; + } + return undefined; + }, [pem]); + + const isDisabled = useMemo(() => { + return ( + name.trim().length === 0 || + pem.trim().length === 0 || + pemError !== undefined + ); + }, [name, pem, pemError]); + + const handleSubmit = () => { + const trimmedName = name.trim(); + const trimmedPem = pem.trim(); + + notify({ + title: "Adding trusted CA", + description: `"${trimmedName}" was added successfully`, + promise: addTrustedCA(trimmedName, trimmedPem).then(() => { + onSuccess(); + }), + loadingMessage: "Adding trusted CA...", + }); + }; + + return ( + + } + title="Add Trusted CA" + description="Upload a trusted Certificate Authority to authenticate devices" + color="netbird" + /> + + + +
+
+ + A descriptive name for this Certificate Authority + setName(e.target.value)} + /> +
+ +
+ + + Paste the PEM-encoded certificate. Must begin with{" "} + {PEM_HEADER} + +