From 87253c3e94ea0164d45cf4bae6d265c8e9cb5134 Mon Sep 17 00:00:00 2001 From: thvevirtue Date: Fri, 24 Apr 2026 10:25:45 +0200 Subject: [PATCH] Add Entra device authentication admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the dashboard-facing half of the Entra device auth feature delivered in the backend (netbirdio/netbird#5977). Adds: - New page under /integrations/entra-device-auth with two tabs: * Configuration (singleton integration config: tenant/client/secret, issuer, audience, Intune compliance toggle, tenant-only fallback + fallback auto-groups, mapping resolution strategy, revalidation interval). * Mappings (CRUD list of Entra security group → NetBird auto-groups mappings with ephemeral, extra-DNS-labels, expires-in, priority, revoked). - Sidebar navigation entry gated on the optional entra_device_auth permission module (defaults to visible for backward compatibility). - TypeScript interfaces mirroring the backend integrationDTO / mappingDTO shapes. - Optional entra_device_auth permission field on Permissions to remain compatible with management servers that don't publish the new module yet. UI patterns follow existing setup-keys and identity-providers modules. Uses the existing useApiCall/useFetchApi hooks, PeerGroupSelector for auto-groups, FancyToggleSwitch for booleans, and notify() for operation feedback. --- .../integrations/entra-device-auth/layout.tsx | 9 + .../integrations/entra-device-auth/page.tsx | 85 ++++ src/app/(dashboard)/integrations/layout.tsx | 3 + src/assets/icons/EntraDeviceIcon.tsx | 41 ++ src/interfaces/EntraDeviceAuth.ts | 97 ++++ src/interfaces/Permission.ts | 7 + src/layouts/Navigation.tsx | 22 + .../EntraDeviceAuthConfig.tsx | 415 ++++++++++++++++++ .../EntraDeviceMappingActionCell.tsx | 108 +++++ .../EntraDeviceMappingModal.tsx | 295 +++++++++++++ .../EntraDeviceMappingsTable.tsx | 304 +++++++++++++ 11 files changed, 1386 insertions(+) create mode 100644 src/app/(dashboard)/integrations/entra-device-auth/layout.tsx create mode 100644 src/app/(dashboard)/integrations/entra-device-auth/page.tsx create mode 100644 src/app/(dashboard)/integrations/layout.tsx create mode 100644 src/assets/icons/EntraDeviceIcon.tsx create mode 100644 src/interfaces/EntraDeviceAuth.ts create mode 100644 src/modules/integrations/entra-device-auth/EntraDeviceAuthConfig.tsx create mode 100644 src/modules/integrations/entra-device-auth/EntraDeviceMappingActionCell.tsx create mode 100644 src/modules/integrations/entra-device-auth/EntraDeviceMappingModal.tsx create mode 100644 src/modules/integrations/entra-device-auth/EntraDeviceMappingsTable.tsx diff --git a/src/app/(dashboard)/integrations/entra-device-auth/layout.tsx b/src/app/(dashboard)/integrations/entra-device-auth/layout.tsx new file mode 100644 index 00000000..a839fd8e --- /dev/null +++ b/src/app/(dashboard)/integrations/entra-device-auth/layout.tsx @@ -0,0 +1,9 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Entra Device Auth - ${globalMetaTitle}`, +}; + +export default BlankLayout; diff --git a/src/app/(dashboard)/integrations/entra-device-auth/page.tsx b/src/app/(dashboard)/integrations/entra-device-auth/page.tsx new file mode 100644 index 00000000..fff8bead --- /dev/null +++ b/src/app/(dashboard)/integrations/entra-device-auth/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { FingerprintIcon, FolderGit2Icon, SettingsIcon } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import React, { useMemo } from "react"; +import EntraDeviceIcon from "@/assets/icons/EntraDeviceIcon"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import PageContainer from "@/layouts/PageContainer"; +import EntraDeviceAuthConfig from "@/modules/integrations/entra-device-auth/EntraDeviceAuthConfig"; +import EntraDeviceMappingsTable from "@/modules/integrations/entra-device-auth/EntraDeviceMappingsTable"; + +/** + * Admin UI for the Entra device authentication integration. + * + * This page is a thin shell that hosts two horizontally-tabbed panels: + * 1. Configuration — singleton integration settings. + * 2. Mappings — Entra security group → NetBird auto-groups CRUD. + * + * Permissions use optional chaining (`permission?.entra_device_auth?.read`) + * so that the page stays usable against a management server that doesn't + * publish the new permission module yet; admin writes will still be rejected + * server-side in that case. + */ +export default function EntraDeviceAuthPage() { + const { permission } = usePermissions(); + const queryParams = useSearchParams(); + const initialTab = useMemo(() => queryParams.get("tab") ?? "config", [ + queryParams, + ]); + + const canRead = permission?.entra_device_auth?.read ?? true; + + return ( + +
+ + } + /> + } + active + /> + +

Entra Device Auth

+ + Zero-touch device enrollment for Microsoft Entra-joined machines. + Devices hitting /join/entra on the + management URL prove their identity with the Entra device + certificate and are automatically placed into NetBird groups based + on their Entra security-group membership. + +
+ + + + + + + Configuration + + + + Mappings + + + + + + + + + + +
+ ); +} diff --git a/src/app/(dashboard)/integrations/layout.tsx b/src/app/(dashboard)/integrations/layout.tsx new file mode 100644 index 00000000..62e36766 --- /dev/null +++ b/src/app/(dashboard)/integrations/layout.tsx @@ -0,0 +1,3 @@ +import BlankLayout from "@/layouts/BlankLayout"; + +export default BlankLayout; diff --git a/src/assets/icons/EntraDeviceIcon.tsx b/src/assets/icons/EntraDeviceIcon.tsx new file mode 100644 index 00000000..a41adc03 --- /dev/null +++ b/src/assets/icons/EntraDeviceIcon.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; + +type Props = { + size?: number; + className?: string; +}; + +/** + * A small, neutral icon for the Entra device authentication pages. + * + * We deliberately avoid Microsoft/Entra trademark-coloured assets: this icon + * depicts a device with a fingerprint/shield overlay, matching the rest of + * NetBird's internal icon set. + */ +export default function EntraDeviceIcon({ + size = 16, + className, +}: Readonly) { + return ( + + ); +} diff --git a/src/interfaces/EntraDeviceAuth.ts b/src/interfaces/EntraDeviceAuth.ts new file mode 100644 index 00000000..50e27fa6 --- /dev/null +++ b/src/interfaces/EntraDeviceAuth.ts @@ -0,0 +1,97 @@ +import { Group } from "@/interfaces/Group"; + +/** + * Resolution strategy for a device that matches multiple mappings. + * Mirrors the backend's `types.MappingResolution`. + */ +export type EntraDeviceMappingResolution = "strict_priority" | "union"; + +export const EntraDeviceMappingResolutionOptions: { + value: EntraDeviceMappingResolution; + label: string; + description: string; +}[] = [ + { + value: "strict_priority", + label: "Strict priority", + description: + "Apply only the single mapping with the lowest priority. Ties are broken deterministically by ID.", + }, + { + value: "union", + label: "Union", + description: + "Apply all matched mappings: union of auto-groups, OR on ephemeral, AND on extra DNS labels, earliest expiry wins.", + }, +]; + +/** + * Integration-level configuration for Microsoft Entra device authentication. + * Mirrors `integrationDTO` in management/server/http/handlers/entra_device_auth/handler.go. + */ +export interface EntraDeviceAuth { + id?: string; + tenant_id: string; + client_id: string; + /** Write-only; server returns "********" when a secret is already stored. */ + client_secret?: string; + issuer?: string; + audience?: string; + enabled: boolean; + require_intune_compliant: boolean; + allow_tenant_only_fallback: boolean; + fallback_auto_groups?: string[]; + mapping_resolution?: EntraDeviceMappingResolution; + /** Go-duration string, e.g. "24h". Empty string disables revalidation. */ + revalidation_interval?: string; + created_at?: string; + updated_at?: string; +} + +export interface EntraDeviceAuthRequest { + tenant_id: string; + client_id: string; + client_secret?: string; + issuer?: string; + audience?: string; + enabled: boolean; + require_intune_compliant: boolean; + allow_tenant_only_fallback: boolean; + fallback_auto_groups?: string[]; + mapping_resolution?: EntraDeviceMappingResolution; + revalidation_interval?: string; +} + +/** + * A single Entra-group → NetBird-auto-groups mapping. + * Mirrors `mappingDTO` in management/server/http/handlers/entra_device_auth/handler.go. + */ +export interface EntraDeviceMapping { + id?: string; + name: string; + /** Entra Object ID (GUID) of the source security group. "*" for catch-all. */ + entra_group_id: string; + auto_groups: string[]; + ephemeral: boolean; + allow_extra_dns_labels: boolean; + /** RFC3339 timestamp or null for "never". */ + expires_at?: string | null; + revoked: boolean; + priority: number; + created_at?: string; + updated_at?: string; + + // Frontend-only decoration (resolved from /groups). + groups?: Group[]; +} + +export interface EntraDeviceMappingRequest { + name: string; + entra_group_id: string; + auto_groups: string[]; + ephemeral: boolean; + allow_extra_dns_labels: boolean; + expires_at?: string | null; + revoked: boolean; + priority: number; +} diff --git a/src/interfaces/Permission.ts b/src/interfaces/Permission.ts index 4eb77d2d..986ea39e 100644 --- a/src/interfaces/Permission.ts +++ b/src/interfaces/Permission.ts @@ -35,6 +35,13 @@ export interface Permissions { proxy_configuration: Permission; services: Permission; + + /** + * Entra device authentication integration (optional so the UI stays + * backwards-compatible with management servers that don't expose this + * module yet — all call sites use optional chaining). + */ + entra_device_auth?: Permission; }; } diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx index 4f14c695..a34d8f23 100644 --- a/src/layouts/Navigation.tsx +++ b/src/layouts/Navigation.tsx @@ -21,6 +21,7 @@ import { SmallBadge } from "@components/ui/SmallBadge"; import * as React from "react"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; import ActivityIcon from "@/assets/icons/ActivityIcon"; +import EntraDeviceIcon from "@/assets/icons/EntraDeviceIcon"; type Props = { fullWidth?: boolean; @@ -213,6 +214,7 @@ export default function Navigation({ /> + @@ -283,3 +285,23 @@ const ActivityNavigationItem = () => { ); }; + +// Entra device authentication admin UI. The permission module is optional on +// the backend for backwards compatibility, so we default to showing the link +// when `entra_device_auth` isn't published — the server-side permission +// check remains the source of truth, and admins on older builds will simply +// see a 403 when they hit the endpoints. +const EntraDeviceAuthNavigationItem = () => { + const { permission } = usePermissions(); + const visible = permission?.entra_device_auth?.read ?? true; + + return ( + } + label="Entra Device Auth" + href={"/integrations/entra-device-auth"} + exactPathMatch={false} + visible={visible} + /> + ); +}; diff --git a/src/modules/integrations/entra-device-auth/EntraDeviceAuthConfig.tsx b/src/modules/integrations/entra-device-auth/EntraDeviceAuthConfig.tsx new file mode 100644 index 00000000..053571ff --- /dev/null +++ b/src/modules/integrations/entra-device-auth/EntraDeviceAuthConfig.tsx @@ -0,0 +1,415 @@ +import Button from "@components/Button"; +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 { PeerGroupSelector } from "@components/PeerGroupSelector"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/Select"; +import Separator from "@components/Separator"; +import { useDialog } from "@/contexts/DialogProvider"; +import useFetchApi, { useApiCall } from "@utils/api"; +import { trim } from "lodash"; +import { + AlarmClock, + GlobeIcon, + IdCard, + KeyIcon, + SaveIcon, + ShieldCheckIcon, + TagIcon, + Trash2, +} from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { + EntraDeviceAuth, + EntraDeviceAuthRequest, + EntraDeviceMappingResolution, + EntraDeviceMappingResolutionOptions, +} from "@/interfaces/EntraDeviceAuth"; +import { Group } from "@/interfaces/Group"; +import useGroupHelper from "@/modules/groups/useGroupHelper"; + +/** + * Entra device auth integration configuration (singleton per account). + * + * Mirrors the backend admin endpoint `/api/integrations/entra-device-auth`. + * Allows creating / updating the integration as well as wiping it. + */ +export default function EntraDeviceAuthConfig() { + const { permission } = usePermissions(); + const { mutate } = useSWRConfig(); + const { confirm } = useDialog(); + + // Silently tolerate 404 (integration not yet configured) via ignoreError. + const { data, isLoading } = useFetchApi( + "/integrations/entra-device-auth", + true, + ); + + const saveRequest = useApiCall( + "/integrations/entra-device-auth", + ); + const deleteRequest = useApiCall( + "/integrations/entra-device-auth", + ); + + const canRead = permission?.entra_device_auth?.read ?? true; + const canCreate = permission?.entra_device_auth?.create ?? true; + const canUpdate = permission?.entra_device_auth?.update ?? true; + const canDelete = permission?.entra_device_auth?.delete ?? true; + + // Form state + const [tenantId, setTenantId] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [issuer, setIssuer] = useState(""); + const [audience, setAudience] = useState(""); + const [enabled, setEnabled] = useState(true); + const [requireIntune, setRequireIntune] = useState(false); + const [allowTenantOnlyFallback, setAllowTenantOnlyFallback] = useState(false); + const [mappingResolution, setMappingResolution] = + useState("strict_priority"); + const [revalidationInterval, setRevalidationInterval] = useState(""); + + const [fallbackGroups, setFallbackGroups, { save: saveFallbackGroups }] = + useGroupHelper({ + initial: [], + }); + + // Hydrate from server-side config once it arrives. + useEffect(() => { + if (!data) return; + setTenantId(data.tenant_id ?? ""); + setClientId(data.client_id ?? ""); + // The server returns "********" when a secret is already stored; keep + // the UI empty so we don't accidentally echo it back as plaintext. + setClientSecret(""); + setIssuer(data.issuer ?? ""); + setAudience(data.audience ?? ""); + setEnabled(!!data.enabled); + setRequireIntune(!!data.require_intune_compliant); + setAllowTenantOnlyFallback(!!data.allow_tenant_only_fallback); + setMappingResolution(data.mapping_resolution ?? "strict_priority"); + setRevalidationInterval(data.revalidation_interval ?? ""); + }, [data]); + + const isEditing = !!data?.id; + const hasSecretStored = !!data && !!data.client_secret; // "********" when present + const resolvedIssuer = useMemo( + () => + issuer || + (tenantId + ? `https://login.microsoftonline.com/${tenantId}/v2.0` + : "https://login.microsoftonline.com/{tenant}/v2.0"), + [issuer, tenantId], + ); + + const isDisabled = useMemo(() => { + if (!trim(tenantId) || !trim(clientId)) return true; + // Require a secret only when we don't already have one stored. + if (!hasSecretStored && !trim(clientSecret)) return true; + return false; + }, [tenantId, clientId, clientSecret, hasSecretStored]); + + const buildRequest = async (): Promise => { + const groups = await saveFallbackGroups(); + return { + tenant_id: trim(tenantId), + client_id: trim(clientId), + // Only send the secret when the operator has actually typed a new + // one — the backend preserves the existing value otherwise. + client_secret: trim(clientSecret) || undefined, + issuer: trim(issuer) || undefined, + audience: trim(audience) || undefined, + enabled, + require_intune_compliant: requireIntune, + allow_tenant_only_fallback: allowTenantOnlyFallback, + fallback_auto_groups: groups.map((g: Group) => g.id!).filter(Boolean), + mapping_resolution: mappingResolution, + revalidation_interval: trim(revalidationInterval) || undefined, + }; + }; + + const submit = async () => { + const payload = await buildRequest(); + notify({ + title: "Entra Device Auth", + description: isEditing + ? "Integration updated successfully." + : "Integration configured successfully.", + promise: (isEditing ? saveRequest.put(payload) : saveRequest.post(payload)).then( + () => { + mutate("/integrations/entra-device-auth"); + mutate("/integrations/entra-device-auth/mappings"); + setClientSecret(""); // prevent the secret from lingering in state + }, + ), + loadingMessage: isEditing + ? "Updating integration..." + : "Configuring integration...", + }); + }; + + const handleDelete = async () => { + const choice = await confirm({ + title: "Delete Entra Device Auth integration?", + description: + "This disables zero-touch Entra enrollment and removes all mappings. Peers already joined via Entra will stay registered but won't re-authenticate.", + confirmText: "Delete", + cancelText: "Cancel", + type: "danger", + }); + if (!choice) return; + notify({ + title: "Entra Device Auth", + description: "Integration deleted.", + promise: deleteRequest.del().then(() => { + mutate("/integrations/entra-device-auth"); + mutate("/integrations/entra-device-auth/mappings"); + }), + loadingMessage: "Deleting integration...", + }); + }; + + if (!canRead) { + return ( + + You don't have permission to view the Entra device authentication + integration. + + ); + } + + return ( +
+
+
+
+

Configuration

+ + Connect NetBird to your Microsoft Entra tenant so Entra-joined + devices can enroll without user interaction. Dedicated endpoint:{" "} + /join/entra + +
+ {isEditing && ( + + )} +
+
+ +
+
+ + + Microsoft Entra tenant GUID (the directory NetBird will trust). + + setTenantId(e.target.value)} + customPrefix={} + disabled={isLoading} + /> +
+ +
+ + + Application registered in Entra used for Microsoft Graph lookups. + + setClientId(e.target.value)} + customPrefix={} + disabled={isLoading} + /> +
+ +
+ + + {hasSecretStored + ? "A secret is already stored. Leave empty to keep it, or enter a new value to rotate." + : "Client secret for the app registration. Required on first setup."} + + setClientSecret(e.target.value)} + customPrefix={} + disabled={isLoading || !(canCreate || canUpdate)} + /> +
+ +
+ + + OIDC issuer used when validating device tokens. Defaults to{" "} + {resolvedIssuer} when empty. + + setIssuer(e.target.value)} + customPrefix={} + disabled={isLoading} + /> +
+ +
+ + + Expected aud claim for Entra + device tokens. Leave empty when using the default Entra app URI. + + setAudience(e.target.value)} + customPrefix={} + disabled={isLoading} + /> +
+ + + + + + Enabled + + } + helpText="When disabled, the /join/entra endpoint rejects all requests for this account." + disabled={!canUpdate && isEditing} + /> + + + + Require Intune compliance + + } + helpText="Only allow devices marked as compliant by Microsoft Intune. Requires DeviceManagementManagedDevices.Read.All." + disabled={!canUpdate && isEditing} + /> + + + + Allow tenant-only fallback + + } + helpText="If no group-scoped mapping matches, apply the fallback auto-groups below for any valid device from this tenant." + disabled={!canUpdate && isEditing} + /> + + {allowTenantOnlyFallback && ( +
+ + + NetBird groups applied when the tenant-only fallback kicks in. + + +
+ )} + +
+ + + How to combine results when a device matches multiple mappings. + + +
+ +
+ + + Go-duration string (e.g. 24h,{" "} + 12h30m). Leave empty to disable + background revalidation. + + setRevalidationInterval(e.target.value)} + customPrefix={} + disabled={!canUpdate && isEditing} + /> +
+ + + +
+ +
+
+
+ ); +} diff --git a/src/modules/integrations/entra-device-auth/EntraDeviceMappingActionCell.tsx b/src/modules/integrations/entra-device-auth/EntraDeviceMappingActionCell.tsx new file mode 100644 index 00000000..8e27baf0 --- /dev/null +++ b/src/modules/integrations/entra-device-auth/EntraDeviceMappingActionCell.tsx @@ -0,0 +1,108 @@ +import Button from "@components/Button"; +import { notify } from "@components/Notification"; +import { useApiCall } from "@utils/api"; +import { Trash2, Undo2Icon } from "lucide-react"; +import * as React from "react"; +import { useSWRConfig } from "swr"; +import { useDialog } from "@/contexts/DialogProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { + EntraDeviceMapping, + EntraDeviceMappingRequest, +} from "@/interfaces/EntraDeviceAuth"; + +type Props = { + mapping: EntraDeviceMapping; + onEdit?: (mapping: EntraDeviceMapping) => void; +}; + +/** + * Per-row revoke / delete controls for a mapping, mirroring + * SetupKeyActionCell. + */ +export default function EntraDeviceMappingActionCell({ + mapping, +}: Readonly) { + const { confirm } = useDialog(); + const { mutate } = useSWRConfig(); + const { permission } = usePermissions(); + const request = useApiCall( + `/integrations/entra-device-auth/mappings/${mapping.id}`, + ); + + const canUpdate = permission?.entra_device_auth?.update ?? true; + const canDelete = permission?.entra_device_auth?.delete ?? true; + + const handleRevoke = async () => { + const choice = await confirm({ + title: `Revoke '${mapping.name || "mapping"}'?`, + description: + "Revoked mappings are ignored during enrollment. Existing peers stay registered.", + confirmText: "Revoke", + cancelText: "Cancel", + type: "danger", + }); + if (!choice) return; + const payload: EntraDeviceMappingRequest = { + name: mapping.name, + entra_group_id: mapping.entra_group_id, + auto_groups: mapping.auto_groups ?? [], + ephemeral: mapping.ephemeral, + allow_extra_dns_labels: mapping.allow_extra_dns_labels, + expires_at: mapping.expires_at ?? null, + revoked: true, + priority: mapping.priority ?? 0, + }; + notify({ + title: mapping.name || "Entra mapping", + description: "Mapping revoked.", + promise: request.put(payload).then(() => { + mutate("/integrations/entra-device-auth/mappings"); + }), + loadingMessage: "Revoking mapping...", + }); + }; + + const handleDelete = async () => { + const choice = await confirm({ + title: `Delete '${mapping.name || "mapping"}'?`, + description: + "Deleting a mapping is permanent. Existing peers stay registered but will no longer re-evaluate against this mapping.", + confirmText: "Delete", + cancelText: "Cancel", + type: "danger", + }); + if (!choice) return; + notify({ + title: mapping.name || "Entra mapping", + description: "Mapping deleted.", + promise: request.del().then(() => { + mutate("/integrations/entra-device-auth/mappings"); + }), + loadingMessage: "Deleting mapping...", + }); + }; + + return ( +
+ + +
+ ); +} diff --git a/src/modules/integrations/entra-device-auth/EntraDeviceMappingModal.tsx b/src/modules/integrations/entra-device-auth/EntraDeviceMappingModal.tsx new file mode 100644 index 00000000..3bb0d96a --- /dev/null +++ b/src/modules/integrations/entra-device-auth/EntraDeviceMappingModal.tsx @@ -0,0 +1,295 @@ +import Button from "@components/Button"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { notify } from "@components/Notification"; +import { PeerGroupSelector } from "@components/PeerGroupSelector"; +import Separator from "@components/Separator"; +import { useApiCall } from "@utils/api"; +import { trim } from "lodash"; +import { + AlarmClock, + FingerprintIcon, + GlobeIcon, + PlusCircle, + PowerOffIcon, + SaveIcon, + TagIcon, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { + EntraDeviceMapping, + EntraDeviceMappingRequest, +} from "@/interfaces/EntraDeviceAuth"; +import useGroupHelper from "@/modules/groups/useGroupHelper"; + +type Props = { + open: boolean; + onClose: () => void; + mapping?: EntraDeviceMapping | null; +}; + +/** + * Create / edit modal for a single Entra device auth mapping, modelled after + * SetupKeyModal so admins see consistent controls (auto-groups, ephemeral, + * extra-DNS labels, expires-in, priority, revoked). + */ +export default function EntraDeviceMappingModal({ + open, + onClose, + mapping, +}: Readonly) { + const isEditing = !!mapping?.id; + const { mutate } = useSWRConfig(); + + const createRequest = useApiCall( + "/integrations/entra-device-auth/mappings", + ); + const updateRequest = useApiCall( + `/integrations/entra-device-auth/mappings/${mapping?.id ?? ""}`, + ); + + const [name, setName] = useState(mapping?.name ?? ""); + const [entraGroupId, setEntraGroupId] = useState(mapping?.entra_group_id ?? ""); + const [ephemeral, setEphemeral] = useState(mapping?.ephemeral ?? false); + const [allowExtraDNSLabels, setAllowExtraDNSLabels] = useState( + mapping?.allow_extra_dns_labels ?? false, + ); + const [revoked, setRevoked] = useState(mapping?.revoked ?? false); + const [priority, setPriority] = useState(String(mapping?.priority ?? 0)); + const [expiresInDays, setExpiresInDays] = useState(() => { + if (!mapping?.expires_at) return ""; + const diffMs = + new Date(mapping.expires_at).getTime() - Date.now(); + if (diffMs <= 0) return ""; + return String(Math.ceil(diffMs / (24 * 60 * 60 * 1000))); + }); + + const [selectedGroups, setSelectedGroups, { save: saveGroups }] = + useGroupHelper({ + initial: mapping?.auto_groups ?? [], + }); + + const isDisabled = useMemo(() => { + if (trim(name).length === 0) return true; + if (trim(entraGroupId).length === 0) return true; + if (priority !== "" && isNaN(parseInt(priority, 10))) return true; + if (expiresInDays !== "" && isNaN(parseInt(expiresInDays, 10))) return true; + return false; + }, [name, entraGroupId, priority, expiresInDays]); + + const buildRequest = async (): Promise => { + const groups = await saveGroups(); + let expiresAt: string | null = null; + if (trim(expiresInDays).length > 0) { + const days = parseInt(expiresInDays, 10); + if (!isNaN(days) && days > 0) { + expiresAt = new Date( + Date.now() + days * 24 * 60 * 60 * 1000, + ).toISOString(); + } + } + return { + name: trim(name), + entra_group_id: trim(entraGroupId), + auto_groups: groups.map((g) => g.id!).filter(Boolean), + ephemeral, + allow_extra_dns_labels: allowExtraDNSLabels, + expires_at: expiresAt, + revoked, + priority: parseInt(priority || "0", 10) || 0, + }; + }; + + const submit = async () => { + const payload = await buildRequest(); + const call = isEditing ? updateRequest.put(payload) : createRequest.post(payload); + notify({ + title: isEditing ? "Update mapping" : "Create mapping", + description: isEditing + ? "Mapping updated successfully." + : "Mapping created successfully.", + promise: call.then(() => { + mutate("/integrations/entra-device-auth/mappings"); + mutate("/groups"); + onClose(); + }), + loadingMessage: isEditing + ? "Saving mapping..." + : "Creating mapping...", + }); + }; + + return ( + !state && onClose()} + key={open ? 1 : 0} + > + + } + title={isEditing ? "Edit mapping" : "Create mapping"} + description={ + isEditing + ? "Update the Entra → NetBird mapping configuration" + : "Map an Entra security group onto NetBird auto-groups" + } + color="netbird" + /> + + + +
+
+ + + Friendly name shown in the admin UI and activity logs. + + setName(e.target.value)} + customPrefix={} + /> +
+ +
+ + + Microsoft Entra security group Object ID (GUID). Use{" "} + * to match any device from the + configured tenant. + + setEntraGroupId(e.target.value)} + customPrefix={ + + } + /> +
+ +
+ + + Peers enrolled via this mapping are automatically placed in these + groups. + + +
+ +
+
+ + + Lower values win in{" "} + strict_priority mode. Ignored + in union mode. + + setPriority(e.target.value)} + /> +
+
+ + + Days until this mapping stops matching. Leave empty for no + expiry. + + setExpiresInDays(e.target.value)} + customPrefix={ + + } + customSuffix="Day(s)" + /> +
+
+ + + + Ephemeral peers + + } + helpText="Peers offline for more than 10 minutes are removed automatically." + /> + + + + Allow extra DNS labels + + } + helpText="Enable multiple subdomain labels (e.g. host.dev.example.com)." + /> + + + + Revoked + + } + helpText="Revoked mappings are ignored during enrollment but kept for audit." + /> +
+ + +
+ + + + +
+
+
+
+ ); +} diff --git a/src/modules/integrations/entra-device-auth/EntraDeviceMappingsTable.tsx b/src/modules/integrations/entra-device-auth/EntraDeviceMappingsTable.tsx new file mode 100644 index 00000000..fb7e4404 --- /dev/null +++ b/src/modules/integrations/entra-device-auth/EntraDeviceMappingsTable.tsx @@ -0,0 +1,304 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import Paragraph from "@components/Paragraph"; +import SquareIcon from "@components/SquareIcon"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import MultipleGroups from "@components/ui/MultipleGroups"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import dayjs from "dayjs"; +import useFetchApi from "@utils/api"; +import { + FingerprintIcon, + GlobeIcon, + PlusCircle, + PowerOffIcon, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { + EntraDeviceAuth, + EntraDeviceMapping, +} from "@/interfaces/EntraDeviceAuth"; +import { Group } from "@/interfaces/Group"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; +import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow"; +import EntraDeviceMappingActionCell from "@/modules/integrations/entra-device-auth/EntraDeviceMappingActionCell"; +import EntraDeviceMappingModal from "@/modules/integrations/entra-device-auth/EntraDeviceMappingModal"; + +/** + * CRUD data-table for Entra device auth mappings. + * + * Blocked with a friendly empty state when the integration itself isn't + * configured yet — the backend refuses to accept mappings in that state + * (HTTP 409 no_integration) and the admin can't meaningfully create them. + */ +export default function EntraDeviceMappingsTable() { + const { mutate } = useSWRConfig(); + const { permission } = usePermissions(); + const { groups: allGroups } = useGroups(); + + const { data: integration } = useFetchApi( + "/integrations/entra-device-auth", + true, + ); + + const { data: mappings, isLoading } = useFetchApi( + "/integrations/entra-device-auth/mappings", + true, + ); + + const canRead = permission?.entra_device_auth?.read ?? true; + const canCreate = permission?.entra_device_auth?.create ?? true; + + const hasIntegration = !!integration?.id; + + // Decorate each mapping with resolved Group objects so the Groups column + // can render real names rather than bare IDs. + const decorated = useMemo(() => { + if (!mappings) return []; + if (!allGroups) return mappings; + return mappings.map((m) => ({ + ...m, + groups: (m.auto_groups ?? []) + .map((id) => allGroups.find((g) => g.id === id)) + .filter(Boolean) as Group[], + })); + }, [mappings, allGroups]); + + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort-entra-device-mappings", + [ + { id: "priority", desc: false }, + { id: "name", desc: false }, + ], + ); + + const [modalOpen, setModalOpen] = useState(false); + const [editMapping, setEditMapping] = useState( + null, + ); + + const handleCreate = () => { + setEditMapping(null); + setModalOpen(true); + }; + + const handleEdit = (mapping: EntraDeviceMapping) => { + setEditMapping(mapping); + setModalOpen(true); + }; + + const handleCloseModal = () => { + setModalOpen(false); + setEditMapping(null); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => ( + Name + ), + sortingFn: "text", + cell: ({ row }) => ( +
+ + {row.original.name} + {row.original.revoked && ( + + Revoked + + )} +
+ ), + }, + { + accessorKey: "entra_group_id", + header: ({ column }) => ( + Entra group + ), + cell: ({ row }) => ( + + {row.original.entra_group_id === "*" ? ( + + + Any device in tenant + + ) : ( + row.original.entra_group_id + )} + + ), + }, + { + id: "groups", + accessorFn: (m) => m.auto_groups?.length ?? 0, + header: ({ column }) => ( + Auto-groups + ), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "priority", + header: ({ column }) => ( + Priority + ), + cell: ({ row }) => ( + {row.original.priority ?? 0} + ), + }, + { + accessorKey: "ephemeral", + header: ({ column }) => ( + Ephemeral + ), + cell: ({ row }) => + row.original.ephemeral ? ( + + + Ephemeral + + ) : ( + + ), + }, + { + accessorKey: "expires_at", + header: ({ column }) => ( + Expires + ), + cell: ({ row }) => { + const exp = row.original.expires_at; + if (!exp) return ; + const d = dayjs(exp); + if (!d.isValid() || d.year() <= 1) return ; + return ; + }, + }, + { + id: "actions", + accessorKey: "id", + header: "", + cell: ({ row }) => ( + + ), + }, + ]; + + if (!canRead) { + return ( + + You don't have permission to view mappings. + + ); + } + + if (!hasIntegration) { + return ( +
+ } + color="gray" + size="large" + /> + } + title="Configure the integration first" + description="Set up the Entra Device Auth integration (Configuration tab) before adding mappings. The backend refuses mappings while the integration is missing." + /> +
+ ); + } + + return ( + <> + + handleEdit(row.original)} + searchPlaceholder="Search by name or Entra group ID..." + getStartedCard={ + } + color="gray" + size="large" + /> + } + title="Add your first mapping" + description="Map an Entra security group (or the catch-all '*') to a set of NetBird auto-groups. Peers enrolled via /join/entra will be placed accordingly." + button={ + + } + /> + } + rightSide={() => ( + <> + {mappings && mappings.length > 0 && ( + + )} + + )} + > + {(table) => ( + <> + + { + mutate("/integrations/entra-device-auth/mappings"); + mutate("/groups"); + }} + /> + + )} + + + ); +}