mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-14 08:56:39 +00:00
Chungus
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
580
src/components/HealthCheckDialog.tsx
Normal file
580
src/components/HealthCheckDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
538
src/components/private/AuthPageSettings.tsx
Normal file
538
src/components/private/AuthPageSettings.tsx
Normal 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;
|
||||
185
src/components/private/AutoProvisionConfigWidget.tsx
Normal file
185
src/components/private/AutoProvisionConfigWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/components/private/CertificateStatus.tsx
Normal file
156
src/components/private/CertificateStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/private/IdpLoginButtons.tsx
Normal file
135
src/components/private/IdpLoginButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/private/OrgIdpDataTable.tsx
Normal file
45
src/components/private/OrgIdpDataTable.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
219
src/components/private/OrgIdpTable.tsx
Normal file
219
src/components/private/OrgIdpTable.tsx
Normal 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`)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
src/components/private/RegionSelector.tsx
Normal file
101
src/components/private/RegionSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/private/SplashImage.tsx
Normal file
57
src/components/private/SplashImage.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/private/ValidateSessionTransferToken.tsx
Normal file
84
src/components/private/ValidateSessionTransferToken.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user