mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4451274c7 | ||
|
|
b2e658a2be | ||
|
|
4b2c394181 | ||
|
|
74eae99820 | ||
|
|
da81cb7f50 | ||
|
|
f4e80d37c4 | ||
|
|
de045582e5 | ||
|
|
6a85748b91 |
No files matched your search
+2
-1
@@ -24,5 +24,6 @@
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS",
|
||||
"announcement": "$NETBIRD_ANNOUNCEMENT"
|
||||
}
|
||||
@@ -73,6 +73,8 @@ export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID}
|
||||
export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS}
|
||||
# A permanent banner for this deployment, shown to every user; empty for none.
|
||||
export NETBIRD_ANNOUNCEMENT=${NETBIRD_ANNOUNCEMENT}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
@@ -163,7 +165,7 @@ sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /
|
||||
}
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS \$\$NETBIRD_ANNOUNCEMENT"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
|
||||
@@ -926,6 +926,16 @@ services:
|
||||
# GeoLite2 download removes a startup stall of up to 2 minutes
|
||||
# when pkgs.netbird.io is slow.
|
||||
- NB_PROXY_DISABLE_GEOLOCATION=true
|
||||
# Declare the private capability so this cluster can host the agent
|
||||
# network endpoint. That endpoint is a private service — reachable only
|
||||
# from connected peers — and management refuses to bootstrap it onto a
|
||||
# cluster without a private-capable proxy; the connect-provider wizard
|
||||
# reads the same flag (supports_private) to pick its cluster. Without
|
||||
# it no cluster qualifies and the wizard stops at the provider tab,
|
||||
# which strands every spec that walks through it. The no-ports proxy
|
||||
# below stays centralised, so the pick has to skip past a cluster that
|
||||
# does not qualify.
|
||||
- NB_PROXY_PRIVATE=true
|
||||
volumes:
|
||||
- ./proxy-certs:/certs:ro
|
||||
command: [
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export async function loginToApp(
|
||||
// Use locators that match either outcome — Playwright auto-waits.
|
||||
const appReady = page.getByTestId("left-navigation-item").first();
|
||||
const setupModal = page.getByTestId("setup-netbird-modal");
|
||||
const approvalPending = page.getByText("User Approval Pending");
|
||||
const approvalPending = page.getByTestId("pending-approval");
|
||||
const onboarding = page.getByText("Add new device to your network");
|
||||
const selectAccount = page.getByText("Select account");
|
||||
const loginInput = page.locator("input[id=loginName]");
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Agent Network bootstrap-cluster spec.
|
||||
*
|
||||
* The account's agent network endpoint is assigned once, from the proxy
|
||||
* cluster the connect-provider wizard pins it to, and it is immutable after
|
||||
* that. Not every live cluster can host it: the endpoint is a private service,
|
||||
* reachable only from connected peers and authorised by their tunnel identity,
|
||||
* which only a cluster with private capabilities can serve. Management
|
||||
* reports that per cluster as supports_private and refuses a
|
||||
* bootstrap onto a cluster without it.
|
||||
*
|
||||
* This spec pins the wizard's half of that contract: it picks a
|
||||
* private-capable cluster even when a centralised one is listed first, and it
|
||||
* blocks setup — with a message naming what is missing — when no listed
|
||||
* cluster qualifies. Both the settings and domains routes are mocked, so the
|
||||
* assertions do not depend on which clusters the test backend happens to have
|
||||
* connected, and no provider is created against it.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
|
||||
// bug this spec guards against.
|
||||
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
|
||||
// Listed second, and the only valid pick.
|
||||
const PRIVATE_CLUSTER = "private.proxy.netbird.io";
|
||||
|
||||
const UNBOOTSTRAPPED_DEFAULTS = {
|
||||
endpoint: "",
|
||||
proxy_address: "",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
};
|
||||
|
||||
const BOOTSTRAPPED_SETTINGS = {
|
||||
...UNBOOTSTRAPPED_DEFAULTS,
|
||||
endpoint: `violet.${PRIVATE_CLUSTER}`,
|
||||
proxy_address: PRIVATE_CLUSTER,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
|
||||
function freeDomain(name: string, supportsPrivate: boolean) {
|
||||
return {
|
||||
id: name,
|
||||
domain: name,
|
||||
validated: true,
|
||||
type: "free",
|
||||
supports_private: supportsPrivate,
|
||||
supports_custom_ports: true,
|
||||
require_subdomain: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function json(route: import("@playwright/test").Route, body: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the providers page for an account that has not been bootstrapped,
|
||||
* with the given clusters on the domains endpoint. Returns the page plus a
|
||||
* promise-friendly getter for the bootstrap POST body, which is captured and
|
||||
* answered locally so the real backend is never mutated.
|
||||
*/
|
||||
async function openWizard(
|
||||
browser: Browser,
|
||||
domains: ReturnType<typeof freeDomain>[],
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
try {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.route("**/api/agent-network/settings", (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
|
||||
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
|
||||
return route.continue();
|
||||
});
|
||||
await page.route("**/api/reverse-proxies/domains", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? json(route, domains)
|
||||
: route.continue(),
|
||||
);
|
||||
// Keep the create hermetic: the wizard only reaches this once the
|
||||
// bootstrap it does first has succeeded.
|
||||
await page.route("**/api/agent-network/providers", (route) =>
|
||||
route.request().method() === "POST"
|
||||
? json(route, { id: "prov-e2e", name: "e2e-provider" })
|
||||
: route.continue(),
|
||||
);
|
||||
|
||||
await loginToApp(page, "owner");
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
return { page, close: () => context.close() };
|
||||
} catch (e) {
|
||||
await context.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fills the provider tab with the minimum a create needs. */
|
||||
async function fillProviderTab(page: Page) {
|
||||
await page
|
||||
.getByPlaceholder("https://api.openai.com")
|
||||
.fill("https://api.openai.com");
|
||||
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
|
||||
}
|
||||
|
||||
test.describe("Agent Network bootstrap cluster @agent-network", () => {
|
||||
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
freeDomain(PRIVATE_CLUSTER, true),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
// The unusable cluster is listed, but it is not a blocker: a usable one
|
||||
// exists, so the wizard proceeds without the warning.
|
||||
await expect(
|
||||
page.getByTestId("agent-network-no-cluster-callout"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await fillProviderTab(page);
|
||||
|
||||
const bootstrap = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/settings") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Continue" })
|
||||
.click({ force: true });
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
|
||||
const payload = (await bootstrap).request().postDataJSON();
|
||||
expect(payload.proxy_address).toBe(PRIVATE_CLUSTER);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks setup when no listed cluster has private capabilities", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const callout = page.getByTestId("agent-network-no-cluster-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 10_000 });
|
||||
await expect(callout).toContainText("private capabilities");
|
||||
// The fix lives in the agent network's own Clusters section, not the
|
||||
// generic Reverse Proxy page, so that is where the callout sends users.
|
||||
await expect(callout.getByRole("link")).toHaveAttribute(
|
||||
"href",
|
||||
"/agent-network/configuration?tab=clusters",
|
||||
);
|
||||
|
||||
// With nothing to pin to there is no bootstrap to make, so the wizard
|
||||
// cannot advance — rather than sending a request management refuses.
|
||||
await fillProviderTab(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue" }),
|
||||
).toBeDisabled();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,7 @@ async function loginAndSave(
|
||||
const skipButton = page.locator("button[name=skip]");
|
||||
const appNav = page.getByTestId("left-navigation-item").first();
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
const approval = page.getByText("User Approval Pending");
|
||||
const approval = page.getByTestId("pending-approval");
|
||||
|
||||
let after_login: "2fa" | "app" | "modal" | "approval";
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ test.describe.serial("User Approval & Billing Admin @team", () => {
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "user");
|
||||
await expect(page.getByText("User Approval Pending")).toBeVisible();
|
||||
await expect(page.getByTestId("pending-approval")).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
|
||||
Generated
+518
-397
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -75,7 +75,7 @@
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.4",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
|
||||
@@ -61,10 +61,7 @@ export default function NetBirdSettings() {
|
||||
<VerticalTabs.List>
|
||||
{permission.settings.read && (
|
||||
<>
|
||||
<VerticalTabs.Trigger
|
||||
value="authentication"
|
||||
data-testid="settings-tab-authentication"
|
||||
>
|
||||
<VerticalTabs.Trigger value="authentication" data-testid="settings-tab-authentication">
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
@@ -81,31 +78,19 @@ export default function NetBirdSettings() {
|
||||
Identity Providers
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
<VerticalTabs.Trigger
|
||||
value="groups"
|
||||
data-testid="settings-tab-groups"
|
||||
>
|
||||
<VerticalTabs.Trigger value="groups" data-testid="settings-tab-groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
Groups
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger
|
||||
value="permissions"
|
||||
data-testid="settings-tab-permissions"
|
||||
>
|
||||
<VerticalTabs.Trigger value="permissions" data-testid="settings-tab-permissions">
|
||||
<LockIcon size={14} />
|
||||
Permissions
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger
|
||||
value="networks"
|
||||
data-testid="settings-tab-networks"
|
||||
>
|
||||
<VerticalTabs.Trigger value="networks" data-testid="settings-tab-networks">
|
||||
<NetworkIcon size={14} />
|
||||
Networks
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger
|
||||
value="clients"
|
||||
data-testid="settings-tab-clients"
|
||||
>
|
||||
<VerticalTabs.Trigger value="clients" data-testid="settings-tab-clients">
|
||||
<MonitorSmartphoneIcon size={14} />
|
||||
Clients
|
||||
</VerticalTabs.Trigger>
|
||||
|
||||
+15
-6
@@ -8,6 +8,7 @@ import { ArrowRightIcon, RefreshCw } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import { PendingApproval } from "@/modules/users/PendingApproval";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -57,9 +58,20 @@ export default function ErrorPage() {
|
||||
error?.code === 403 &&
|
||||
error?.message?.toLowerCase().includes("pending approval");
|
||||
|
||||
// Waiting for an approval is an expected part of signing up, so it gets a
|
||||
// welcoming screen of its own instead of the error treatment.
|
||||
if (isPendingApproval) {
|
||||
return (
|
||||
<PendingApproval
|
||||
error={error}
|
||||
onRefresh={handleRetry}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
if (isBlockedUser) return "User Account Blocked";
|
||||
if (isPendingApproval) return "User Approval Pending";
|
||||
return "Access Error";
|
||||
};
|
||||
|
||||
@@ -67,9 +79,6 @@ export default function ErrorPage() {
|
||||
if (isBlockedUser) {
|
||||
return "Your access has been blocked by the NetBird account administrator, possibly due to new user approval requirements or security policies. Please contact your administrator to regain access.";
|
||||
}
|
||||
if (isPendingApproval) {
|
||||
return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard.";
|
||||
}
|
||||
return "An error occurred while trying to access the dashboard. Please try again or contact your administrator.";
|
||||
};
|
||||
|
||||
@@ -98,7 +107,7 @@ export default function ErrorPage() {
|
||||
</Paragraph>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{!isBlockedUser && !isPendingApproval && (
|
||||
{!isBlockedUser && (
|
||||
<Button variant="default-outline" size="sm" onClick={handleRetry}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Try Again
|
||||
@@ -106,7 +115,7 @@ export default function ErrorPage() {
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="sm" onClick={handleLogout}>
|
||||
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
|
||||
{isBlockedUser ? "Sign Out" : "Logout"}
|
||||
<ArrowRightIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useOidcAccessToken, useOidcIdToken } from "@axa-fr/react-oidc";
|
||||
import loadConfig from "@utils/config";
|
||||
import { useMemo } from "react";
|
||||
import { decodeToken } from "react-jwt";
|
||||
import { useJwt } from "react-jwt";
|
||||
|
||||
const config = loadConfig();
|
||||
export const useDomainCategory = () => {
|
||||
@@ -9,12 +9,10 @@ export const useDomainCategory = () => {
|
||||
const { idToken } = useOidcIdToken();
|
||||
const { accessToken } = useOidcAccessToken();
|
||||
const token = tokenSource.toLowerCase() == "idtoken" ? idToken : accessToken;
|
||||
const { decodedToken } = useJwt<Record<any, any>>(token);
|
||||
|
||||
const domainCategory = useMemo(() => {
|
||||
try {
|
||||
const decodedToken = token
|
||||
? decodeToken<Record<any, any>>(token)
|
||||
: undefined;
|
||||
const key = decodedToken
|
||||
? Object.keys(decodedToken)
|
||||
.filter((key) => key.includes("wt_account_domain_category"))
|
||||
@@ -24,7 +22,7 @@ export const useDomainCategory = () => {
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}, [token]);
|
||||
}, [decodedToken]);
|
||||
|
||||
const isPrivate = useMemo(() => {
|
||||
return domainCategory === "private";
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Card from "@components/Card";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { SkeletonNotificationSettings } from "@components/skeletons/SkeletonNotificationSettings";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import { VerticalTabs } from "@components/VerticalTabs";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { ExternalLinkIcon, MessageSquareDot } from "lucide-react";
|
||||
import React from "react";
|
||||
import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useIsLicensed } from "@/hooks/useIsLicensed";
|
||||
import { VerticalTabs } from "@components/VerticalTabs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Card from "@components/Card";
|
||||
import { NotificationEmailChannel } from "@/cloud/notifications/channels/NotificationEmailChannel";
|
||||
import { NotificationSlackChannel } from "@/cloud/notifications/channels/NotificationSlackChannel";
|
||||
import { NotificationWebhookChannel } from "@/cloud/notifications/channels/NotificationWebhookChannel";
|
||||
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
|
||||
import useUrlTab from "@/hooks/useUrlTab";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import NotificationProvider, {
|
||||
useNotifications,
|
||||
} from "@/cloud/notifications/NotificationProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useIsLicensed } from "@/hooks/useIsLicensed";
|
||||
import useUrlTab from "@/hooks/useUrlTab";
|
||||
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
|
||||
import {
|
||||
NOTIFICATION_CHANNELS_DOCS_LINK,
|
||||
NotificationChannelType,
|
||||
} from "@/interfaces/NotificationChannel";
|
||||
import { SkeletonNotificationSettings } from "@components/skeletons/SkeletonNotificationSettings";
|
||||
import { NotificationSlackChannel } from "@/cloud/notifications/channels/NotificationSlackChannel";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
|
||||
const NotificationsOverview = ({
|
||||
onSelectChannel,
|
||||
@@ -52,18 +52,23 @@ const NotificationsOverview = ({
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className={"flex items-start justify-between -mt-1"}>
|
||||
<div className={"flex items-start justify-between"}>
|
||||
<div>
|
||||
<h1>Notifications</h1>
|
||||
<Paragraph className={"block"}>
|
||||
Get notified when important events happen.{" "}
|
||||
<Paragraph>
|
||||
Choose how to be notified when important events occur in your
|
||||
account.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={NOTIFICATION_CHANNELS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
Notification Channels
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
|
||||
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
|
||||
import { DomainVerificationModal } from "@/modules/integrations/sso/DomainVerificationModal";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const AddSignInDomainModal = ({ open, onOpenChange }: Props) => {
|
||||
const { addDomain, verifyDomain, mutate } = useSignInDomains();
|
||||
const [name, setName] = useState("");
|
||||
const [verifyModal, setVerifyModal] = useState(false);
|
||||
const [addedDomain, setAddedDomain] = useState<SignInDomain>();
|
||||
|
||||
const validationError = useMemo(() => {
|
||||
const domain = name.trim();
|
||||
if (domain === "") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: false,
|
||||
preventLeadingAndTrailingDots: true,
|
||||
});
|
||||
if (!valid) return "Please enter a valid domain, e.g. company.com";
|
||||
return "";
|
||||
}, [name]);
|
||||
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!next) setName("");
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const domain = name.trim();
|
||||
if (!domain || validationError) return;
|
||||
|
||||
setOpen(false);
|
||||
|
||||
notify({
|
||||
title: "Sign-in Domains",
|
||||
description: `${domain} has been added`,
|
||||
promise: addDomain(domain).then((added) => {
|
||||
if (
|
||||
added &&
|
||||
added.validation_status !== DomainValidationStatus.VERIFIED
|
||||
) {
|
||||
setAddedDomain(added);
|
||||
setVerifyModal(true);
|
||||
}
|
||||
mutate().catch(() => {});
|
||||
return added;
|
||||
}),
|
||||
loadingMessage: "Adding domain...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{addedDomain && (
|
||||
<DomainVerificationModal
|
||||
open={verifyModal}
|
||||
onOpenChange={setVerifyModal}
|
||||
domain={addedDomain.name}
|
||||
token={addedDomain.validation_token}
|
||||
onVerify={() =>
|
||||
verifyDomain(addedDomain.id).then((res) => {
|
||||
mutate().catch(() => {});
|
||||
return res;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent maxWidthClass={"max-w-md"}>
|
||||
<ModalHeader
|
||||
className={"pb-4 px-8"}
|
||||
title={"Add Sign-in Domain"}
|
||||
description={"Users on this domain are matched to your account."}
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<div className={"p-default flex flex-col gap-4"}>
|
||||
<div>
|
||||
<Input
|
||||
autoFocus
|
||||
data-testid={"add-domain-input"}
|
||||
placeholder={"e.g. company.com"}
|
||||
value={name}
|
||||
error={validationError}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"} separator={false}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"} className={"w-full"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
type={"submit"}
|
||||
disabled={!name.trim() || !!validationError}
|
||||
data-testid={"add-domain-submit"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Label } from "@components/Label";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useDomainCategory } from "@/cloud/cloud-hooks/useDomainCategory";
|
||||
import { AddSignInDomainModal } from "@/cloud/sign-in-domains/AddSignInDomainModal";
|
||||
import SignInDomainsTable from "@/cloud/sign-in-domains/table/SignInDomainsTable";
|
||||
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
const SIGN_IN_DOMAINS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/team/sign-in-domains";
|
||||
|
||||
export const useCanViewSignInDomains = () => {
|
||||
const { permission } = usePermissions();
|
||||
const { isPrivate } = useDomainCategory();
|
||||
return Boolean(permission?.settings?.read) && isNetBirdCloud() && isPrivate;
|
||||
};
|
||||
|
||||
export const SignInDomainsSettings = () => {
|
||||
const { permission } = usePermissions();
|
||||
const { domains, isLoading, isUnavailable } =
|
||||
useSignInDomains(POLL_INTERVAL_MS);
|
||||
const [addModal, setAddModal] = useState(false);
|
||||
|
||||
const showSkeleton = isLoading && !isUnavailable;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isLoading && "animate-pulse pointer-events-none opacity-90",
|
||||
isUnavailable && "pointer-events-none opacity-70",
|
||||
)}
|
||||
data-testid={"sign-in-domains"}
|
||||
>
|
||||
<div className={"flex items-end justify-between gap-4 mb-4"}>
|
||||
<div className={"min-w-0"}>
|
||||
<Label>Sign-in Domains</Label>
|
||||
<HelpText className={"!mb-0"}>
|
||||
Users from these domains can join your account.{" "}
|
||||
<InlineLink href={SIGN_IN_DOMAINS_DOCS_LINK} target={"_blank"}>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</HelpText>
|
||||
</div>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"xs"}
|
||||
className={"shrink-0"}
|
||||
disabled={!permission?.settings?.update || isUnavailable}
|
||||
onClick={() => setAddModal(true)}
|
||||
data-testid={"add-domain"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SignInDomainsTable domains={domains} isLoading={showSkeleton} />
|
||||
|
||||
<AddSignInDomainModal open={addModal} onOpenChange={setAddModal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { notify } from "@components/Notification";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
|
||||
import { DomainVerificationModal } from "@/modules/integrations/sso/DomainVerificationModal";
|
||||
|
||||
type Props = {
|
||||
domain: SignInDomain;
|
||||
};
|
||||
|
||||
export default function DomainActionCell({ domain }: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { verifyDomain, deleteDomain, mutate } = useSignInDomains();
|
||||
const { confirm } = useDialog();
|
||||
const [modal, setModal] = useState(false);
|
||||
|
||||
const isVerified =
|
||||
domain.validation_status === DomainValidationStatus.VERIFIED;
|
||||
const connections = domain.connections ?? [];
|
||||
|
||||
const removeBlockedReason: React.ReactNode = domain.is_primary ? (
|
||||
<>
|
||||
This is the primary domain of your account
|
||||
<br />
|
||||
and cannot be removed.
|
||||
</>
|
||||
) : connections.length > 0 ? (
|
||||
<>
|
||||
This domain is used by an SSO integration.
|
||||
<br />
|
||||
Remove it there before deleting it.
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
const deleteDomainHandler = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Remove ${domain.name}?`,
|
||||
description:
|
||||
"Users with an email address on this domain will no longer be matched to your account.",
|
||||
confirmText: "Remove",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
maxWidthClass: "max-w-md",
|
||||
});
|
||||
if (!choice) return;
|
||||
notify({
|
||||
title: "Sign-in Domains",
|
||||
description: `${domain.name} has been removed`,
|
||||
promise: deleteDomain(domain.id).then((res) => {
|
||||
mutate().catch(() => {});
|
||||
return res;
|
||||
}),
|
||||
loadingMessage: "Removing domain...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"relative top-[2px] flex gap-2 items-center justify-end ml-auto min-h-[34px]"
|
||||
}
|
||||
>
|
||||
<DomainVerificationModal
|
||||
open={modal}
|
||||
onOpenChange={setModal}
|
||||
domain={domain.name}
|
||||
token={domain.validation_token}
|
||||
onVerify={() =>
|
||||
verifyDomain(domain.id).then((res) => {
|
||||
mutate().catch(() => {});
|
||||
return res;
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{!isVerified && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"!px-3"}
|
||||
disabled={!permission.settings.update}
|
||||
onClick={() => setModal(true)}
|
||||
data-testid={"verify-domain"}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
{removeBlockedReason ?? "Remove domain"}
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"xs"}
|
||||
className={cn(
|
||||
"!p-0 !h-[34px] !w-[34px]",
|
||||
removeBlockedReason && "pointer-events-none",
|
||||
)}
|
||||
disabled={!!removeBlockedReason || !permission.settings.update}
|
||||
onClick={deleteDomainHandler}
|
||||
aria-label={"Remove domain"}
|
||||
data-testid={"remove-domain"}
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
|
||||
|
||||
type Props = {
|
||||
domain: SignInDomain;
|
||||
};
|
||||
|
||||
const FAILED_HINT =
|
||||
"This domain could not be verified. Check that the TXT record is published, then try again. If it keeps failing, contact support@netbird.io";
|
||||
|
||||
export default function DomainStatusCell({ domain }: Readonly<Props>) {
|
||||
const status = domain.validation_status;
|
||||
const isVerified = status === DomainValidationStatus.VERIFIED;
|
||||
const isPending = status === DomainValidationStatus.PENDING;
|
||||
const isFailed = status === DomainValidationStatus.FAILED;
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={<div className={"text-xs max-w-xs"}>{FAILED_HINT}</div>}
|
||||
disabled={!isFailed}
|
||||
interactive={false}
|
||||
>
|
||||
<div
|
||||
className={"flex items-center gap-2 text-sm text-nb-gray-300 mr-auto"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
isVerified && "bg-green-400",
|
||||
isPending && "bg-yellow-400",
|
||||
!isVerified && !isPending && "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
{statusLabel(status)}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: DomainValidationStatus) {
|
||||
if (status === DomainValidationStatus.VERIFIED) return "Verified";
|
||||
if (status === DomainValidationStatus.PENDING) return "Pending";
|
||||
return "Unverified";
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import Card from "@components/Card";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import DomainActionCell from "@/cloud/sign-in-domains/table/DomainActionCell";
|
||||
import DomainStatusCell from "@/cloud/sign-in-domains/table/DomainStatusCell";
|
||||
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
|
||||
|
||||
type Props = {
|
||||
domains?: SignInDomain[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_ID = "__loading__";
|
||||
|
||||
const placeholderRow: SignInDomain = {
|
||||
id: PLACEHOLDER_ID,
|
||||
name: "",
|
||||
is_primary: false,
|
||||
validation_token: "",
|
||||
validation_status: DomainValidationStatus.PENDING,
|
||||
validation_last_updated: null,
|
||||
};
|
||||
|
||||
const isPlaceholder = (domain: SignInDomain) => domain.id === PLACEHOLDER_ID;
|
||||
|
||||
const SignInDomainsColumns: ColumnDef<SignInDomain>[] = [
|
||||
{
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Domain</DataTableHeader>;
|
||||
},
|
||||
accessorKey: "name",
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) =>
|
||||
isPlaceholder(row.original) ? (
|
||||
<Skeleton height={10} width={"60%"} />
|
||||
) : (
|
||||
<div className={"text-sm text-nb-gray-100"}>{row.original.name}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Status</DataTableHeader>;
|
||||
},
|
||||
accessorKey: "validation_status",
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) =>
|
||||
isPlaceholder(row.original) ? (
|
||||
<Skeleton height={10} width={"50%"} />
|
||||
) : (
|
||||
<DomainStatusCell domain={row.original} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: () => null,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
isPlaceholder(row.original) ? (
|
||||
<div className={"flex items-center justify-end ml-auto min-h-[34px]"} />
|
||||
) : (
|
||||
<DomainActionCell domain={row.original} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SignInDomainsTable({
|
||||
domains,
|
||||
isLoading,
|
||||
}: Readonly<Props>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "w-full pb-1" }}
|
||||
useRowId={true}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
showSearchAndFilters={false}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Domains"}
|
||||
columns={SignInDomainsColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={isLoading ? [placeholderRow] : domains}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
hideIcon
|
||||
title={"No sign-in domains yet"}
|
||||
description={
|
||||
"Add a domain, then verify ownership with a DNS record to start matching users to this account."
|
||||
}
|
||||
/>
|
||||
}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import loadConfig from "@utils/config";
|
||||
import { useCallback } from "react";
|
||||
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
export const useSignInDomains = (pollWhilePendingMs?: number) => {
|
||||
const refreshInterval = useCallback(
|
||||
(latest?: SignInDomain[]) =>
|
||||
pollWhilePendingMs &&
|
||||
latest?.some(
|
||||
(domain) => domain.validation_status === DomainValidationStatus.PENDING,
|
||||
)
|
||||
? pollWhilePendingMs
|
||||
: 0,
|
||||
[pollWhilePendingMs],
|
||||
);
|
||||
|
||||
const {
|
||||
data: domains,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
} = useFetchApi<SignInDomain[]>(
|
||||
"/service/sign-in-domains",
|
||||
true,
|
||||
true,
|
||||
!!config.authServiceUrl,
|
||||
{
|
||||
origin: config.authServiceUrl,
|
||||
refreshInterval,
|
||||
shouldRetryOnError: false,
|
||||
},
|
||||
);
|
||||
|
||||
const request = useApiCall<SignInDomain>("/service/sign-in-domains", true, {
|
||||
origin: config.authServiceUrl,
|
||||
});
|
||||
|
||||
const addDomain = async (domain: string) => {
|
||||
return request.post({ domain });
|
||||
};
|
||||
|
||||
const verifyDomain = async (domainId: string) => {
|
||||
return request.post({}, `/${domainId}/verify`);
|
||||
};
|
||||
|
||||
const deleteDomain = async (domainId: string) => {
|
||||
return request.del({}, `/${domainId}`);
|
||||
};
|
||||
|
||||
const isUnavailable = !!error || (!isLoading && !domains);
|
||||
|
||||
return {
|
||||
domains,
|
||||
isLoading,
|
||||
isUnavailable,
|
||||
error,
|
||||
mutate,
|
||||
addDomain,
|
||||
verifyDomain,
|
||||
deleteDomain,
|
||||
} as const;
|
||||
};
|
||||
@@ -9,6 +9,9 @@ type Props = {
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Lets callers mark a callout so tests can assert on the state it
|
||||
* announces rather than on its copy. */
|
||||
"data-testid"?: string;
|
||||
} & CalloutVariants;
|
||||
|
||||
export const calloutVariants = cva(
|
||||
@@ -31,9 +34,13 @@ export const Callout = ({
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
"data-testid": dataTestId,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={cn(calloutVariants({ variant }), className)}>
|
||||
<div
|
||||
className={cn(calloutVariants({ variant }), className)}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
|
||||
Loaded 20 of 68 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user