mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-10 20:02:26 +00:00
505 lines
22 KiB
TypeScript
505 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { Button } from "@app/components/ui/button";
|
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
import { toast } from "@app/hooks/useToast";
|
|
import { useState, useEffect, useActionState } from "react";
|
|
import { Form } 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/loginPage/types";
|
|
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/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 { build } from "@server/build";
|
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
|
|
// Auth page form schema
|
|
const AuthPageFormSchema = z.object({
|
|
authPageDomainId: z.string().optional(),
|
|
authPageSubdomain: z.string().optional()
|
|
});
|
|
|
|
interface AuthPageSettingsProps {
|
|
onSaveSuccess?: () => void;
|
|
onSaveError?: (error: any) => void;
|
|
loginPage: GetLoginPageResponse | null;
|
|
}
|
|
|
|
export interface AuthPageSettingsRef {
|
|
saveAuthSettings: () => Promise<void>;
|
|
hasUnsavedChanges: () => boolean;
|
|
}
|
|
|
|
function AuthPageSettings({
|
|
onSaveSuccess,
|
|
onSaveError,
|
|
loginPage: defaultLoginPage
|
|
}: AuthPageSettingsProps) {
|
|
const { org } = useOrgContext();
|
|
const api = createApiClient(useEnvContext());
|
|
const router = useRouter();
|
|
const t = useTranslations();
|
|
const { env } = useEnvContext();
|
|
|
|
const { isPaidUser } = usePaidStatus();
|
|
|
|
// Auth page domain state
|
|
const [loginPage, setLoginPage] = useState(defaultLoginPage);
|
|
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
|
const [loginPageExists, setLoginPageExists] = useState(
|
|
Boolean(defaultLoginPage)
|
|
);
|
|
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 [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
|
|
const form = useForm({
|
|
resolver: zodResolver(AuthPageFormSchema),
|
|
defaultValues: {
|
|
authPageDomainId: loginPage?.domainId || "",
|
|
authPageSubdomain: loginPage?.subdomain || ""
|
|
},
|
|
mode: "onChange"
|
|
});
|
|
|
|
// Fetch login page and domains data
|
|
useEffect(() => {
|
|
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) {
|
|
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() {
|
|
const isValid = await form.trigger();
|
|
if (!isValid) return;
|
|
|
|
const data = form.getValues();
|
|
|
|
try {
|
|
// Handle auth page domain
|
|
if (data.authPageDomainId) {
|
|
if (isPaidUser(tierMatrix.loginPageDomain)) {
|
|
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?.();
|
|
toast({
|
|
variant: "default",
|
|
title: t("success"),
|
|
description: t("authPageDomainUpdated")
|
|
});
|
|
} catch (e) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: t("authPageErrorUpdate"),
|
|
description: formatAxiosError(
|
|
e,
|
|
t("authPageErrorUpdateMessage")
|
|
)
|
|
});
|
|
onSaveError?.(e);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<SettingsSection>
|
|
<SettingsSectionHeader>
|
|
<SettingsSectionTitle>
|
|
{t("customDomain")}
|
|
</SettingsSectionTitle>
|
|
<SettingsSectionDescription>
|
|
{t("authPageDescription")}
|
|
</SettingsSectionDescription>
|
|
</SettingsSectionHeader>
|
|
<SettingsSectionBody>
|
|
<SettingsSectionForm>
|
|
<PaidFeaturesAlert />
|
|
|
|
<Form {...form}>
|
|
<form
|
|
action={formAction}
|
|
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)
|
|
}
|
|
disabled={
|
|
!isPaidUser(
|
|
tierMatrix.loginPageDomain
|
|
)
|
|
}
|
|
>
|
|
{form.watch("authPageDomainId")
|
|
? t("changeDomain")
|
|
: t("selectDomain")}
|
|
</Button>
|
|
{form.watch("authPageDomainId") && (
|
|
<Button
|
|
variant="destructive"
|
|
type="button"
|
|
size="sm"
|
|
onClick={
|
|
clearAuthPageDomain
|
|
}
|
|
disabled={
|
|
!isPaidUser(
|
|
tierMatrix.loginPageDomain
|
|
)
|
|
}
|
|
>
|
|
<Trash2 size="14" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!form.watch("authPageDomainId") && (
|
|
<div className="text-sm text-muted-foreground">
|
|
{t(
|
|
"addDomainToEnableCustomAuthPages"
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{env.flags.usePangolinDns &&
|
|
(build === "enterprise" ||
|
|
!isPaidUser(
|
|
tierMatrix.loginPageDomain
|
|
)) &&
|
|
loginPage?.domainId &&
|
|
loginPage?.fullDomain &&
|
|
!hasUnsavedChanges && (
|
|
<CertificateStatus
|
|
orgId={org?.org.orgId || ""}
|
|
domainId={loginPage.domainId}
|
|
fullDomain={
|
|
loginPage.fullDomain
|
|
}
|
|
autoFetch={true}
|
|
showLabel={true}
|
|
polling={true}
|
|
/>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</SettingsSectionForm>
|
|
</SettingsSectionBody>
|
|
|
|
<div className="flex justify-end mt-6">
|
|
<Button
|
|
type="submit"
|
|
form="auth-page-settings-form"
|
|
loading={isSubmitting}
|
|
disabled={
|
|
isSubmitting ||
|
|
!hasUnsavedChanges ||
|
|
!isPaidUser(tierMatrix.loginPageDomain)
|
|
}
|
|
>
|
|
{t("saveAuthPageDomain")}
|
|
</Button>
|
|
</div>
|
|
</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 =
|
|
res === null
|
|
? null
|
|
: {
|
|
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 ||
|
|
!isPaidUser(tierMatrix.loginPageDomain)
|
|
}
|
|
>
|
|
{t("selectDomain")}
|
|
</Button>
|
|
</CredenzaFooter>
|
|
</CredenzaContent>
|
|
</Credenza>
|
|
</>
|
|
);
|
|
}
|
|
|
|
AuthPageSettings.displayName = "AuthPageSettings";
|
|
|
|
export default AuthPageSettings;
|