mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6845094723 | ||
|
|
b9f278360e | ||
|
|
6d3928d7c0 | ||
|
|
35fe6b6cbb | ||
|
|
dcfa2398e3 | ||
|
|
1cc2353957 | ||
|
|
7189a9913d | ||
|
|
6a37af081b | ||
|
|
4d21ce4680 | ||
|
|
9d8e4dc50d | ||
|
|
26edbb8add | ||
|
|
6791fb44a4 | ||
|
|
00a1fd8113 | ||
|
|
08bb65dfe8 | ||
|
|
f24775fe25 |
No files matched your search
@@ -61,7 +61,10 @@ 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>
|
||||
@@ -78,19 +81,31 @@ 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useOidcAccessToken, useOidcIdToken } from "@axa-fr/react-oidc";
|
||||
import loadConfig from "@utils/config";
|
||||
import { useMemo } from "react";
|
||||
import { useJwt } from "react-jwt";
|
||||
import { decodeToken } from "react-jwt";
|
||||
|
||||
const config = loadConfig();
|
||||
export const useDomainCategory = () => {
|
||||
@@ -9,10 +9,12 @@ 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"))
|
||||
@@ -22,7 +24,7 @@ export const useDomainCategory = () => {
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}, [decodedToken]);
|
||||
}, [token]);
|
||||
|
||||
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 useUrlTab from "@/hooks/useUrlTab";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
|
||||
import NotificationProvider, {
|
||||
useNotifications,
|
||||
} from "@/cloud/notifications/NotificationProvider";
|
||||
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useIsLicensed } from "@/hooks/useIsLicensed";
|
||||
import useUrlTab from "@/hooks/useUrlTab";
|
||||
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,23 +52,18 @@ const NotificationsOverview = ({
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className={"flex items-start justify-between"}>
|
||||
<div className={"flex items-start justify-between -mt-1"}>
|
||||
<div>
|
||||
<h1>Notifications</h1>
|
||||
<Paragraph>
|
||||
Choose how to be notified when important events occur in your
|
||||
account.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<Paragraph className={"block"}>
|
||||
Get notified when important events happen.{" "}
|
||||
<InlineLink
|
||||
href={NOTIFICATION_CHANNELS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Notification Channels
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ScrollArea, ScrollAreaViewport } from "@components/ScrollArea";
|
||||
import { TabContext, useTabContext } from "@components/Tabs";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { TabsTrigger } from "@radix-ui/react-tabs";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { useIsLg } from "@utils/responsive";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
|
||||
import { headerHeight } from "@/layouts/Header";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
@@ -46,19 +48,115 @@ function VerticalTabs({ value, onChange, children }: Props) {
|
||||
}
|
||||
|
||||
function List({ children }: { children: React.ReactNode }) {
|
||||
const isLg = useIsLg();
|
||||
const { bannerHeight } = useAnnouncement();
|
||||
const viewport = React.useRef<HTMLDivElement>(null);
|
||||
const [overflows, setOverflows] = React.useState({
|
||||
start: false,
|
||||
end: false,
|
||||
});
|
||||
|
||||
// A fade at whichever end still has tabs behind it, on top of the scrollbar
|
||||
// rather than instead of it.
|
||||
React.useEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const max = el.scrollWidth - el.clientWidth;
|
||||
const start = el.scrollLeft > 1;
|
||||
const end = el.scrollLeft < max - 1;
|
||||
setOverflows((prev) =>
|
||||
prev.start === start && prev.end === end ? prev : { start, end },
|
||||
);
|
||||
};
|
||||
|
||||
// ResizeObserver fires once on observe, which covers the initial state
|
||||
// without calling setState straight from the effect body.
|
||||
const resize = new ResizeObserver(measure);
|
||||
resize.observe(el);
|
||||
|
||||
// A permission-gated trigger appearing later changes scrollWidth without
|
||||
// resizing the element, which a ResizeObserver alone would miss.
|
||||
const mutation = new MutationObserver(measure);
|
||||
mutation.observe(el, { childList: true, subtree: true });
|
||||
|
||||
el.addEventListener("scroll", measure, { passive: true });
|
||||
|
||||
return () => {
|
||||
resize.disconnect();
|
||||
mutation.disconnect();
|
||||
el.removeEventListener("scroll", measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fade =
|
||||
"absolute top-0 bottom-2.5 w-16 pointer-events-none transition-opacity lg:hidden";
|
||||
|
||||
return (
|
||||
<Tabs.List
|
||||
className={cn(
|
||||
"px-4 py-4 whitespace-nowrap overflow-y-hidden shrink-0 no-scrollbar",
|
||||
"lg:h-full items-start bg-nb-gray border-b-0 border-nb-gray-930",
|
||||
"flex lg:flex-col lg:gap-1",
|
||||
"shrink-0 bg-nb-gray border-b-0 border-nb-gray-930",
|
||||
// PageContainer is the scroll container, so without this the tab list
|
||||
// scrolls away with the tab content. Pinned to the top of it instead,
|
||||
// and given its own overflow so a list taller than the viewport can
|
||||
// still be reached.
|
||||
//
|
||||
// The height has to come from CSS, not a JS media query: as a flex item
|
||||
// with height auto the list stretches to the full content height, and a
|
||||
// sticky element as tall as its containing block has nowhere to stick.
|
||||
// useMediaQuery reports false during SSR and the first client render,
|
||||
// so that is exactly what the desktop sidebar got until it resolved.
|
||||
//
|
||||
// The offset is the header plus whatever the announcement banner is
|
||||
// taking, the same figure DashboardLayout and Navigation use. It comes
|
||||
// through a variable so the breakpoint stays in CSS, and it is always a
|
||||
// length, so the height never falls back to auto.
|
||||
"h-auto lg:h-[calc(100vh_-_var(--nb-tabs-offset))] lg:sticky lg:top-0 lg:overflow-y-auto",
|
||||
)}
|
||||
style={{
|
||||
height: isLg ? "calc(100vh - 75px)" : "auto",
|
||||
}}
|
||||
style={
|
||||
{
|
||||
// bannerHeight is undefined outside the provider, and NaNpx would make
|
||||
// the calc invalid and drop the height back to auto.
|
||||
"--nb-tabs-offset": `${headerHeight + (bannerHeight ?? 0)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{/* Below lg the tabs are a horizontal strip, and ScrollArea gives it the
|
||||
same styled scrollbar the rest of the app uses. On lg the Root and
|
||||
Viewport are display:contents, so they leave the layout and the tab
|
||||
column sits directly in the list as before. */}
|
||||
<ScrollArea className={"w-full lg:contents"} withoutViewport>
|
||||
<ScrollAreaViewport ref={viewport} className={"lg:contents"}>
|
||||
{/* The padding belongs inside the scroll area: outside it, the
|
||||
scrollbar sits over the items, and the leading gap does not
|
||||
travel with the content. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-nowrap gap-[1px] whitespace-nowrap items-start",
|
||||
"px-4 pt-4 pb-3 lg:p-4 lg:flex-col lg:gap-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
fade,
|
||||
"left-0 bg-gradient-to-r from-nb-gray to-transparent",
|
||||
overflows.start ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
fade,
|
||||
"right-0 bg-gradient-to-l from-nb-gray to-transparent",
|
||||
overflows.end ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Tabs.List>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Button from "@components/Button";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FilterX } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
|
||||
type Props = {
|
||||
icon?: React.ReactNode;
|
||||
hideIcon?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
@@ -20,6 +21,7 @@ type Props = {
|
||||
|
||||
export default function NoResults({
|
||||
icon,
|
||||
hideIcon = false,
|
||||
title = "Could not find any results",
|
||||
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
||||
children,
|
||||
@@ -74,13 +76,15 @@ export default function NoResults({
|
||||
<div
|
||||
className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
|
||||
>
|
||||
<div className={"flex items-center justify-center mb-6"}>
|
||||
<SquareIcon
|
||||
icon={icon ? icon : <FilterX size={24} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
</div>
|
||||
{!hideIcon && (
|
||||
<div className={"flex items-center justify-center mb-6"}>
|
||||
<SquareIcon
|
||||
icon={icon ?? <FilterX size={24} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={"text-center"}>
|
||||
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
|
||||
|
||||
@@ -51,3 +51,24 @@ export interface AccountOnboarding {
|
||||
onboarding_flow_pending: boolean;
|
||||
signup_form_pending: boolean;
|
||||
}
|
||||
|
||||
export interface SignInDomain {
|
||||
id: string;
|
||||
name: string;
|
||||
is_primary: boolean;
|
||||
validation_token: string;
|
||||
validation_status: DomainValidationStatus;
|
||||
validation_last_updated: string | null;
|
||||
connections?: DomainConnection[];
|
||||
}
|
||||
|
||||
export interface DomainConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export enum DomainValidationStatus {
|
||||
PENDING = "pending",
|
||||
VERIFIED = "verified",
|
||||
FAILED = "failed",
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DomainValidationStatus } from "@/interfaces/Account";
|
||||
|
||||
export interface GoogleWorkspaceIntegration {
|
||||
id: string;
|
||||
customer_id: string;
|
||||
@@ -64,12 +66,6 @@ export interface EnterpriseConnectionDomain {
|
||||
validation_last_updated: Date;
|
||||
}
|
||||
|
||||
export enum DomainValidationStatus {
|
||||
PENDING = "pending",
|
||||
VERIFIED = "verified",
|
||||
FAILED = "failed",
|
||||
}
|
||||
|
||||
export interface SSOConnection {
|
||||
id: string;
|
||||
strategy: string;
|
||||
|
||||
@@ -159,7 +159,9 @@ const PlansAndBillingTabContent = () => {
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
<div className={"flex items-center justify-between max-w-4xl mb-4"}>
|
||||
<div
|
||||
className={"flex items-start justify-between -mt-1 max-w-4xl mb-4"}
|
||||
>
|
||||
<h1>Plans & Billing</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,10 +6,8 @@ import { TrashIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import {
|
||||
DomainValidationStatus,
|
||||
EnterpriseConnectionDomain,
|
||||
} from "@/interfaces/IdentityProvider";
|
||||
import { DomainValidationStatus } from "@/interfaces/Account";
|
||||
import { EnterpriseConnectionDomain } from "@/interfaces/IdentityProvider";
|
||||
import { DomainVerificationModal } from "@/modules/integrations/sso/DomainVerificationModal";
|
||||
import { useEnterpriseConnections } from "@/modules/integrations/sso/useEnterpriseConnections";
|
||||
|
||||
@@ -19,7 +17,7 @@ type Props = {
|
||||
};
|
||||
export const DomainVerificationCard = ({ domain, connectionId }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { deleteDomain, mutate } = useEnterpriseConnections();
|
||||
const { deleteDomain, verifyDomain, mutate } = useEnterpriseConnections();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const deleteDomainHandler = async () => {
|
||||
@@ -49,7 +47,7 @@ export const DomainVerificationCard = ({ domain, connectionId }: Props) => {
|
||||
onOpenChange={setModal}
|
||||
domain={domain.name}
|
||||
token={domain.validation_token}
|
||||
connectionId={connectionId}
|
||||
onVerify={() => verifyDomain(connectionId, domain.name)}
|
||||
/>
|
||||
<div className={"flex flex-col"}>
|
||||
<span
|
||||
|
||||
@@ -16,29 +16,26 @@ import { Mark } from "@components/ui/Mark";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEnterpriseConnections } from "@/modules/integrations/sso/useEnterpriseConnections";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
domain: string;
|
||||
token: string;
|
||||
connectionId: string;
|
||||
onVerify: () => Promise<unknown>;
|
||||
};
|
||||
export const DomainVerificationModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
domain,
|
||||
token,
|
||||
connectionId,
|
||||
onVerify,
|
||||
}: Props) => {
|
||||
const { verifyDomain } = useEnterpriseConnections();
|
||||
|
||||
const startVerification = async () => {
|
||||
notify({
|
||||
title: "Domain Verification",
|
||||
description: `Verification for ${domain} has started`,
|
||||
promise: verifyDomain(connectionId, domain).then(() => {
|
||||
promise: onVerify().then(() => {
|
||||
onOpenChange(false);
|
||||
}),
|
||||
loadingMessage: "Starting domain verification...",
|
||||
|
||||
@@ -6,10 +6,8 @@ import { Settings } from "lucide-react";
|
||||
import { StaticImport } from "next/dist/shared/lib/get-img-props";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
DomainValidationStatus,
|
||||
EnterpriseConnection,
|
||||
} from "@/interfaces/IdentityProvider";
|
||||
import { DomainValidationStatus } from "@/interfaces/Account";
|
||||
import { EnterpriseConnection } from "@/interfaces/IdentityProvider";
|
||||
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
|
||||
import OidcSetupModal from "@/modules/integrations/sso/oidc/OidcSetupModal";
|
||||
import { OktaSsoSettings } from "@/modules/integrations/sso/okta/OktaSSOSettings";
|
||||
|
||||
@@ -16,7 +16,7 @@ import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, GlobeIcon, Repeat } from "lucide-react";
|
||||
import { StaticImport } from "next/dist/shared/lib/get-img-props";
|
||||
import React, { useState } from "react";
|
||||
import { DomainValidationStatus } from "@/interfaces/IdentityProvider";
|
||||
import { DomainValidationStatus } from "@/interfaces/Account";
|
||||
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
|
||||
import { DomainVerificationModal } from "@/modules/integrations/sso/DomainVerificationModal";
|
||||
import { useEnterpriseConnections } from "@/modules/integrations/sso/useEnterpriseConnections";
|
||||
@@ -36,7 +36,8 @@ export default function OidcSetupModal({
|
||||
logo,
|
||||
discoveryPlaceholder,
|
||||
}: Readonly<Props>) {
|
||||
const { createOrUpdateConnection, mutate } = useEnterpriseConnections();
|
||||
const { createOrUpdateConnection, verifyDomain, mutate } =
|
||||
useEnterpriseConnections();
|
||||
const [step, setStep] = useState(0);
|
||||
const maxSteps = 1;
|
||||
|
||||
@@ -101,7 +102,7 @@ export default function OidcSetupModal({
|
||||
onOpenChange={setDomainVerificationModal}
|
||||
domain={verificationDomain}
|
||||
token={verificationToken}
|
||||
connectionId={connectionId}
|
||||
onVerify={() => verifyDomain(connectionId, verificationDomain)}
|
||||
/>
|
||||
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -7,10 +7,8 @@ import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import integrationImage from "@/assets/integrations/okta.png";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import {
|
||||
DomainValidationStatus,
|
||||
EnterpriseConnection,
|
||||
} from "@/interfaces/IdentityProvider";
|
||||
import { DomainValidationStatus } from "@/interfaces/Account";
|
||||
import { EnterpriseConnection } from "@/interfaces/IdentityProvider";
|
||||
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
|
||||
import { OktaSsoSettings } from "@/modules/integrations/sso/okta/OktaSSOSettings";
|
||||
import OktaSSOSetup from "@/modules/integrations/sso/okta/OktaSSOSetup";
|
||||
|
||||
@@ -13,8 +13,8 @@ import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import integrationImage from "@/assets/integrations/okta.png";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { DomainValidationStatus } from "@/interfaces/Account";
|
||||
import {
|
||||
DomainValidationStatus,
|
||||
EnterpriseConnection,
|
||||
EnterpriseConnectionDomain,
|
||||
} from "@/interfaces/IdentityProvider";
|
||||
@@ -29,7 +29,8 @@ type Props = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
export const OktaSsoSettings = ({ open, onOpenChange, config }: Props) => {
|
||||
const { deleteConnection, addDomain, mutate } = useEnterpriseConnections();
|
||||
const { deleteConnection, addDomain, verifyDomain, mutate } =
|
||||
useEnterpriseConnections();
|
||||
const [tab, setTab] = useState("domains");
|
||||
const [domain, setDomain] = useState("");
|
||||
const { confirm } = useDialog();
|
||||
@@ -89,7 +90,7 @@ export const OktaSsoSettings = ({ open, onOpenChange, config }: Props) => {
|
||||
onOpenChange={setDomainVerificationModal}
|
||||
domain={domainVerification.name}
|
||||
token={domainVerification.token}
|
||||
connectionId={config.id}
|
||||
onVerify={() => verifyDomain(config.id, domainVerification.name)}
|
||||
/>
|
||||
<ModalContent
|
||||
maxWidthClass={cn("relative max-w-xl")}
|
||||
|
||||
Loaded 20 of 30 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user