mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-13 16:36:41 +00:00
🚧 WIP
This commit is contained in:
319
src/components/AuthPageBrandingForm.tsx
Normal file
319
src/components/AuthPageBrandingForm.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "./Settings";
|
||||
import { useTranslations } from "next-intl";
|
||||
import AuthPageSettings, {
|
||||
AuthPageSettingsRef
|
||||
} from "./private/AuthPageSettings";
|
||||
import type {
|
||||
GetLoginPageBrandingResponse,
|
||||
GetLoginPageResponse
|
||||
} from "@server/routers/loginPage/types";
|
||||
import { Input } from "./ui/input";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export type AuthPageCustomizationProps = {
|
||||
orgId: string;
|
||||
branding: GetLoginPageBrandingResponse | null;
|
||||
};
|
||||
|
||||
const AuthPageFormSchema = z.object({
|
||||
logoUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.refine(
|
||||
async (url) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return (
|
||||
response.status === 200 &&
|
||||
(response.headers.get("content-type") ?? "").startsWith(
|
||||
"image/"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Invalid logo URL, must be a valid image URL"
|
||||
}
|
||||
),
|
||||
logoWidth: z.number().min(1),
|
||||
logoHeight: z.number().min(1),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
resourceTitle: z.string(),
|
||||
resourceSubtitle: z.string().optional()
|
||||
});
|
||||
|
||||
export default function AuthPageBrandingForm({
|
||||
orgId,
|
||||
branding
|
||||
}: AuthPageCustomizationProps) {
|
||||
const [, formAction, isSubmitting] = React.useActionState(onSubmit, null);
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AuthPageFormSchema),
|
||||
defaultValues: {
|
||||
logoUrl: branding?.logoUrl ?? "",
|
||||
logoWidth: branding?.logoWidth ?? 500,
|
||||
logoHeight: branding?.logoHeight ?? 500,
|
||||
title: branding?.title ?? `Log in to {{orgName}}`,
|
||||
subtitle: branding?.subtitle ?? `Log in to {{orgName}}`,
|
||||
resourceTitle:
|
||||
branding?.resourceTitle ??
|
||||
`Authenticate to access {{resourceName}}`,
|
||||
resourceSubtitle:
|
||||
branding?.resourceSubtitle ??
|
||||
`Choose your preferred authentication method for {{resourceName}}`
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
console.log({
|
||||
dirty: form.formState.isDirty
|
||||
});
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
// ...
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authPageBranding")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("authPageBrandingDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="auth-page-branding-form"
|
||||
className="flex flex-col gap-8 items-stretch"
|
||||
>
|
||||
<div className="grid md:grid-cols-5 gap-3 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingLogoURL")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="md:col-span-2 flex gap-3 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow">
|
||||
<FormLabel>
|
||||
{t("brandingLogoWidth")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="self-center relative top-2.5">
|
||||
<XIcon className="text-muted-foreground size-4" />
|
||||
</span>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"brandingLogoHeight"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingOrgTitle")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"brandingOrgDescription",
|
||||
{
|
||||
orgName:
|
||||
"{{orgName}}"
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subtitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingOrgSubtitle")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"brandingOrgDescription",
|
||||
{
|
||||
orgName:
|
||||
"{{orgName}}"
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resourceTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingResourceTitle")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"brandingResourceDescription",
|
||||
{
|
||||
resourceName:
|
||||
"{{resourceName}}"
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resourceSubtitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"brandingResourceSubtitle"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"brandingResourceDescription",
|
||||
{
|
||||
resourceName:
|
||||
"{{resourceName}}"
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
{/* {branding && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="auth-page-branding-form"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
)} */}
|
||||
<Button
|
||||
type="submit"
|
||||
form="auth-page-branding-form"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("saveAuthPageBranding")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
|
||||
export type AuthPageCustomizationProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
const AuthPageFormSchema = z.object({
|
||||
logoUrl: z.string().url(),
|
||||
logoWidth: z.number().min(1),
|
||||
logoHeight: z.number().min(1),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
resourceTitle: z.string(),
|
||||
resourceSubtitle: z.string().optional()
|
||||
});
|
||||
|
||||
export default function AuthPageCustomizationForm({
|
||||
orgId
|
||||
}: AuthPageCustomizationProps) {
|
||||
const [, formAction, isSubmitting] = React.useActionState(onSubmit, null);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AuthPageFormSchema),
|
||||
defaultValues: {
|
||||
title: `Log in to {{orgName}}`,
|
||||
resourceTitle: `Authenticate to access {{resourceName}}`
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
// ...
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<button>Hello</button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,15 @@
|
||||
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 {
|
||||
useState,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
RefObject,
|
||||
Ref,
|
||||
useActionState
|
||||
} from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -52,7 +60,6 @@ import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// Auth page form schema
|
||||
@@ -66,6 +73,7 @@ type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
|
||||
interface AuthPageSettingsProps {
|
||||
onSaveSuccess?: () => void;
|
||||
onSaveError?: (error: any) => void;
|
||||
loginPage: GetLoginPageResponse | null;
|
||||
}
|
||||
|
||||
export interface AuthPageSettingsRef {
|
||||
@@ -73,476 +81,434 @@ export interface AuthPageSettingsRef {
|
||||
hasUnsavedChanges: () => boolean;
|
||||
}
|
||||
|
||||
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
|
||||
({ onSaveSuccess, onSaveError }, ref) => {
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
function AuthPageSettings({
|
||||
onSaveSuccess,
|
||||
onSaveError,
|
||||
loginPage: defaultLoginPage
|
||||
}: AuthPageSettingsProps) {
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
|
||||
// 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);
|
||||
// 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"
|
||||
});
|
||||
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(() => {
|
||||
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);
|
||||
// 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(() => {
|
||||
const fetchDomains = async () => {
|
||||
try {
|
||||
// Handle auth page domain
|
||||
if (data.authPageDomainId) {
|
||||
if (
|
||||
build === "enterprise" ||
|
||||
(build === "saas" && subscription?.subscribed)
|
||||
) {
|
||||
const sanitizedSubdomain = data.authPageSubdomain
|
||||
? finalizeSubdomainSanitize(data.authPageSubdomain)
|
||||
: "";
|
||||
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 (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 (org?.org.orgId) {
|
||||
fetchDomains();
|
||||
}
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
// 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 existing auth page domain
|
||||
const updateRes = await api.post(
|
||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
|
||||
{
|
||||
domainId: data.authPageDomainId,
|
||||
subdomain: sanitizedSubdomain || null
|
||||
}
|
||||
);
|
||||
// Update loginPage state to show the selected domain immediately
|
||||
const sanitizedSubdomain = domain.subdomain
|
||||
? finalizeSubdomainSanitize(domain.subdomain)
|
||||
: "";
|
||||
|
||||
if (updateRes.status === 201) {
|
||||
setLoginPage(updateRes.data.data);
|
||||
setLoginPageExists(true);
|
||||
}
|
||||
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 (
|
||||
build === "enterprise" ||
|
||||
(build === "saas" && subscription?.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 {
|
||||
// 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
|
||||
}
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (createRes.status === 201) {
|
||||
setLoginPage(createRes.data.data);
|
||||
setLoginPageExists(true);
|
||||
// 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;
|
||||
}
|
||||
} 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);
|
||||
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;
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
router.refresh();
|
||||
onSaveSuccess?.();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("authPageErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("authPageErrorUpdateMessage")
|
||||
)
|
||||
});
|
||||
onSaveError?.(e);
|
||||
} finally {
|
||||
setLoadingSave(false);
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authPage")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("authPageDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{build === "saas" && !subscription?.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>
|
||||
|
||||
{!form.watch(
|
||||
"authPageDomainId"
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"addDomainToEnableCustomAuthPages"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{env.flags
|
||||
.usePangolinDns &&
|
||||
(build === "enterprise" ||
|
||||
(build === "saas" &&
|
||||
subscription?.subscribed)) &&
|
||||
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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("authPage")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("authPageDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{build === "saas" && !subscription?.subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("orgAuthPageDisabled")}{" "}
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SettingsSectionForm>
|
||||
<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)
|
||||
}
|
||||
>
|
||||
{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>
|
||||
|
||||
{!form.watch("authPageDomainId") && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"addDomainToEnableCustomAuthPages"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{env.flags.usePangolinDns &&
|
||||
(build === "enterprise" ||
|
||||
(build === "saas" &&
|
||||
subscription?.subscribed)) &&
|
||||
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}
|
||||
>
|
||||
{t("saveAuthPage")}
|
||||
</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 = {
|
||||
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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user