Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/app/(dashboard)/integrations/entra-device-auth/layout.tsx
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;
85 changes: 85 additions & 0 deletions src/app/(dashboard)/integrations/entra-device-auth/page.tsx
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,
]);

Comment on lines +28 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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={

Loading search...
}> ); } ### Alternatives (No Suspense needed): 1. Server Components: Use searchParams prop directly (async Promise in Next.js 16: await searchParams). 2. Dynamic Rendering: Add export const dynamic = 'force-dynamic'; to page/layout. 3. Connection API: Use connection in Server Component to wait for request. 4. loading.tsx: Next.js auto-wraps pages with Suspense if loading.tsx exists nearby. Official docs confirm this for latest version (published 2026-04-21).

Citations:


🏁 Script executed:

cat -n src/app/\(dashboard\)/integrations/entra-device-auth/page.tsx

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., RestrictedAccess onwards) 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
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(dashboard)/integrations/entra-device-auth/page.tsx around lines 28
- 34, The page component EntraDeviceAuthPage is calling useSearchParams()
directly which in Next.js App Router requires a Suspense boundary to avoid
forcing client-side rendering; extract the tab-dependent UI (starting at
RestrictedAccess and any components that rely on queryParams or initialTab) into
a child Client Component (e.g., EntraDeviceAuthBody or EntraDeviceAuthClient)
and keep EntraDeviceAuthPage as a server component that imports that client
component, then wrap the client child in a React.Suspense with an appropriate
fallback (e.g., loading skeleton) at the page level so useSearchParams() runs
inside the Suspense boundary and the page maintains static rendering.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"Integrations" breadcrumb points to the current page.

Both breadcrumb items use href="/integrations/entra-device-auth", so the parent "Integrations" crumb just re-navigates to this same page. If there's no integrations index route, either drop the href on the parent crumb or point it at a meaningful parent (e.g., /integrations or the settings landing).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(dashboard)/integrations/entra-device-auth/page.tsx around lines 40
- 45, The "Integrations" breadcrumb in the Breadcrumbs component currently links
to the same page via Breadcrumbs.Item with
href="/integrations/entra-device-auth"; update that item so it doesn't point to
the current page—either remove the href prop from the parent Breadcrumbs.Item or
change it to a meaningful parent route such as "/integrations" (modify the
Breadcrumbs.Item that has label="Integrations" and
href="/integrations/entra-device-auth" accordingly).

<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>
);
}
3 changes: 3 additions & 0 deletions src/app/(dashboard)/integrations/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import BlankLayout from "@/layouts/BlankLayout";

export default BlankLayout;
41 changes: 41 additions & 0 deletions src/assets/icons/EntraDeviceIcon.tsx
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>
);
}
97 changes: 97 additions & 0 deletions src/interfaces/EntraDeviceAuth.ts
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;
}
7 changes: 7 additions & 0 deletions src/interfaces/Permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

Expand Down
22 changes: 22 additions & 0 deletions src/layouts/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -213,6 +214,7 @@ export default function Navigation({
/>
</SidebarItem>
<ActivityNavigationItem />
<EntraDeviceAuthNavigationItem />
</SidebarItemGroup>

<SidebarItemGroup>
Expand Down Expand Up @@ -283,3 +285,23 @@ const ActivityNavigationItem = () => {
</SidebarItem>
);
};

// 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 (
<SidebarItem
icon={<EntraDeviceIcon size={16} />}
label="Entra Device Auth"
href={"/integrations/entra-device-auth"}
exactPathMatch={false}
visible={visible}
/>
);
};
Loading