-
Notifications
You must be signed in to change notification settings - Fork 134
Add Entra device authentication admin UI #621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <PageContainer> | ||
| <div className="p-default py-6"> | ||
| <Breadcrumbs> | ||
| <Breadcrumbs.Item | ||
| href="/integrations/entra-device-auth" | ||
| label="Integrations" | ||
| icon={<SettingsIcon size={13} />} | ||
| /> | ||
|
Comment on lines
+40
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Integrations" breadcrumb points to the current page. Both breadcrumb items use 🤖 Prompt for AI Agents |
||
| <Breadcrumbs.Item | ||
| href="/integrations/entra-device-auth" | ||
| label="Entra Device Auth" | ||
| icon={<EntraDeviceIcon size={14} />} | ||
| active | ||
| /> | ||
| </Breadcrumbs> | ||
| <h1>Entra Device Auth</h1> | ||
| <Paragraph> | ||
| Zero-touch device enrollment for Microsoft Entra-joined machines. | ||
| Devices hitting <code className="text-xs">/join/entra</code> 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. | ||
| </Paragraph> | ||
| </div> | ||
|
|
||
| <RestrictedAccess page="Entra Device Auth" hasAccess={canRead}> | ||
| <Tabs defaultValue={initialTab}> | ||
| <TabsList justify="start" className="px-default"> | ||
| <TabsTrigger value="config"> | ||
| <FingerprintIcon size={14} /> | ||
| Configuration | ||
| </TabsTrigger> | ||
| <TabsTrigger value="mappings"> | ||
| <FolderGit2Icon size={14} /> | ||
| Mappings | ||
| </TabsTrigger> | ||
| </TabsList> | ||
| <TabsContent value="config"> | ||
| <EntraDeviceAuthConfig /> | ||
| </TabsContent> | ||
| <TabsContent value="mappings"> | ||
| <EntraDeviceMappingsTable /> | ||
| </TabsContent> | ||
| </Tabs> | ||
| </RestrictedAccess> | ||
| </PageContainer> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import BlankLayout from "@/layouts/BlankLayout"; | ||
|
|
||
| export default BlankLayout; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Props>) { | ||
| return ( | ||
| <svg | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| width={size} | ||
| height={size} | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| strokeWidth={2} | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| className={className} | ||
| aria-hidden="true" | ||
| > | ||
| {/* laptop base */} | ||
| <path d="M3 17h18" /> | ||
| <rect x="5" y="5" width="14" height="10" rx="1.5" /> | ||
| {/* shield/check overlay */} | ||
| <path d="M12 7.5l3 1.2v2.4c0 2-1.3 3.1-3 3.6-1.7-.5-3-1.6-3-3.6V8.7L12 7.5z" /> | ||
| <path d="M10.8 10.8l.8.9 1.6-1.7" /> | ||
| </svg> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Next.js 16 useSearchParams Suspense boundary requirement app router💡 Result:
In Next.js 16 with the App Router, using the useSearchParams hook (a Client Component hook from next/navigation) requires wrapping the component that calls it in a Suspense boundary. This is necessary during static prerendering at build time, when no request/query parameters exist, causing the hook to suspend. Without Suspense, the entire page opts into full client-side rendering (CSR bailout), potentially leaving the page blank until JS loads, and failing the build with a "Missing Suspense boundary with useSearchParams" error. This requirement has not changed in Next.js 16; Next.js 16 updates (like async searchParams props in Server Components) do not affect useSearchParams behavior. ### Key Rules: - Works in development without Suspense (on-demand rendering). - Fails production build without Suspense for static routes. - Isolate to smallest subtree: wrap only the Client Component using the hook. ### Example Fix: tsx // Client Component using the hook 'use client'; import { useSearchParams } from 'next/navigation'; function Search({ searchParamsPromise }: { searchParamsPromise: Promise<{ q?: string }> }) { const searchParams = use(searchParamsPromise); // Or directly: const searchParams = useSearchParams; const q = searchParams.get('q'); return <input defaultValue={q || ''} />; } // Server Component Page/Layout import { Suspense } from 'react'; export default function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) { return ( <Suspense fallback={
Citations:
🏁 Script executed:
Repository: netbirdio/dashboard
Length of output: 3965
Wrap
useSearchParams()usage in a<Suspense>boundary.In Next.js 16 with the App Router, calling
useSearchParams()requires the component to be wrapped in a<Suspense>boundary. Without it, the entire page opts into client-side rendering and will fail the build-time check. Extract the tab body content (e.g.,RestrictedAccessonwards) into a child Client Component and wrap it with<Suspense fallback={...}>at the page level to maintain static rendering and satisfy the build requirement.🤖 Prompt for AI Agents