This commit is contained in:
Owen
2025-10-04 18:36:44 -07:00
parent 3123f858bb
commit c2c907852d
320 changed files with 35785 additions and 2984 deletions

View File

@@ -21,12 +21,14 @@ type AutoLoginHandlerProps = {
resourceId: number;
skipToIdpId: number;
redirectUrl: string;
orgId?: string;
};
export default function AutoLoginHandler({
resourceId,
skipToIdpId,
redirectUrl
redirectUrl,
orgId
}: AutoLoginHandlerProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
@@ -44,7 +46,8 @@ export default function AutoLoginHandler({
try {
const response = await generateOidcUrlProxy(
skipToIdpId,
redirectUrl
redirectUrl,
orgId
);
if (response.error) {
@@ -83,7 +86,9 @@ export default function AutoLoginHandler({
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("autoLoginTitle")}</CardTitle>
<CardDescription>{t("autoLoginDescription")}</CardDescription>
<CardDescription>
{t("autoLoginDescription")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{loading && (

View File

@@ -27,10 +27,12 @@ export default function BrandingLogo(props: BrandingLogoProps) {
}
if (lightOrDark === "light") {
return "/logo/word_mark_black.png";
return (
env.branding.logo?.lightPath || "/logo/word_mark_black.png"
);
}
return "/logo/word_mark_white.png";
return env.branding.logo?.darkPath || "/logo/word_mark_white.png";
}
const path = getPath();

View File

@@ -38,7 +38,10 @@ export default function DashboardLoginForm({
<Card className="shadow-md w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={58} width={175} />
<BrandingLogo
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
/>
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>

View File

@@ -32,6 +32,7 @@ import { createApiClient, formatAxiosError } from "@/lib/api";
import { useEnvContext } from "@/hooks/useEnvContext";
import { toast } from "@/hooks/useToast";
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/privateCheckDomainNamespaceAvailability";
import { AxiosResponse } from "axios";
import { cn } from "@/lib/cn";
import { useTranslations } from "next-intl";
@@ -155,7 +156,10 @@ export default function DomainPicker2({
fullDomain: firstOrgDomain.baseDomain,
baseDomain: firstOrgDomain.baseDomain
});
} else if ((build === "saas" || build === "enterprise") && !hideFreeDomain) {
} else if (
(build === "saas" || build === "enterprise") &&
!hideFreeDomain
) {
// If no organization domains, select the provided domain option
const domainOptionText =
build === "enterprise"
@@ -198,7 +202,21 @@ export default function DomainPicker2({
.toLowerCase()
.replace(/\./g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-");
.replace(/-+/g, "-") // Replace multiple consecutive dashes with single dash
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
if (build != "oss") {
const response = await api.get<
AxiosResponse<CheckDomainAvailabilityResponse>
>(
`/domain/check-namespace-availability?subdomain=${encodeURIComponent(checkSubdomain)}`
);
if (response.status === 200) {
const { options } = response.data.data;
setAvailableOptions(options);
}
}
} catch (error) {
console.error("Failed to check domain availability:", error);
setAvailableOptions([]);
@@ -272,13 +290,16 @@ export default function DomainPicker2({
toast({
variant: "destructive",
title: t("domainPickerInvalidSubdomain"),
description: t("domainPickerInvalidSubdomainRemoved", { sub }),
description: t("domainPickerInvalidSubdomainRemoved", { sub })
});
return "";
}
const ok = validateByDomainType(sanitized, {
type: base.type === "provided-search" ? "provided-search" : "organization",
type:
base.type === "provided-search"
? "provided-search"
: "organization",
domainType: base.domainType
});
@@ -286,7 +307,10 @@ export default function DomainPicker2({
toast({
variant: "destructive",
title: t("domainPickerInvalidSubdomain"),
description: t("domainPickerInvalidSubdomainCannotMakeValid", { sub, domain: base.domain }),
description: t("domainPickerInvalidSubdomainCannotMakeValid", {
sub,
domain: base.domain
})
});
return "";
}
@@ -294,7 +318,10 @@ export default function DomainPicker2({
if (sub !== sanitized) {
toast({
title: t("domainPickerSubdomainSanitized"),
description: t("domainPickerSubdomainCorrected", { sub, sanitized }),
description: t("domainPickerSubdomainCorrected", {
sub,
sanitized
})
});
}
@@ -365,7 +392,8 @@ export default function DomainPicker2({
onDomainChange?.({
domainId: option.domainId || "",
domainNamespaceId: option.domainNamespaceId,
type: option.type === "provided-search" ? "provided" : "organization",
type:
option.type === "provided-search" ? "provided" : "organization",
subdomain: sub || undefined,
fullDomain,
baseDomain: option.domain
@@ -389,12 +417,16 @@ export default function DomainPicker2({
});
};
const isSubdomainValid = selectedBaseDomain && subdomainInput
? validateByDomainType(subdomainInput, {
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
domainType: selectedBaseDomain.domainType
})
: true;
const isSubdomainValid =
selectedBaseDomain && subdomainInput
? validateByDomainType(subdomainInput, {
type:
selectedBaseDomain.type === "provided-search"
? "provided-search"
: "organization",
domainType: selectedBaseDomain.domainType
})
: true;
const showSubdomainInput =
selectedBaseDomain &&
@@ -415,7 +447,6 @@ export default function DomainPicker2({
const hasMoreProvided =
sortedAvailableOptions.length > providedDomainsShown;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -434,16 +465,16 @@ export default function DomainPicker2({
showProvidedDomainSearch
? ""
: showSubdomainInput
? ""
: t("domainPickerNotAvailableForCname")
? ""
: t("domainPickerNotAvailableForCname")
}
disabled={
!showSubdomainInput && !showProvidedDomainSearch
}
className={cn(
!isSubdomainValid &&
subdomainInput &&
"border-red-500 focus:border-red-500"
subdomainInput &&
"border-red-500 focus:border-red-500"
)}
onChange={(e) => {
if (showProvidedDomainSearch) {
@@ -453,11 +484,13 @@ export default function DomainPicker2({
}
}}
/>
{showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (
<p className="text-sm text-red-500">
{t("domainPickerInvalidSubdomainStructure")}
</p>
)}
{showSubdomainInput &&
subdomainInput &&
!isValidSubdomainStructure(subdomainInput) && (
<p className="text-sm text-red-500">
{t("domainPickerInvalidSubdomainStructure")}
</p>
)}
{showSubdomainInput && !subdomainInput && (
<p className="text-sm text-muted-foreground">
{t("domainPickerEnterSubdomainOrLeaveBlank")}
@@ -483,7 +516,7 @@ export default function DomainPicker2({
{selectedBaseDomain ? (
<div className="flex items-center space-x-2 min-w-0 flex-1">
{selectedBaseDomain.type ===
"organization" ? null : (
"organization" ? null : (
<Zap className="h-4 w-4 flex-shrink-0" />
)}
<span className="truncate">
@@ -557,8 +590,12 @@ export default function DomainPicker2({
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? t("domainPickerVerified")
: t("domainPickerUnverified")}
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)}
</span>
</div>
<Check
@@ -576,21 +613,24 @@ export default function DomainPicker2({
</CommandList>
</CommandGroup>
{(build === "saas" ||
build === "enterprise") && !hideFreeDomain && (
build === "enterprise") &&
!hideFreeDomain && (
<CommandSeparator className="my-2" />
)}
</>
)}
{(build === "saas" ||
build === "enterprise") && !hideFreeDomain && (
{(build === "saas" || build === "enterprise") &&
!hideFreeDomain && (
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t("domainPickerFreeDomains")
"domainPickerProvidedDomains"
)
: t(
"domainPickerFreeDomains"
)
}
className="py-2"
>
@@ -602,9 +642,13 @@ export default function DomainPicker2({
id: "provided-search",
domain:
build ===
"enterprise"
? t("domainPickerProvidedDomain")
: t("domainPickerFreeProvidedDomain"),
"enterprise"
? t(
"domainPickerProvidedDomain"
)
: t(
"domainPickerFreeProvidedDomain"
),
type: "provided-search"
})
}
@@ -615,9 +659,14 @@ export default function DomainPicker2({
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{build === "enterprise"
? t("domainPickerProvidedDomain")
: t("domainPickerFreeProvidedDomain")}
{build ===
"enterprise"
? t(
"domainPickerProvidedDomain"
)
: t(
"domainPickerFreeProvidedDomain"
)}
</span>
<span className="text-xs text-muted-foreground">
{t(
@@ -644,6 +693,15 @@ export default function DomainPicker2({
</div>
</div>
{/*showProvidedDomainSearch && build === "saas" && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("domainPickerNotWorkSelfHosted")}
</AlertDescription>
</Alert>
)*/}
{showProvidedDomainSearch && (
<div className="space-y-4">
{isChecking && (
@@ -693,7 +751,7 @@ export default function DomainPicker2({
htmlFor={option.domainNamespaceId}
data-state={
selectedProvidedDomain?.domainNamespaceId ===
option.domainNamespaceId
option.domainNamespaceId
? "checked"
: "unchecked"
}

View File

@@ -0,0 +1,580 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { HeadersInput } from "@app/components/HeadersInput";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@/components/Credenza";
import { toast } from "@/hooks/useToast";
import { useTranslations } from "next-intl";
type HealthCheckConfig = {
hcEnabled: boolean;
hcPath: string;
hcMethod: string;
hcInterval: number;
hcTimeout: number;
hcStatus: number | null;
hcHeaders?: { name: string; value: string }[] | null;
hcScheme?: string;
hcHostname: string;
hcPort: number;
hcFollowRedirects: boolean;
hcMode: string;
hcUnhealthyInterval: number;
};
type HealthCheckDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
targetId: number;
targetAddress: string;
targetMethod?: string;
initialConfig?: Partial<HealthCheckConfig>;
onChanges: (config: HealthCheckConfig) => Promise<void>;
};
export default function HealthCheckDialog({
open,
setOpen,
targetId,
targetAddress,
targetMethod,
initialConfig,
onChanges
}: HealthCheckDialogProps) {
const t = useTranslations();
const healthCheckSchema = z.object({
hcEnabled: z.boolean(),
hcPath: z.string().min(1, { message: t("healthCheckPathRequired") }),
hcMethod: z
.string()
.min(1, { message: t("healthCheckMethodRequired") }),
hcInterval: z
.number()
.int()
.positive()
.min(5, { message: t("healthCheckIntervalMin") }),
hcTimeout: z
.number()
.int()
.positive()
.min(1, { message: t("healthCheckTimeoutMin") }),
hcStatus: z.number().int().positive().min(100).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
hcScheme: z.string().optional(),
hcHostname: z.string(),
hcPort: z.number().positive().gt(0).lte(65535),
hcFollowRedirects: z.boolean(),
hcMode: z.string(),
hcUnhealthyInterval: z.number().int().positive().min(5)
});
const form = useForm<z.infer<typeof healthCheckSchema>>({
resolver: zodResolver(healthCheckSchema),
defaultValues: {}
});
useEffect(() => {
if (!open) return;
// Determine default scheme from target method
const getDefaultScheme = () => {
if (initialConfig?.hcScheme) {
return initialConfig.hcScheme;
}
// Default to target method if it's http or https, otherwise default to http
if (targetMethod === "https") {
return "https";
}
return "http";
};
form.reset({
hcEnabled: initialConfig?.hcEnabled,
hcPath: initialConfig?.hcPath,
hcMethod: initialConfig?.hcMethod,
hcInterval: initialConfig?.hcInterval,
hcTimeout: initialConfig?.hcTimeout,
hcStatus: initialConfig?.hcStatus,
hcHeaders: initialConfig?.hcHeaders,
hcScheme: getDefaultScheme(),
hcHostname: initialConfig?.hcHostname,
hcPort: initialConfig?.hcPort,
hcFollowRedirects: initialConfig?.hcFollowRedirects,
hcMode: initialConfig?.hcMode,
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval
});
}, [open]);
const watchedEnabled = form.watch("hcEnabled");
const handleFieldChange = async (fieldName: string, value: any) => {
try {
const currentValues = form.getValues();
const updatedValues = { ...currentValues, [fieldName]: value };
await onChanges({
...updatedValues,
hcStatus: updatedValues.hcStatus || null
});
} catch (error) {
toast({
title: t("healthCheckError"),
description: t("healthCheckErrorDescription"),
variant: "destructive"
});
}
};
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("configureHealthCheck")}</CredenzaTitle>
<CredenzaDescription>
{t("configureHealthCheckDescription", {
target: targetAddress
})}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form className="space-y-6">
{/* Enable Health Checks */}
<FormField
control={form.control}
name="hcEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base font-semibold">
{t("enableHealthChecks")}
</FormLabel>
<FormDescription>
{t(
"enableHealthChecksDescription"
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
handleFieldChange(
"hcEnabled",
value
);
}}
/>
</FormControl>
</FormItem>
)}
/>
{watchedEnabled && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<FormField
control={form.control}
name="hcScheme"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("healthScheme")}
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
handleFieldChange(
"hcScheme",
value
);
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"healthSelectScheme"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">
HTTP
</SelectItem>
<SelectItem value="https">
HTTPS
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcHostname"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("healthHostname")}
</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(
e
);
handleFieldChange(
"hcHostname",
e.target
.value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcPort"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("healthPort")}
</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcPort",
value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcPath"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("healthCheckPath")}
</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(
e
);
handleFieldChange(
"hcPath",
e.target
.value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* HTTP Method */}
<FormField
control={form.control}
name="hcMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("httpMethod")}
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
handleFieldChange(
"hcMethod",
value
);
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectHttpMethod"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="GET">
GET
</SelectItem>
<SelectItem value="POST">
POST
</SelectItem>
<SelectItem value="HEAD">
HEAD
</SelectItem>
<SelectItem value="PUT">
PUT
</SelectItem>
<SelectItem value="DELETE">
DELETE
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Check Interval, Timeout, and Retry Attempts */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="hcInterval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t(
"healthyIntervalSeconds"
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcInterval",
value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcUnhealthyInterval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t(
"unhealthyIntervalSeconds"
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcUnhealthyInterval",
value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcTimeout"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("timeoutSeconds")}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcTimeout",
value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
{t("timeIsInSeconds")}
</FormDescription>
</div>
{/* Expected Response Codes */}
<FormField
control={form.control}
name="hcStatus"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("expectedResponseCodes")}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={
field.value || ""
}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcStatus",
value
);
}}
/>
</FormControl>
<FormDescription>
{t(
"expectedResponseCodesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Custom Headers */}
<FormField
control={form.control}
name="hcHeaders"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={(value) => {
field.onChange(value);
handleFieldChange(
"hcHeaders",
value
);
}}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"customHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button onClick={() => setOpen(false)}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -6,6 +6,10 @@ import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "./ui/badge";
import { build } from "@server/build";
interface LayoutHeaderProps {
showTopBar: boolean;
@@ -14,6 +18,7 @@ interface LayoutHeaderProps {
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
const { env } = useEnvContext();
useEffect(() => {
function getPath() {
@@ -44,16 +49,18 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
<div className="h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center">
{path && (
<Image
src={path}
alt="Pangolin"
width={98}
height={32}
className="h-8 w-auto"
/>
)}
<BrandingLogo
width={
env.branding.logo?.navbar?.width || 98
}
height={
env.branding.logo?.navbar?.height || 32
}
/>
</Link>
{/* {build === "saas" && (
<Badge variant="secondary">Cloud Beta</Badge>
)} */}
</div>
{showTopBar && (

View File

@@ -57,6 +57,16 @@ export function LayoutSidebar({
setSidebarStateCookie(isSidebarCollapsed);
}, [isSidebarCollapsed]);
function loadFooterLinks(): { text: string; href?: string }[] | undefined {
if (env.branding.footer) {
try {
return JSON.parse(env.branding.footer);
} catch (e) {
console.error("Failed to parse BRANDING_FOOTER", e);
}
}
}
return (
<div
className={cn(
@@ -113,31 +123,62 @@ export function LayoutSidebar({
<SupporterStatus isCollapsed={isSidebarCollapsed} />
{!isSidebarCollapsed && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t("communityEdition")
: t("commercialEdition")}
<FaGithub size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div>
{loadFooterLinks() ? (
<>
{loadFooterLinks()!.map((link, index) => (
<div
key={index}
className="whitespace-nowrap"
>
{link.href ? (
<div className="text-xs text-muted-foreground text-center">
<Link
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{link.text}
<ExternalLink size={12} />
</Link>
</div>
) : (
<div className="text-xs text-muted-foreground text-center">
{link.text}
</div>
)}
</div>
))}
</>
) : (
<>
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t("communityEdition")
: t("commercialEdition")}
<FaGithub size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div>
)}
</>
)}
</div>
)}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
@@ -22,10 +22,7 @@ import {
CardTitle
} from "@app/components/ui/card";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { LockIcon, FingerprintIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import {
@@ -49,6 +46,9 @@ import {
} from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
// @ts-ignore
import { loadReoScript } from "reodotdev";
import { build } from "@server/build";
export type LoginFormIDP = {
idpId: number;
@@ -60,13 +60,18 @@ type LoginFormProps = {
redirect?: string;
onLogin?: () => void | Promise<void>;
idps?: LoginFormIDP[];
orgId?: string;
};
export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
export default function LoginForm({
redirect,
onLogin,
idps,
orgId
}: LoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [error, setError] = useState<string | null>(null);
@@ -77,10 +82,32 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
const t = useTranslations();
const currentHost = typeof window !== "undefined" ? window.location.hostname : "";
const currentHost =
typeof window !== "undefined" ? window.location.hostname : "";
const expectedHost = new URL(env.app.dashboardUrl).host;
const isExpectedHost = currentHost === expectedHost;
const [reo, setReo] = useState<any | undefined>(undefined);
useEffect(() => {
async function init() {
if (env.app.environment !== "prod") {
return;
}
try {
const clientID = env.server.reoClientId;
const reoClient = await loadReoScript({ clientID });
await reoClient.init({ clientID });
setReo(reoClient);
} catch (e) {
console.error("Failed to load Reo script", e);
}
}
if (build == "saas") {
init();
}
}, []);
const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") })
@@ -183,26 +210,13 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
}
} catch (e: any) {
if (e.isAxiosError) {
setError(
formatAxiosError(
e,
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
)
);
} else {
console.error(e);
setError(
e.message ||
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
);
}
console.error(e);
setError(
t("securityKeyAuthError", {
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
setLoading(false);
setShowSecurityKeyPrompt(false);
@@ -224,6 +238,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
code
});
try {
const identity = {
username: email,
type: "email" // can be one of email, github, linkedin, gmail, userID,
};
if (reo) {
reo.identify(identity);
}
} catch (e) {
console.error("Reo identify error:", e);
}
if (response.error) {
setError(response.message);
return;
@@ -253,7 +279,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
if (data.emailVerificationRequired) {
if (!isExpectedHost) {
setError(t("emailVerificationRequired", { dashboardUrl: env.app.dashboardUrl }));
setError(
t("emailVerificationRequired", {
dashboardUrl: env.app.dashboardUrl
})
);
return;
}
if (redirect) {
@@ -266,7 +296,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
if (data.twoFactorSetupRequired) {
if (!isExpectedHost) {
setError(t("twoFactorSetupRequired", { dashboardUrl: env.app.dashboardUrl }));
setError(
t("twoFactorSetupRequired", {
dashboardUrl: env.app.dashboardUrl
})
);
return;
}
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
@@ -278,25 +312,13 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
await onLogin();
}
} catch (e: any) {
if (e.isAxiosError) {
const errorMessage = formatAxiosError(
e,
t("loginError", {
defaultValue: "Failed to log in"
})
);
setError(errorMessage);
return;
} else {
console.error(e);
setError(
e.message ||
t("loginError", {
defaultValue: "Failed to log in"
})
);
return;
}
console.error(e);
setError(
t("loginError", {
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
setLoading(false);
}
@@ -307,7 +329,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
try {
const data = await generateOidcUrlProxy(
idpId,
redirect || "/"
redirect || "/",
orgId
);
const url = data.data?.redirectUrl;
if (data.error) {
@@ -527,7 +550,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</div>
{idps.map((idp) => {
const effectiveType = idp.variant || idp.name.toLowerCase();
const effectiveType =
idp.variant || idp.name.toLowerCase();
return (
<Button

View File

@@ -8,6 +8,8 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { build } from "@server/build";
type PermissionsSelectBoxProps = {
root?: boolean;
@@ -17,6 +19,7 @@ type PermissionsSelectBoxProps = {
function getActionsCategories(root: boolean) {
const t = useTranslations();
const { env } = useEnvContext();
const actionsByCategory: Record<string, Record<string, string>> = {
Organization: {
@@ -34,12 +37,12 @@ function getActionsCategories(root: boolean) {
},
Site: {
[t('actionCreateSite')]: "createSite",
[t('actionDeleteSite')]: "deleteSite",
[t('actionGetSite')]: "getSite",
[t('actionListSites')]: "listSites",
[t('actionUpdateSite')]: "updateSite",
[t('actionListSiteRoles')]: "listSiteRoles"
[t("actionCreateSite")]: "createSite",
[t("actionDeleteSite")]: "deleteSite",
[t("actionGetSite")]: "getSite",
[t("actionListSites")]: "listSites",
[t("actionUpdateSite")]: "updateSite",
[t("actionListSiteRoles")]: "listSiteRoles"
},
Resource: {
@@ -64,26 +67,26 @@ function getActionsCategories(root: boolean) {
},
Target: {
[t('actionCreateTarget')]: "createTarget",
[t('actionDeleteTarget')]: "deleteTarget",
[t('actionGetTarget')]: "getTarget",
[t('actionListTargets')]: "listTargets",
[t('actionUpdateTarget')]: "updateTarget"
[t("actionCreateTarget")]: "createTarget",
[t("actionDeleteTarget")]: "deleteTarget",
[t("actionGetTarget")]: "getTarget",
[t("actionListTargets")]: "listTargets",
[t("actionUpdateTarget")]: "updateTarget"
},
Role: {
[t('actionCreateRole')]: "createRole",
[t('actionDeleteRole')]: "deleteRole",
[t('actionGetRole')]: "getRole",
[t('actionListRole')]: "listRoles",
[t('actionUpdateRole')]: "updateRole",
[t('actionListAllowedRoleResources')]: "listRoleResources",
[t('actionAddUserRole')]: "addUserRole"
[t("actionCreateRole")]: "createRole",
[t("actionDeleteRole")]: "deleteRole",
[t("actionGetRole")]: "getRole",
[t("actionListRole")]: "listRoles",
[t("actionUpdateRole")]: "updateRole",
[t("actionListAllowedRoleResources")]: "listRoleResources",
[t("actionAddUserRole")]: "addUserRole"
},
"Access Token": {
[t('actionGenerateAccessToken')]: "generateAccessToken",
[t('actionDeleteAccessToken')]: "deleteAcessToken",
[t('actionListAccessTokens')]: "listAccessTokens"
[t("actionGenerateAccessToken")]: "generateAccessToken",
[t("actionDeleteAccessToken")]: "deleteAcessToken",
[t("actionListAccessTokens")]: "listAccessTokens"
},
"Resource Rule": {
@@ -102,36 +105,52 @@ function getActionsCategories(root: boolean) {
}
};
if (env.flags.enableClients) {
actionsByCategory["Clients"] = {
"Create Client": "createClient",
"Delete Client": "deleteClient",
"Update Client": "updateClient",
"List Clients": "listClients",
"Get Client": "getClient"
};
}
if (root) {
actionsByCategory["Organization"] = {
[t('actionListOrgs')]: "listOrgs",
[t('actionCheckOrgId')]: "checkOrgId",
[t('actionCreateOrg')]: "createOrg",
[t('actionDeleteOrg')]: "deleteOrg",
[t('actionListApiKeys')]: "listApiKeys",
[t('actionListApiKeyActions')]: "listApiKeyActions",
[t('actionSetApiKeyActions')]: "setApiKeyActions",
[t('actionCreateApiKey')]: "createApiKey",
[t('actionDeleteApiKey')]: "deleteApiKey",
[t("actionListOrgs")]: "listOrgs",
[t("actionCheckOrgId")]: "checkOrgId",
[t("actionCreateOrg")]: "createOrg",
[t("actionDeleteOrg")]: "deleteOrg",
[t("actionListApiKeys")]: "listApiKeys",
[t("actionListApiKeyActions")]: "listApiKeyActions",
[t("actionSetApiKeyActions")]: "setApiKeyActions",
[t("actionCreateApiKey")]: "createApiKey",
[t("actionDeleteApiKey")]: "deleteApiKey",
...actionsByCategory["Organization"]
};
actionsByCategory["Identity Provider (IDP)"] = {
[t('actionCreateIdp')]: "createIdp",
[t('actionUpdateIdp')]: "updateIdp",
[t('actionDeleteIdp')]: "deleteIdp",
[t('actionListIdps')]: "listIdps",
[t('actionGetIdp')]: "getIdp",
[t('actionCreateIdpOrg')]: "createIdpOrg",
[t('actionDeleteIdpOrg')]: "deleteIdpOrg",
[t('actionListIdpOrgs')]: "listIdpOrgs",
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
[t("actionCreateIdp")]: "createIdp",
[t("actionUpdateIdp")]: "updateIdp",
[t("actionDeleteIdp")]: "deleteIdp",
[t("actionListIdps")]: "listIdps",
[t("actionGetIdp")]: "getIdp",
[t("actionCreateIdpOrg")]: "createIdpOrg",
[t("actionDeleteIdpOrg")]: "deleteIdpOrg",
[t("actionListIdpOrgs")]: "listIdpOrgs",
[t("actionUpdateIdpOrg")]: "updateIdpOrg"
};
actionsByCategory["User"] = {
[t('actionUpdateUser')]: "updateUser",
[t('actionGetUser')]: "getUser"
[t("actionUpdateUser")]: "updateUser",
[t("actionGetUser")]: "getUser"
};
if (build == "saas") {
actionsByCategory["SAAS"] = {
["Send Usage Notification Email"]: "sendUsageNotification",
}
}
}
return actionsByCategory;
@@ -189,7 +208,7 @@ export default function PermissionsSelectBox({
<CheckboxWithLabel
variant="outlinePrimarySquare"
id="toggle-all-permissions"
label={t('permissionsAllowAll')}
label={t("permissionsAllowAll")}
checked={allPermissionsChecked}
onCheckedChange={(checked) =>
toggleAllPermissions(checked as boolean)
@@ -208,7 +227,7 @@ export default function PermissionsSelectBox({
<CheckboxWithLabel
variant="outlinePrimarySquare"
id={`toggle-all-${category}`}
label={t('allowAll')}
label={t("allowAll")}
checked={allChecked}
onCheckedChange={(checked) =>
toggleAllInCategory(

View File

@@ -31,27 +31,23 @@ import {
} from "@app/components/ui/input-otp";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
import {
AuthWithPasswordResponse,
AuthWithWhitelistResponse
} from "@server/routers/resource";
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
import {
resourcePasswordProxy,
resourcePincodeProxy,
resourceWhitelistProxy,
resourceAccessProxy,
resourceAccessProxy
} from "@app/actions/server";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import Image from "next/image";
import BrandingLogo from "@app/components/BrandingLogo";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
const pinSchema = z.object({
pin: z
@@ -90,6 +86,7 @@ type ResourceAuthPortalProps = {
};
redirect: string;
idps?: LoginFormIDP[];
orgId?: string;
};
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
@@ -117,8 +114,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { supporterStatus } = useSupporterStatusContext();
function getDefaultSelectedMethod() {
@@ -216,7 +211,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
console.error(e);
setWhitelistError(
t("otpEmailErrorAuthenticate", {
defaultValue: "An unexpected error occurred. Please try again."
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
@@ -249,7 +245,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
console.error(e);
setPincodeError(
t("pincodeErrorAuthenticate", {
defaultValue: "An unexpected error occurred. Please try again."
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
@@ -282,7 +279,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
console.error(e);
setPasswordError(
t("passwordErrorAuthenticate", {
defaultValue: "An unexpected error occurred. Please try again."
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
@@ -310,34 +308,75 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
function getTitle() {
if (build !== "oss" && env.branding.resourceAuthPage?.titleText) {
return env.branding.resourceAuthPage.titleText;
}
return t("authenticationRequired");
}
function getSubtitle(resourceName: string) {
if (build !== "oss" && env.branding.resourceAuthPage?.subtitleText) {
return env.branding.resourceAuthPage.subtitleText
.split("{{resourceName}}")
.join(resourceName);
}
return numMethods > 1
? t("authenticationMethodChoose", { name: props.resource.name })
: t("authenticationRequest", { name: props.resource.name });
? t("authenticationMethodChoose", { name: resourceName })
: t("authenticationRequest", { name: resourceName });
}
return (
<div>
{!accessDenied ? (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
{build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy && (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://digpangolin.com/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{env.branding.appName || "Pangolin"}
</Link>
</span>
</div>
)
) : (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://digpangolin.com/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
)}
<Card>
<CardHeader>
{build !== "oss" &&
env.branding?.resourceAuthPage?.showLogo && (
<div className="flex flex-row items-center justify-center mb-3">
<BrandingLogo
height={
env.branding.logo?.authPage
?.height || 100
}
width={
env.branding.logo?.authPage
?.width || 100
}
/>
</div>
)}
<CardTitle>{getTitle()}</CardTitle>
<CardDescription>
{getSubtitle(props.resource.name)}
@@ -544,6 +583,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
<LoginForm
idps={props.idps}
redirect={props.redirect}
orgId={props.orgId}
onLogin={async () =>
await handleSSOAuth()
}

View File

@@ -12,6 +12,7 @@ import {
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import CertificateStatus from "@app/components/private/CertificateStatus";
import { toUnicode } from 'punycode';
type ResourceInfoBoxType = {};
@@ -21,15 +22,13 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
const t = useTranslations();
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
{/* 4 cols because of the certs */}
<InfoSections cols={resource.http && build != "oss" ? 4 : 3}>
{resource.http ? (
<>
<InfoSection>
@@ -118,6 +117,34 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
)} */}
</>
)}
{/* <InfoSection> */}
{/* <InfoSectionTitle>{t('visibility')}</InfoSectionTitle> */}
{/* <InfoSectionContent> */}
{/* <span> */}
{/* {resource.enabled ? t('enabled') : t('disabled')} */}
{/* </span> */}
{/* </InfoSectionContent> */}
{/* </InfoSection> */}
{/* Certificate Status Column */}
{resource.http && resource.domainId && resource.fullDomain && build != "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
defaultValue: "Certificate"
})}
</InfoSectionTitle>
<InfoSectionContent>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId}
fullDomain={resource.fullDomain}
autoFetch={true}
showLabel={false}
polling={true}
/>
</InfoSectionContent>
</InfoSection>
)}
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>

View File

@@ -108,7 +108,8 @@ export default function SignupForm({
emailParam
}: SignupFormProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [loading, setLoading] = useState(false);
@@ -129,7 +130,10 @@ export default function SignupForm({
});
const passwordStrength = calculatePasswordStrength(passwordValue);
const doPasswordsMatch = passwordValue.length > 0 && confirmPasswordValue.length > 0 && passwordValue === confirmPasswordValue;
const doPasswordsMatch =
passwordValue.length > 0 &&
confirmPasswordValue.length > 0 &&
passwordValue === confirmPasswordValue;
async function onSubmit(values: z.infer<typeof formSchema>) {
const { email, password } = values;
@@ -192,7 +196,10 @@ export default function SignupForm({
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={58} width={175} />
<BrandingLogo
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
/>
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
@@ -211,8 +218,8 @@ export default function SignupForm({
<FormItem>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
<Input
{...field}
disabled={!!emailParam}
/>
</FormControl>
@@ -227,7 +234,8 @@ export default function SignupForm({
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t("password")}</FormLabel>
{passwordStrength.strength === "strong" && (
{passwordStrength.strength ===
"strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
@@ -238,115 +246,193 @@ export default function SignupForm({
{...field}
onChange={(e) => {
field.onChange(e);
setPasswordValue(e.target.value);
setPasswordValue(
e.target.value
);
}}
className={cn(
passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500",
passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength === "weak" && passwordValue.length > 0 && "border-red-500 focus-visible:ring-red-500"
passwordStrength.strength ===
"strong" &&
"border-green-500 focus-visible:ring-green-500",
passwordStrength.strength ===
"medium" &&
"border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength ===
"weak" &&
passwordValue.length >
0 &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{passwordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">{t("passwordStrength")}</span>
<span className={cn(
"text-sm font-semibold",
passwordStrength.strength === "strong" && "text-green-600 dark:text-green-400",
passwordStrength.strength === "medium" && "text-yellow-600 dark:text-yellow-400",
passwordStrength.strength === "weak" && "text-red-600 dark:text-red-400"
)}>
{t(`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`)}
<span className="text-sm font-medium text-foreground">
{t("passwordStrength")}
</span>
<span
className={cn(
"text-sm font-semibold",
passwordStrength.strength ===
"strong" &&
"text-green-600 dark:text-green-400",
passwordStrength.strength ===
"medium" &&
"text-yellow-600 dark:text-yellow-400",
passwordStrength.strength ===
"weak" &&
"text-red-600 dark:text-red-400"
)}
>
{t(
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
)}
</span>
</div>
<Progress
value={passwordStrength.percentage}
<Progress
value={
passwordStrength.percentage
}
className="h-2"
/>
</div>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">{t("passwordRequirements")}</div>
<div className="text-sm font-medium text-foreground mb-2">
{t("passwordRequirements")}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength.requirements.length ? (
{passwordStrength
.requirements
.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.length ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementLengthText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.length
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLengthText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.uppercase ? (
{passwordStrength
.requirements
.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.uppercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementUppercaseText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.uppercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementUppercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.lowercase ? (
{passwordStrength
.requirements
.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.lowercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementLowercaseText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.lowercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLowercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.number ? (
{passwordStrength
.requirements
.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.number ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementNumberText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.number
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementNumberText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.special ? (
{passwordStrength
.requirements
.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.special ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementSpecialText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.special
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementSpecialText"
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{passwordValue.length === 0 && <FormMessage />}
{passwordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
@@ -356,7 +442,9 @@ export default function SignupForm({
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t('confirmPassword')}</FormLabel>
<FormLabel>
{t("confirmPassword")}
</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
@@ -368,23 +456,32 @@ export default function SignupForm({
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(e.target.value);
setConfirmPasswordValue(
e.target.value
);
}}
className={cn(
doPasswordsMatch && "border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500"
doPasswordsMatch &&
"border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length >
0 &&
!doPasswordsMatch &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{confirmPasswordValue.length > 0 && !doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && <FormMessage />}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
@@ -407,28 +504,32 @@ export default function SignupForm({
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t("signUpTerms.IAgreeToThe")}
<a
href="https://digpangolin.com/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
<div>
{t(
"signUpTerms.termsOfService"
)}
</a>
{t("signUpTerms.and")}
<a
href="https://digpangolin.com/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://digpangolin.com/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://digpangolin.com/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
@@ -451,4 +552,4 @@ export default function SignupForm({
</CardContent>
</Card>
);
}
}

View File

@@ -29,6 +29,7 @@ import { useTranslations } from "next-intl";
import { parseDataSize } from "@app/lib/dataSize";
import { Badge } from "@app/components/ui/badge";
import { InfoPopup } from "@app/components/ui/info-popup";
import { build } from "@server/build";
export type SiteRow = {
id: number;
@@ -42,6 +43,8 @@ export type SiteRow = {
newtUpdateAvailable?: boolean;
online: boolean;
address?: string;
exitNodeName?: string;
exitNodeEndpoint?: string;
};
type SitesTableProps = {
@@ -280,6 +283,34 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}
}
},
{
accessorKey: "exitNode",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("exitNode")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-2">
<span>{originalRow.exitNodeName}</span>
{build == "saas" && originalRow.exitNodeName &&
['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && (
<Badge variant="secondary">Cloud</Badge>
)}
</div>
);
},
},
...(env.flags.enableClients ? [{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {

View File

@@ -2,7 +2,6 @@
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -26,6 +25,7 @@ type ValidateOidcTokenParams = {
expectedState: string | undefined;
stateCookie: string | undefined;
idp: { name: string };
loginPageId?: number;
};
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
@@ -44,7 +44,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
async function validate() {
setLoading(true);
console.log(t('idpOidcTokenValidating'), {
console.log(t("idpOidcTokenValidating"), {
code: props.code,
expectedState: props.expectedState,
stateCookie: props.stateCookie
@@ -59,7 +59,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
props.idpId,
props.code || "",
props.expectedState || "",
props.stateCookie || ""
props.stateCookie || "",
props.loginPageId
);
if (response.error) {
@@ -78,7 +79,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
const redirectUrl = data.redirectUrl;
if (!redirectUrl) {
router.push("/");
router.push(env.app.dashboardUrl);
}
setLoading(false);
@@ -89,8 +90,13 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
} else {
router.push(data.redirectUrl);
}
} catch (e) {
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
} catch (e: any) {
console.error(e);
setError(
t("idpErrorOidcTokenValidating", {
defaultValue: "An unexpected error occurred. Please try again."
})
);
} finally {
setLoading(false);
}
@@ -103,20 +109,24 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('idpConnectingTo', {name: props.idp.name})}</CardTitle>
<CardDescription>{t('idpConnectingToDescription')}</CardDescription>
<CardTitle>
{t("idpConnectingTo", { name: props.idp.name })}
</CardTitle>
<CardDescription>
{t("idpConnectingToDescription")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{loading && (
<div className="flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>{t('idpConnectingToProcess')}</span>
<span>{t("idpConnectingToProcess")}</span>
</div>
)}
{!loading && !error && (
<div className="flex items-center space-x-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
<span>{t('idpConnectingToFinished')}</span>
<span>{t("idpConnectingToFinished")}</span>
</div>
)}
{error && (
@@ -124,7 +134,9 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>
{t('idpErrorConnectingTo', {name: props.idp.name})}
{t("idpErrorConnectingTo", {
name: props.idp.name
})}
</span>
<span className="text-xs">{error}</span>
</AlertDescription>

View File

@@ -0,0 +1,538 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import {
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import { GetLoginPageResponse } from "@server/routers/private/loginPage";
import { ListDomainsResponse } from "@server/routers/domain";
import { DomainRow } from "@app/components/DomainsTable";
import { toUnicode } from "punycode";
import { Globe, Trash2 } from "lucide-react";
import CertificateStatus from "@app/components/private/CertificateStatus";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { usePrivateSubscriptionStatusContext } from "@app/hooks/privateUseSubscriptionStatusContext";
import { TierId } from "@server/lib/private/billing/tiers";
import { build } from "@server/build";
// Auth page form schema
const AuthPageFormSchema = z.object({
authPageDomainId: z.string().optional(),
authPageSubdomain: z.string().optional()
});
type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
interface AuthPageSettingsProps {
onSaveSuccess?: () => void;
onSaveError?: (error: any) => void;
}
export interface AuthPageSettingsRef {
saveAuthSettings: () => Promise<void>;
hasUnsavedChanges: () => boolean;
}
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(({
onSaveSuccess,
onSaveError
}, ref) => {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const subscription = usePrivateSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
// Auth page domain state
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
null
);
const [loginPageExists, setLoginPageExists] = useState(false);
const [editDomainOpen, setEditDomainOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
authPageDomainId: loginPage?.domainId || "",
authPageSubdomain: loginPage?.subdomain || ""
},
mode: "onChange"
});
// Expose save function to parent component
useImperativeHandle(ref, () => ({
saveAuthSettings: async () => {
await form.handleSubmit(onSubmit)();
},
hasUnsavedChanges: () => hasUnsavedChanges
}), [form, hasUnsavedChanges]);
// Fetch login page and domains data
useEffect(() => {
if (build !== "saas") {
return;
}
const fetchLoginPage = async () => {
try {
const res = await api.get<AxiosResponse<GetLoginPageResponse>>(
`/org/${org?.org.orgId}/login-page`
);
if (res.status === 200) {
setLoginPage(res.data.data);
setLoginPageExists(true);
// Update form with login page data
form.setValue(
"authPageDomainId",
res.data.data.domainId || ""
);
form.setValue(
"authPageSubdomain",
res.data.data.subdomain || ""
);
}
} catch (err) {
// Login page doesn't exist yet, that's okay
setLoginPage(null);
setLoginPageExists(false);
} finally {
setLoadingLoginPage(false);
}
};
const fetchDomains = async () => {
try {
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
`/org/${org?.org.orgId}/domains/`
);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
}
} catch (err) {
console.error("Failed to fetch domains:", err);
}
};
if (org?.org.orgId) {
fetchLoginPage();
fetchDomains();
}
}, []);
// Handle domain selection from modal
function handleDomainSelection(domain: {
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) {
form.setValue("authPageDomainId", domain.domainId);
form.setValue("authPageSubdomain", domain.subdomain || "");
setEditDomainOpen(false);
// Update loginPage state to show the selected domain immediately
const sanitizedSubdomain = domain.subdomain
? finalizeSubdomainSanitize(domain.subdomain)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
// Only update loginPage state if a login page already exists
if (loginPageExists && loginPage) {
setLoginPage({
...loginPage,
domainId: domain.domainId,
subdomain: sanitizedSubdomain,
fullDomain: sanitizedFullDomain
});
}
setHasUnsavedChanges(true);
}
// Clear auth page domain
function clearAuthPageDomain() {
form.setValue("authPageDomainId", "");
form.setValue("authPageSubdomain", "");
setLoginPage(null);
setHasUnsavedChanges(true);
}
async function onSubmit(data: AuthPageFormValues) {
setLoadingSave(true);
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (build !== "saas" || (build === "saas" && subscribed)) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
if (loginPageExists) {
// Login page exists on server - need to update it
// First, we need to get the loginPageId from the server since loginPage might be null locally
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
// Update existing auth page domain
const updateRes = await api.post(
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (updateRes.status === 201) {
setLoginPage(updateRes.data.data);
setLoginPageExists(true);
}
} else {
// No login page exists on server - create new one
const createRes = await api.put(
`/org/${org?.org.orgId}/login-page`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (createRes.status === 201) {
setLoginPage(createRes.data.data);
setLoginPageExists(true);
}
}
}
} else if (loginPageExists) {
// Delete existing auth page domain if no domain selected
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
await api.delete(
`/org/${org?.org.orgId}/login-page/${loginPageId}`
);
setLoginPage(null);
setLoginPageExists(false);
}
setHasUnsavedChanges(false);
router.refresh();
onSaveSuccess?.();
} catch (e) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(e, t("authPageErrorUpdateMessage"))
});
onSaveError?.(e);
} finally {
setLoadingSave(false);
}
}
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authPage")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{build === "saas" && !subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("orgAuthPageDisabled")}{" "}
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
<SettingsSectionForm>
{loadingLoginPage ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
{t("loading")}
</div>
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="auth-page-settings-form"
>
<div className="space-y-3">
<Label>{t("authPageDomain")}</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{loginPage &&
!loginPage.domainId ? (
<InfoPopup
info={t(
"domainNotFoundDescription"
)}
text={t("domainNotFound")}
/>
) : loginPage?.fullDomain ? (
<a
href={`${window.location.protocol}//${loginPage.fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{`${window.location.protocol}//${loginPage.fullDomain}`}
</a>
) : form.watch(
"authPageDomainId"
) ? (
// Show selected domain from form state when no loginPage exists yet
(() => {
const selectedDomainId =
form.watch(
"authPageDomainId"
);
const selectedSubdomain =
form.watch(
"authPageSubdomain"
);
const domain =
baseDomains.find(
(d) =>
d.domainId ===
selectedDomainId
);
if (domain) {
const sanitizedSubdomain =
selectedSubdomain
? finalizeSubdomainSanitize(
selectedSubdomain
)
: "";
const fullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
return fullDomain;
}
return t("noDomainSet");
})()
) : (
t("noDomainSet")
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(true)
}
>
{form.watch("authPageDomainId")
? t("changeDomain")
: t("selectDomain")}
</Button>
{form.watch("authPageDomainId") && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={
clearAuthPageDomain
}
>
<Trash2 size="14" />
</Button>
)}
</div>
</div>
{/* Certificate Status */}
{(build !== "saas" ||
(build === "saas" && subscribed)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={org?.org.orgId || ""}
domainId={loginPage.domainId}
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
)}
{!form.watch("authPageDomainId") && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
)}
</div>
)}
</div>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Domain Picker Modal */}
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{loginPage
? t("editAuthPageDomain")
: t("setAuthPageDomain")}
</CredenzaTitle>
<CredenzaDescription>
{t("selectDomainForOrgAuthPage")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
hideFreeDomain={true}
orgId={org?.org.orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain}
>
{t("selectDomain")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
});
AuthPageSettings.displayName = 'AuthPageSettings';
export default AuthPageSettings;

View File

@@ -0,0 +1,185 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage
} from "@app/components/ui/form";
import { SwitchInput } from "@app/components/SwitchInput";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
import { Control, FieldValues, Path } from "react-hook-form";
type Role = {
roleId: number;
name: string;
};
type AutoProvisionConfigWidgetProps<T extends FieldValues> = {
control: Control<T>;
autoProvision: boolean;
onAutoProvisionChange: (checked: boolean) => void;
roleMappingMode: "role" | "expression";
onRoleMappingModeChange: (mode: "role" | "expression") => void;
roles: Role[];
roleIdFieldName: Path<T>;
roleMappingFieldName: Path<T>;
};
export default function AutoProvisionConfigWidget<T extends FieldValues>({
control,
autoProvision,
onAutoProvisionChange,
roleMappingMode,
onRoleMappingModeChange,
roles,
roleIdFieldName,
roleMappingFieldName
}: AutoProvisionConfigWidgetProps<T>) {
const t = useTranslations();
return (
<div className="space-y-4">
<div className="mb-4">
<SwitchInput
id="auto-provision-toggle"
label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange}
/>
<span className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")}
</span>
</div>
{autoProvision && (
<div className="space-y-4">
<div>
<FormLabel className="mb-2">
{t("roleMapping")}
</FormLabel>
<FormDescription className="mb-4">
{t("roleMappingDescription")}
</FormDescription>
<RadioGroup
value={roleMappingMode}
onValueChange={onRoleMappingModeChange}
className="flex space-x-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="role" id="role-mode" />
<label
htmlFor="role-mode"
className="text-sm font-medium"
>
{t("selectRole")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="expression"
id="expression-mode"
/>
<label
htmlFor="expression-mode"
className="text-sm font-medium"
>
{t("roleMappingExpression")}
</label>
</div>
</RadioGroup>
</div>
{roleMappingMode === "role" ? (
<FormField
control={control}
name={roleIdFieldName}
render={({ field }) => (
<FormItem>
<Select
onValueChange={(value) =>
field.onChange(Number(value))
}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectRolePlaceholder"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("selectRoleDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={control}
name={roleMappingFieldName}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
defaultValue={field.value || ""}
value={field.value || ""}
placeholder={t(
"roleMappingExpressionPlaceholder"
)}
/>
</FormControl>
<FormDescription>
{t("roleMappingExpressionDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,156 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { Button } from "@/components/ui/button";
import { RotateCw } from "lucide-react";
import { useCertificate } from "@app/hooks/privateUseCertificate";
import { useTranslations } from "next-intl";
type CertificateStatusProps = {
orgId: string;
domainId: string;
fullDomain: string;
autoFetch?: boolean;
showLabel?: boolean;
className?: string;
onRefresh?: () => void;
polling?: boolean;
pollingInterval?: number;
};
export default function CertificateStatus({
orgId,
domainId,
fullDomain,
autoFetch = true,
showLabel = true,
className = "",
onRefresh,
polling = false,
pollingInterval = 5000
}: CertificateStatusProps) {
const t = useTranslations();
const { cert, certLoading, certError, refreshing, refreshCert } = useCertificate({
orgId,
domainId,
fullDomain,
autoFetch,
polling,
pollingInterval
});
const handleRefresh = async () => {
await refreshCert();
onRefresh?.();
};
const getStatusColor = (status: string) => {
switch (status) {
case "valid":
return "text-green-500";
case "pending":
case "requested":
return "text-yellow-500";
case "expired":
case "failed":
return "text-red-500";
default:
return "text-muted-foreground";
}
};
const shouldShowRefreshButton = (status: string, updatedAt: string) => {
return (
status === "failed" ||
status === "expired" ||
(status === "requested" &&
updatedAt && new Date(updatedAt).getTime() < Date.now() - 5 * 60 * 1000)
);
};
if (certLoading) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-muted-foreground">
{t("loading")}
</span>
</div>
);
}
if (certError) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-red-500">
{certError}
</span>
</div>
);
}
if (!cert) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-muted-foreground">
{t("none", { defaultValue: "None" })}
</span>
</div>
);
}
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
)}
<span className={`text-sm ${getStatusColor(cert.status)}`}>
<span className="inline-flex items-center">
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
{shouldShowRefreshButton(cert.status, cert.updatedAt) && (
<Button
size="icon"
variant="ghost"
className="ml-2 p-0 h-auto align-middle"
onClick={handleRefresh}
disabled={refreshing}
title={t("restartCertificate", { defaultValue: "Restart Certificate" })}
>
<RotateCw
className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
)}
</span>
</span>
</div>
);
}

View File

@@ -0,0 +1,135 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { generateOidcUrlProxy, type GenerateOidcUrlResponse } from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
};
type IdpLoginButtonsProps = {
idps: LoginFormIDP[];
redirect?: string;
orgId?: string;
};
export default function IdpLoginButtons({
idps,
redirect,
orgId
}: IdpLoginButtonsProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const t = useTranslations();
async function loginWithIdp(idpId: number) {
setLoading(true);
setError(null);
let redirectToUrl: string | undefined;
try {
const response = await generateOidcUrlProxy(
idpId,
redirect || "/",
orgId
);
if (response.error) {
setError(response.message);
setLoading(false);
return;
}
const data = response.data;
console.log("Redirecting to:", data?.redirectUrl);
if (data?.redirectUrl) {
redirectToUrl = data.redirectUrl;
}
} catch (e: any) {
console.error(e);
setError(
t("loginError", {
defaultValue: "An unexpected error occurred. Please try again."
})
);
setLoading(false);
}
if (redirectToUrl) {
redirectTo(redirectToUrl);
}
}
if (!idps || idps.length === 0) {
return null;
}
return (
<div className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
{idps.map((idp) => {
const effectiveType = idp.variant || idp.name.toLowerCase();
return (
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
disabled={loading}
>
{effectiveType === "google" && (
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
)}
{effectiveType === "azure" && (
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
)}
<span>{idp.name}</span>
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd?: () => void;
}
export function IdpDataTable<TData, TValue>({
columns,
data,
onAdd
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="idp-table"
title={t("idp")}
searchPlaceholder={t("idpSearch")}
searchColumn="name"
addButtonText={t("idpAdd")}
onAdd={onAdd}
/>
);
}

View File

@@ -0,0 +1,219 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { IdpDataTable } from "@app/components/private/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
export type IdpRow = {
idpId: number;
name: string;
type: string;
variant?: string;
};
type Props = {
idps: IdpRow[];
orgId: string;
};
export default function IdpTable({ idps, orgId }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const deleteIdp = async (idpId: number) => {
try {
await api.delete(`/org/${orgId}/idp/${idpId}`);
toast({
title: t("success"),
description: t("idpDeletedDescription")
});
setIsDeleteModalOpen(false);
router.refresh();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const columns: ColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
const variant = row.original.variant;
return (
<IdpTypeBadge type={type} variant={variant} />
);
}
},
{
id: "actions",
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${orgId}/settings/idp/${siteRow.idpId}/general`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/${orgId}/settings/idp/${siteRow.idpId}/general`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedIdp && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedIdp(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("idpQuestionRemove", {
name: selectedIdp.name
})}
</p>
<p>
<b>{t("idpMessageRemove")}</b>
</p>
<p>{t("idpMessageConfirm")}</p>
</div>
}
buttonText={t("idpConfirmDelete")}
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name}
title={t("idpDelete")}
/>
)}
<IdpDataTable
columns={columns}
data={idps}
onAdd={() => router.push(`/${orgId}/settings/idp/create`)}
/>
</>
);
}

View File

@@ -0,0 +1,101 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { InfoPopup } from "@app/components/ui/info-popup";
import { useTranslations } from "next-intl";
type Region = {
value: string;
label: string;
flag: string;
};
const regions: Region[] = [
{
value: "us",
label: "North America",
flag: ""
},
{
value: "eu",
label: "Europe",
flag: ""
}
];
export default function RegionSelector() {
const [selectedRegion, setSelectedRegion] = useState<string>("us");
const t = useTranslations();
const handleRegionChange = (value: string) => {
setSelectedRegion(value);
const region = regions.find((r) => r.value === value);
if (region) {
console.log(`Selected region: ${region.label}`);
}
};
return (
<div className="flex flex-col items-center space-y-2">
<label className="flex items-center gap-1 text-sm font-medium text-muted-foreground">
{t('regionSelectorTitle')}
<InfoPopup info={t('regionSelectorInfo')} />
</label>
<Select value={selectedRegion} onValueChange={handleRegionChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={t('regionSelectorPlaceholder')} />
</SelectTrigger>
<SelectContent>
{regions.map((region) => (
<SelectItem
key={region.value}
value={region.value}
disabled={region.value === "eu"}
>
<div className="flex items-center space-x-2">
<span className="text-lg">{region.flag}</span>
<div className="flex flex-col">
<span
className={
region.value === "eu"
? "text-muted-foreground"
: ""
}
>
{region.label}
</span>
{region.value === "eu" && (
<span className="text-xs text-muted-foreground">
{t('regionSelectorComingSoon')}
</span>
)}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,57 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePathname } from "next/navigation";
import Image from "next/image";
type SplashImageProps = {
children: React.ReactNode;
};
export default function SplashImage({ children }: SplashImageProps) {
const pathname = usePathname();
const { env } = useEnvContext();
function showBackgroundImage() {
if (!env.branding.background_image_path) {
return false;
}
const pathsPrefixes = ["/auth/login", "/auth/signup", "/auth/resource"];
for (const prefix of pathsPrefixes) {
if (pathname.startsWith(prefix)) {
return true;
}
}
return false;
}
return (
<>
{showBackgroundImage() && (
<Image
src={env.branding.background_image_path!}
alt="Background"
layout="fill"
objectFit="cover"
quality={100}
className="-z-10"
/>
)}
{children}
</>
);
}

View File

@@ -0,0 +1,84 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { TransferSessionResponse } from "@server/routers/auth/privateTransferSession";
type ValidateSessionTransferTokenParams = {
token: string;
};
export default function ValidateSessionTransferToken(
props: ValidateSessionTransferTokenParams
) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const t = useTranslations();
useEffect(() => {
async function validate() {
setLoading(true);
let doRedirect = false;
try {
const res = await api.post<
AxiosResponse<TransferSessionResponse>
>(`/auth/transfer-session-token`, {
token: props.token
});
if (res && res.status === 200) {
doRedirect = true;
}
} catch (e) {
console.error(e);
setError(formatAxiosError(e, "Failed to validate token"));
} finally {
setLoading(false);
}
if (doRedirect) {
redirect(env.app.dashboardUrl);
}
}
validate();
}, []);
return (
<div className="flex items-center justify-center min-h-screen">
{error && (
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span className="text-xs">{error}</span>
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -14,7 +14,9 @@ const alertVariants = cva(
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500"
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500",
warning:
"border-yellow-500/50 border bg-yellow-500/10 text-yellow-500 dark:border-yellow-400 [&>svg]:text-yellow-500"
}
},
defaultVariants: {

View File

@@ -9,10 +9,13 @@ import {
ToastTitle,
ToastViewport
} from "@/components/ui/toast";
import { useEnvContext } from "@app/hooks/useEnvContext";
export function Toaster() {
const { toasts } = useToast();
const { env } = useEnvContext();
return (
<ToastProvider>
{toasts.map(function ({