mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-09 22:04:16 +00:00
Merge branch 'dev' into feat/roles-and-user-multi-selectors
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
} from "@app/components/Credenza";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
tier3LimitSet
|
||||
} from "@server/lib/billing/limitSet";
|
||||
import { FeatureId } from "@server/lib/billing/features";
|
||||
import TrialBillingBanner from "@app/components/TrialBillingBanner";
|
||||
|
||||
// Plan tier definitions matching the mockup
|
||||
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
|
||||
@@ -805,6 +807,20 @@ export default function BillingPage() {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{/* Trial Banner */}
|
||||
{isTrial && (
|
||||
<TrialBillingBanner
|
||||
onUpgrade={() => {
|
||||
const currentPlan = planOptions.find(
|
||||
(p) => p.id === currentPlanId
|
||||
);
|
||||
if (currentPlan?.tierType) {
|
||||
handleStartSubscription(currentPlan.tierType);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subscription Status Alert */}
|
||||
{isProblematicState && statusMessage && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
@@ -859,8 +875,19 @@ export default function BillingPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-2xl">
|
||||
{plan.name}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-2xl">
|
||||
{plan.name}
|
||||
</span>
|
||||
{isCurrentPlan && isTrial && (
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("billingTrialBadge") ||
|
||||
"Free Trial"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="text-xl">
|
||||
|
||||
@@ -45,6 +45,7 @@ export default async function RemoteExitNodesPage(
|
||||
type: node.type,
|
||||
dateCreated: node.dateCreated,
|
||||
version: node.version || undefined,
|
||||
updateAvailable: node.updateAvailable,
|
||||
orgId: params.orgId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,6 +160,18 @@ export default async function Page(props: {
|
||||
redirect={redirectUrl}
|
||||
forceLogin={forceLogin}
|
||||
defaultUser={defaultUser}
|
||||
orgSignIn={
|
||||
!isInvite &&
|
||||
(build === "saas" ||
|
||||
env.app.identityProviderMode === "org")
|
||||
? {
|
||||
href: `/auth/org${buildQueryString(searchParams)}`,
|
||||
linkText: t("orgAuthSignInToOrg"),
|
||||
descriptionText:
|
||||
t("needToSignInToOrg")
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -195,7 +207,8 @@ export default async function Page(props: {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isInvite &&
|
||||
{!useSmartLogin &&
|
||||
!isInvite &&
|
||||
(build === "saas" || env.app.identityProviderMode === "org") ? (
|
||||
<OrgSignInLink
|
||||
href={`/auth/org${buildQueryString(searchParams)}`}
|
||||
|
||||
@@ -145,21 +145,19 @@ const CredenzaTitle = ({ className, children, ...props }: CredenzaProps) => {
|
||||
};
|
||||
|
||||
const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
|
||||
// return (
|
||||
// <div className={cn("px-4 md:px-0 mb-4", className)} {...props}>
|
||||
// {children}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto overflow-x-hidden px-0",
|
||||
"relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="space-y-4">{children}</div>
|
||||
<div
|
||||
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -172,7 +170,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaFooter
|
||||
className={cn(
|
||||
"mt-8 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card md:mt-0 md:-mb-4 md:gap-0",
|
||||
"-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card md:-mb-4 md:gap-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -13,6 +13,7 @@ type DismissableBannerProps = {
|
||||
titleIcon: ReactNode;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
dismissable?: boolean;
|
||||
};
|
||||
|
||||
export const DismissableBanner = ({
|
||||
@@ -21,7 +22,8 @@ export const DismissableBanner = ({
|
||||
title,
|
||||
titleIcon,
|
||||
description,
|
||||
children
|
||||
children,
|
||||
dismissable = true
|
||||
}: DismissableBannerProps) => {
|
||||
const [isDismissed, setIsDismissed] = useState(true);
|
||||
const t = useTranslations();
|
||||
@@ -66,19 +68,21 @@ export const DismissableBanner = ({
|
||||
);
|
||||
};
|
||||
|
||||
if (isDismissed) {
|
||||
if (dismissable && isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
{dismissable && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
export type RemoteExitNodeRow = {
|
||||
id: string;
|
||||
@@ -33,6 +34,7 @@ export type RemoteExitNodeRow = {
|
||||
online: boolean;
|
||||
dateCreated: string;
|
||||
version?: string;
|
||||
updateAvailable?: boolean;
|
||||
};
|
||||
|
||||
type ExitNodesTableProps = {
|
||||
@@ -233,13 +235,18 @@ export default function ExitNodesTable({
|
||||
const originalRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{originalRow.version && originalRow.version ? (
|
||||
{originalRow.version ? (
|
||||
<Badge variant="secondary">
|
||||
{"v" + originalRow.version}
|
||||
</Badge>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
{originalRow.updateAvailable && (
|
||||
<InfoPopup
|
||||
info={t("pangolinNodeUpdateAvailableInfo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
KeyRound,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -50,6 +49,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
||||
@@ -63,6 +63,61 @@ export type IdpRow = {
|
||||
|
||||
type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number];
|
||||
|
||||
type ImportSourceOrg = { orgId: string; orgName: string };
|
||||
|
||||
type GroupedImportableIdp = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
variant: string;
|
||||
tags: string | null;
|
||||
sources: ImportSourceOrg[];
|
||||
};
|
||||
|
||||
function adminRowForImport(
|
||||
group: GroupedImportableIdp,
|
||||
source: ImportSourceOrg
|
||||
): AdminIdpRow {
|
||||
return {
|
||||
idpId: group.idpId,
|
||||
orgId: source.orgId,
|
||||
orgName: source.orgName,
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
variant: group.variant,
|
||||
tags: group.tags
|
||||
};
|
||||
}
|
||||
|
||||
function groupImportableIdps(rows: AdminIdpRow[]): GroupedImportableIdp[] {
|
||||
const map = new Map<number, GroupedImportableIdp>();
|
||||
for (const row of rows) {
|
||||
let g = map.get(row.idpId);
|
||||
if (!g) {
|
||||
g = {
|
||||
idpId: row.idpId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
variant: row.variant,
|
||||
tags: row.tags,
|
||||
sources: []
|
||||
};
|
||||
map.set(row.idpId, g);
|
||||
}
|
||||
if (!g.sources.some((s) => s.orgId === row.orgId)) {
|
||||
g.sources.push({ orgId: row.orgId, orgName: row.orgName });
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
.map((item) => ({
|
||||
...item,
|
||||
sources: [...item.sources].sort((a, b) =>
|
||||
a.orgName.localeCompare(b.orgName)
|
||||
)
|
||||
}))
|
||||
.sort((a, b) => b.name.localeCompare(a.name));
|
||||
}
|
||||
|
||||
function IdpImportRowIcon({
|
||||
type,
|
||||
variant
|
||||
@@ -114,16 +169,22 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
);
|
||||
}, [adminIdpsRaw, orgId, idps]);
|
||||
|
||||
const shownImportIdps = useMemo(() => {
|
||||
const importableGrouped = useMemo(
|
||||
() => groupImportableIdps(importableIdps),
|
||||
[importableIdps]
|
||||
);
|
||||
|
||||
const shownImportGrouped = useMemo(() => {
|
||||
const q = debouncedImportSearch.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return importableIdps;
|
||||
return importableGrouped;
|
||||
}
|
||||
return importableIdps.filter((row) => {
|
||||
const hay = `${row.orgName} ${row.name}`.toLowerCase();
|
||||
return importableGrouped.filter((group) => {
|
||||
const hay =
|
||||
`${group.name} ${group.sources.map((s) => s.orgName).join(" ")}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [importableIdps, debouncedImportSearch]);
|
||||
}, [importableGrouped, debouncedImportSearch]);
|
||||
|
||||
const deleteIdp = async (idpId: number) => {
|
||||
try {
|
||||
@@ -364,31 +425,44 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
{t("idpImportEmpty")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{shownImportIdps.map((row) => (
|
||||
{shownImportGrouped.map((group) => (
|
||||
<CommandItem
|
||||
key={`${row.idpId}:${row.orgId}`}
|
||||
key={group.idpId}
|
||||
className="items-start gap-3 py-2.5"
|
||||
value={`${row.idpId}:${row.orgId}:${row.orgName}:${row.name}`}
|
||||
value={`${group.idpId}:${group.name}:${group.sources.map((s) => s.orgName).join(" ")}`}
|
||||
disabled={!canImportOrgOidcIdp}
|
||||
onSelect={() => {
|
||||
if (!canImportOrgOidcIdp) {
|
||||
return;
|
||||
}
|
||||
void importIdp(row);
|
||||
void importIdp(
|
||||
adminRowForImport(
|
||||
group,
|
||||
group.sources[0]
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<IdpImportRowIcon
|
||||
type={row.type}
|
||||
variant={row.variant}
|
||||
type={group.type}
|
||||
variant={group.variant}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium leading-tight">
|
||||
{row.orgName}
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="truncate text-sm leading-tight text-muted-foreground">
|
||||
{row.name}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{group.sources.map((src) => (
|
||||
<Badge
|
||||
key={src.orgId}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate font-normal"
|
||||
>
|
||||
{src.orgName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
@@ -5,11 +5,15 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
type OrgSignInLinkProps = {
|
||||
href: string;
|
||||
linkText: string;
|
||||
descriptionText: string;
|
||||
primaryActionVariant?: "link" | "button";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_CLICKED = "orgSignInLinkClicked";
|
||||
@@ -18,7 +22,9 @@ const STORAGE_KEY_ACKNOWLEDGED = "orgSignInTipAcknowledged";
|
||||
export default function OrgSignInLink({
|
||||
href,
|
||||
linkText,
|
||||
descriptionText
|
||||
descriptionText,
|
||||
primaryActionVariant = "link",
|
||||
className
|
||||
}: OrgSignInLinkProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
@@ -93,14 +99,32 @@ export default function OrgSignInLink({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="text-sm text-center text-muted-foreground mt-8 flex flex-col items-center">
|
||||
<span>{descriptionText}</span>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="underline text-inherit bg-transparent border-none p-0 cursor-pointer"
|
||||
>
|
||||
{linkText}
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
primaryActionVariant === "button" && "gap-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{primaryActionVariant === "button" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center gap-2"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" aria-hidden />
|
||||
<span>{linkText}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="underline text-inherit bg-transparent border-none p-0 cursor-pointer"
|
||||
>
|
||||
{linkText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -81,10 +81,10 @@ export default function ProductUpdates({
|
||||
|
||||
const showNewVersionPopup = Boolean(
|
||||
latestVersion &&
|
||||
valid(latestVersion) &&
|
||||
valid(currentVersion) &&
|
||||
ignoredVersionUpdate !== latestVersion &&
|
||||
gt(latestVersion, currentVersion)
|
||||
valid(latestVersion) &&
|
||||
valid(currentVersion) &&
|
||||
ignoredVersionUpdate !== latestVersion &&
|
||||
gt(latestVersion, currentVersion)
|
||||
);
|
||||
|
||||
const filteredUpdates = data.updates.filter(
|
||||
@@ -103,40 +103,51 @@ export default function ProductUpdates({
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredUpdates.length > 1 && (
|
||||
<small
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground flex items-center gap-1 mt-2",
|
||||
showMoreUpdatesText
|
||||
? "animate-in fade-in duration-300"
|
||||
: "opacity-0"
|
||||
{filteredUpdates.length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{filteredUpdates.length > 1 && (
|
||||
<small
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground flex items-center gap-1",
|
||||
showMoreUpdatesText
|
||||
? "animate-in fade-in duration-300"
|
||||
: "opacity-0"
|
||||
)}
|
||||
>
|
||||
<BellIcon className="flex-none size-3" />
|
||||
<span>
|
||||
{showNewVersionPopup
|
||||
? t("productUpdateMoreInfo", {
|
||||
noOfUpdates:
|
||||
filteredUpdates.length
|
||||
})
|
||||
: t("productUpdateInfo", {
|
||||
noOfUpdates:
|
||||
filteredUpdates.length
|
||||
})}
|
||||
</span>
|
||||
</small>
|
||||
)}
|
||||
>
|
||||
<BellIcon className="flex-none size-3" />
|
||||
<span>
|
||||
{showNewVersionPopup
|
||||
? t("productUpdateMoreInfo", {
|
||||
noOfUpdates: filteredUpdates.length
|
||||
})
|
||||
: t("productUpdateInfo", {
|
||||
noOfUpdates: filteredUpdates.length
|
||||
})}
|
||||
</span>
|
||||
</small>
|
||||
<ProductUpdatesListPopup
|
||||
updates={filteredUpdates}
|
||||
show={filteredUpdates.length > 0}
|
||||
onDimissAll={() =>
|
||||
setProductUpdatesRead([
|
||||
...productUpdatesRead,
|
||||
...filteredUpdates.map(
|
||||
(update) => update.id
|
||||
)
|
||||
])
|
||||
}
|
||||
onDimiss={(id) =>
|
||||
setProductUpdatesRead([
|
||||
...productUpdatesRead,
|
||||
id
|
||||
])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ProductUpdatesListPopup
|
||||
updates={filteredUpdates}
|
||||
show={filteredUpdates.length > 0}
|
||||
onDimissAll={() =>
|
||||
setProductUpdatesRead([
|
||||
...productUpdatesRead,
|
||||
...filteredUpdates.map((update) => update.id)
|
||||
])
|
||||
}
|
||||
onDimiss={(id) =>
|
||||
setProductUpdatesRead([...productUpdatesRead, id])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewVersionAvailable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
import {
|
||||
InfoSection,
|
||||
@@ -9,77 +9,137 @@ import {
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type SiteInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
function formatPublicEndpoint(endpoint: string) {
|
||||
return endpoint.includes(":")
|
||||
? endpoint.substring(0, endpoint.lastIndexOf(":"))
|
||||
: endpoint;
|
||||
}
|
||||
|
||||
const getConnectionTypeString = (type: string) => {
|
||||
if (type === "newt") {
|
||||
return "Newt";
|
||||
} else if (type === "wireguard") {
|
||||
return "WireGuard";
|
||||
} else if (type === "local") {
|
||||
return t("local");
|
||||
} else {
|
||||
return t("unknown");
|
||||
}
|
||||
};
|
||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
const { site } = useSiteContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const identifierSection = (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{site.niceId}</InfoSectionContent>
|
||||
</InfoSection>
|
||||
);
|
||||
|
||||
const statusSection = (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
);
|
||||
|
||||
const endpointSection = site.endpoint ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{formatPublicEndpoint(site.endpoint)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null;
|
||||
|
||||
if (site.type === "newt") {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={site.endpoint ? 5 : 4}>
|
||||
{identifierSection}
|
||||
{statusSection}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("connectionType")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>Newt</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("newtVersion")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.newtVersion
|
||||
? `v${site.newtVersion}`
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{endpointSection}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (site.type === "wireguard") {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={site.endpoint ? 4 : 3}>
|
||||
{identifierSection}
|
||||
{statusSection}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("connectionType")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>WireGuard</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{endpointSection}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (site.type === "local") {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={site.endpoint ? 3 : 2}>
|
||||
{identifierSection}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("connectionType")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{t("local")}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{endpointSection}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={site.endpoint ? 4 : 3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{site.niceId}</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("status")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
)}
|
||||
<InfoSections cols={site.endpoint ? 3 : 2}>
|
||||
{identifierSection}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("connectionType")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{getConnectionTypeString(site.type)}
|
||||
</InfoSectionContent>
|
||||
<InfoSectionContent>{t("unknown")}</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{site.endpoint && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("publicIpEndpoint")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.endpoint.includes(":")
|
||||
? site.endpoint.substring(0, site.endpoint.lastIndexOf(":"))
|
||||
: site.endpoint}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
{endpointSection}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -15,15 +15,18 @@ import {
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useUserLookup } from "@app/hooks/useUserLookup";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
|
||||
import { useTranslations } from "next-intl";
|
||||
import LoginPasswordForm from "@app/components/LoginPasswordForm";
|
||||
import LoginOrgSelector from "@app/components/LoginOrgSelector";
|
||||
import SmartLoginOrgSelector from "@app/components/SmartLoginOrgSelector";
|
||||
import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import OrgSignInLink from "@app/components/OrgSignInLink";
|
||||
|
||||
const identifierSchema = z.object({
|
||||
identifier: z.string().min(1, "Username or email is required")
|
||||
@@ -39,10 +42,17 @@ const isValidEmail = (str: string): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
type OrgSignInConfig = {
|
||||
href: string;
|
||||
linkText: string;
|
||||
descriptionText: string;
|
||||
};
|
||||
|
||||
type SmartLoginFormProps = {
|
||||
redirect?: string;
|
||||
forceLogin?: boolean;
|
||||
defaultUser?: string;
|
||||
orgSignIn?: OrgSignInConfig;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -58,12 +68,31 @@ type ViewState =
|
||||
lookupResult: LookupUserResponse;
|
||||
};
|
||||
|
||||
function buildResetPasswordHref(
|
||||
dashboardUrl: string,
|
||||
identifier: string,
|
||||
redirectParam?: string
|
||||
) {
|
||||
const trimmed = identifier.trim();
|
||||
const params = new URLSearchParams();
|
||||
if (isValidEmail(trimmed)) {
|
||||
params.set("email", trimmed);
|
||||
}
|
||||
if (redirectParam) {
|
||||
params.set("redirect", redirectParam);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return `${dashboardUrl}/auth/reset-password${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
export default function SmartLoginForm({
|
||||
redirect,
|
||||
forceLogin,
|
||||
defaultUser
|
||||
defaultUser,
|
||||
orgSignIn
|
||||
}: SmartLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const { lookup, loading, error } = useUserLookup();
|
||||
const t = useTranslations();
|
||||
const [viewState, setViewState] = useState<ViewState>({ type: "initial" });
|
||||
@@ -78,6 +107,13 @@ export default function SmartLoginForm({
|
||||
}
|
||||
});
|
||||
|
||||
const watchedIdentifier = form.watch("identifier");
|
||||
const resetPasswordHref = buildResetPasswordHref(
|
||||
env.app.dashboardUrl,
|
||||
watchedIdentifier,
|
||||
redirect
|
||||
);
|
||||
|
||||
const hasAutoLookedUp = useRef(false);
|
||||
useEffect(() => {
|
||||
if (defaultUser?.trim() && !hasAutoLookedUp.current) {
|
||||
@@ -170,7 +206,7 @@ export default function SmartLoginForm({
|
||||
if (viewState.type === "orgSelector") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LoginOrgSelector
|
||||
<SmartLoginOrgSelector
|
||||
identifier={viewState.identifier}
|
||||
lookupResult={viewState.lookupResult}
|
||||
redirect={redirect}
|
||||
@@ -209,6 +245,15 @@ export default function SmartLoginForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={resetPasswordHref}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("passwordForgot")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{(error || securityKeyError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
@@ -219,7 +264,7 @@ export default function SmartLoginForm({
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
@@ -236,6 +281,28 @@ export default function SmartLoginForm({
|
||||
onError={setSecurityKeyError}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{orgSignIn && (
|
||||
<>
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="px-2 bg-card text-muted-foreground">
|
||||
{t("idpContinue")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<OrgSignInLink
|
||||
href={orgSignIn.href}
|
||||
linkText={orgSignIn.linkText}
|
||||
descriptionText={orgSignIn.descriptionText}
|
||||
primaryActionVariant="button"
|
||||
className="mt-0"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
297
src/components/SmartLoginOrgSelector.tsx
Normal file
297
src/components/SmartLoginOrgSelector.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import LoginPasswordForm from "@app/components/LoginPasswordForm";
|
||||
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
|
||||
import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||
import {
|
||||
redirect as redirectTo,
|
||||
useRouter,
|
||||
useSearchParams
|
||||
} from "next/navigation";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
|
||||
type SmartLoginOrgSelectorProps = {
|
||||
identifier: string;
|
||||
lookupResult: LookupUserResponse;
|
||||
redirect?: string;
|
||||
forceLogin?: boolean;
|
||||
onUseDifferentAccount?: () => void;
|
||||
};
|
||||
|
||||
type OrgBucket = {
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
idps: Array<{
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant: string | null;
|
||||
}>;
|
||||
hasInternalAuth: boolean;
|
||||
};
|
||||
|
||||
type GroupedLoginIdp = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant: string | null;
|
||||
orgs: { orgId: string; orgName: string }[];
|
||||
};
|
||||
|
||||
function buildOrgMap(lookupResult: LookupUserResponse) {
|
||||
const orgMap = new Map<string, OrgBucket>();
|
||||
|
||||
for (const account of lookupResult.accounts) {
|
||||
for (const org of account.orgs) {
|
||||
if (!orgMap.has(org.orgId)) {
|
||||
orgMap.set(org.orgId, {
|
||||
orgId: org.orgId,
|
||||
orgName: org.orgName,
|
||||
idps: org.idps,
|
||||
hasInternalAuth: org.hasInternalAuth
|
||||
});
|
||||
} else {
|
||||
const existing = orgMap.get(org.orgId)!;
|
||||
const existingIdpIds = new Set(
|
||||
existing.idps.map((i) => i.idpId)
|
||||
);
|
||||
for (const idp of org.idps) {
|
||||
if (!existingIdpIds.has(idp.idpId)) {
|
||||
existing.idps.push(idp);
|
||||
}
|
||||
}
|
||||
if (org.hasInternalAuth) {
|
||||
existing.hasInternalAuth = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(orgMap.values());
|
||||
}
|
||||
|
||||
function groupIdpsAcrossOrgs(orgs: OrgBucket[]): GroupedLoginIdp[] {
|
||||
const map = new Map<number, GroupedLoginIdp>();
|
||||
|
||||
for (const org of orgs) {
|
||||
for (const idp of org.idps) {
|
||||
let g = map.get(idp.idpId);
|
||||
if (!g) {
|
||||
g = {
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.variant,
|
||||
orgs: []
|
||||
};
|
||||
map.set(idp.idpId, g);
|
||||
}
|
||||
if (!g.orgs.some((o) => o.orgId === org.orgId)) {
|
||||
g.orgs.push({ orgId: org.orgId, orgName: org.orgName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.map((g) => ({
|
||||
...g,
|
||||
orgs: [...g.orgs].sort((a, b) => a.orgName.localeCompare(b.orgName))
|
||||
}))
|
||||
.sort((a, b) => b.name.localeCompare(a.name));
|
||||
}
|
||||
|
||||
export default function SmartLoginOrgSelector({
|
||||
identifier,
|
||||
lookupResult,
|
||||
redirect,
|
||||
forceLogin,
|
||||
onUseDifferentAccount
|
||||
}: SmartLoginOrgSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingIdpId, setPendingIdpId] = useState<number | null>(null);
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const orgs = buildOrgMap(lookupResult);
|
||||
const groupedIdps = groupIdpsAcrossOrgs(orgs);
|
||||
|
||||
const hasInternalAccount = lookupResult.accounts.some(
|
||||
(acc) => acc.hasInternalAuth
|
||||
);
|
||||
|
||||
function goToApp() {
|
||||
const url = window.location.href.split("?")[0];
|
||||
router.push(url);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get("gotoapp")) {
|
||||
goToApp();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function loginWithIdp(idpId: number, orgId: string) {
|
||||
setPendingIdpId(idpId);
|
||||
setError(null);
|
||||
|
||||
let redirectToUrl: string | undefined;
|
||||
try {
|
||||
const safeRedirect = cleanRedirect(redirect || "/");
|
||||
const response = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
safeRedirect,
|
||||
orgId,
|
||||
forceLogin
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setPendingIdpId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
if (data?.redirectUrl) {
|
||||
redirectToUrl = data.redirectUrl;
|
||||
}
|
||||
} catch {
|
||||
setError(
|
||||
t("loginError", {
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectToUrl) {
|
||||
redirectTo(redirectToUrl);
|
||||
} else {
|
||||
setPendingIdpId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (showPasswordForm) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<UserProfileCard
|
||||
identifier={identifier}
|
||||
description={t("loginSelectAuthenticationMethod")}
|
||||
onUseDifferentAccount={onUseDifferentAccount}
|
||||
useDifferentAccountText={t(
|
||||
"deviceLoginUseDifferentAccount"
|
||||
)}
|
||||
/>
|
||||
<LoginPasswordForm
|
||||
identifier={identifier}
|
||||
redirect={redirect}
|
||||
forceLogin={forceLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserProfileCard
|
||||
identifier={identifier}
|
||||
description={t("loginSelectAuthenticationMethod")}
|
||||
onUseDifferentAccount={onUseDifferentAccount}
|
||||
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||
/>
|
||||
|
||||
{hasInternalAccount && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
>
|
||||
{t("signInWithPassword")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedIdps.length > 0 ? (
|
||||
<div className="mt-3 space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="px-2 bg-card text-muted-foreground">
|
||||
{t("idpContinue")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{params.get("gotoapp") ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
goToApp();
|
||||
}}
|
||||
>
|
||||
{t("continueToApplication")}
|
||||
</Button>
|
||||
) : (
|
||||
groupedIdps.map((group) => {
|
||||
const effectiveType =
|
||||
group.variant || group.name.toLowerCase();
|
||||
const sourceOrgId = group.orgs[0].orgId;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={group.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full flex flex-wrap items-center justify-start gap-x-2 gap-y-1.5 py-3 text-left"
|
||||
onClick={() => {
|
||||
void loginWithIdp(
|
||||
group.idpId,
|
||||
sourceOrgId
|
||||
);
|
||||
}}
|
||||
disabled={pendingIdpId !== null}
|
||||
>
|
||||
<IdpTypeIcon
|
||||
type={effectiveType}
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="font-medium shrink-0">
|
||||
{group.name}
|
||||
</span>
|
||||
{group.orgs.map((org) => (
|
||||
<Badge
|
||||
key={org.orgId}
|
||||
variant="secondary"
|
||||
className="max-w-full shrink-0 truncate font-normal"
|
||||
>
|
||||
{org.orgName}
|
||||
</Badge>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/TrialBillingBanner.tsx
Normal file
38
src/components/TrialBillingBanner.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ClockIcon, ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
type TrialBillingBannerProps = {
|
||||
onUpgrade: () => void;
|
||||
};
|
||||
|
||||
export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="trial-billing-banner-dismissed"
|
||||
version={1}
|
||||
title={t("billingTrialBannerTitle")}
|
||||
titleIcon={<ClockIcon className="w-5 h-5 text-primary" />}
|
||||
description={t("billingTrialBannerDescription")}
|
||||
dismissable={false}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
{t("billingTrialBannerUpgrade")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</DismissableBanner>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialBillingBanner;
|
||||
@@ -15,12 +15,15 @@ import {
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
@@ -925,6 +928,69 @@ function WebhookActionFields({
|
||||
/>
|
||||
</div>
|
||||
<WebhookHeadersField index={index} control={control} form={form} />
|
||||
{/* Body Template */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="font-medium text-sm block">
|
||||
{t("httpDestBodyTemplateTitle")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("httpDestBodyTemplateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.useBodyTemplate`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-3">
|
||||
<FormControl>
|
||||
<Switch
|
||||
id={`body-template-${index}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<Label
|
||||
htmlFor={`body-template-${index}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("httpDestEnableBodyTemplate")}
|
||||
</Label>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{useWatch({
|
||||
control,
|
||||
name: `actions.${index}.useBodyTemplate`
|
||||
}) && (
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.bodyTemplate`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("httpDestBodyTemplateLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={
|
||||
'{\n "event": "{{event}}",\n "timestamp": "{{timestamp}}",\n "status": "{{status}}",\n "data": {{data}}\n}'
|
||||
}
|
||||
className="font-mono text-xs min-h-45 resize-y"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("httpDestBodyTemplateHint")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,7 +368,9 @@ export default function AlertRuleGraphEditor({
|
||||
customHeaderName:
|
||||
"",
|
||||
customHeaderValue:
|
||||
""
|
||||
"",
|
||||
useBodyTemplate: false,
|
||||
bodyTemplate: ""
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -113,10 +113,10 @@ export function ResourceTargetAddressItem({
|
||||
? selectedSite?.name
|
||||
: t("siteSelect")}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-45">
|
||||
<PopoverContent className="p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
@@ -225,7 +225,6 @@ export function ResourceTargetAddressItem({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,8 @@ export type AlertRuleFormAction =
|
||||
basicCredentials: string;
|
||||
customHeaderName: string;
|
||||
customHeaderValue: string;
|
||||
useBodyTemplate: boolean;
|
||||
bodyTemplate: string;
|
||||
};
|
||||
|
||||
export type AlertRuleFormValues = {
|
||||
@@ -130,6 +132,8 @@ export type AlertRuleApiResponse = {
|
||||
customHeaderValue?: string;
|
||||
headers?: { key: string; value: string }[];
|
||||
method?: string;
|
||||
useBodyTemplate?: boolean;
|
||||
bodyTemplate?: string;
|
||||
} | null;
|
||||
}[];
|
||||
};
|
||||
@@ -187,7 +191,9 @@ export function buildFormSchema(t: (k: string) => string) {
|
||||
bearerToken: z.string(),
|
||||
basicCredentials: z.string(),
|
||||
customHeaderName: z.string(),
|
||||
customHeaderValue: z.string()
|
||||
customHeaderValue: z.string(),
|
||||
useBodyTemplate: z.boolean().default(false),
|
||||
bodyTemplate: z.string().default("")
|
||||
})
|
||||
])
|
||||
)
|
||||
@@ -415,7 +421,9 @@ export function apiResponseToFormValues(
|
||||
bearerToken: cfg?.bearerToken ?? "",
|
||||
basicCredentials: cfg?.basicCredentials ?? "",
|
||||
customHeaderName: cfg?.customHeaderName ?? "",
|
||||
customHeaderValue: cfg?.customHeaderValue ?? ""
|
||||
customHeaderValue: cfg?.customHeaderValue ?? "",
|
||||
useBodyTemplate: cfg?.useBodyTemplate ?? false,
|
||||
bodyTemplate: cfg?.bodyTemplate ?? ""
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,7 +487,11 @@ export function formValuesToApiPayload(
|
||||
customHeaderName: action.customHeaderName || undefined,
|
||||
customHeaderValue: action.customHeaderValue || undefined,
|
||||
headers: action.headers.filter((h) => h.key.trim()),
|
||||
method: action.method
|
||||
method: action.method,
|
||||
useBodyTemplate: action.useBodyTemplate || undefined,
|
||||
bodyTemplate: action.useBodyTemplate
|
||||
? action.bodyTemplate || undefined
|
||||
: undefined
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user