feat: saml sso ui integration (#8094)

This commit is contained in:
Alejandro Bailo
2025-06-27 10:45:21 +02:00
committed by GitHub
parent 2e97e37316
commit 8e635b3bd4
15 changed files with 829 additions and 122 deletions

View File

@@ -21,6 +21,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Improve `Scan ID` filter by adding more context and enhancing the UI/UX [(#7979)](https://github.com/prowler-cloud/prowler/pull/7979)
- Lighthouse chat interface [(#7878)](https://github.com/prowler-cloud/prowler/pull/7878)
- Google Tag Manager integration [(#8058)](https://github.com/prowler-cloud/prowler/pull/8058)
- SAML login integration [(#8094)](https://github.com/prowler-cloud/prowler/pull/8094)
### 🔄 Changed

View File

@@ -0,0 +1 @@
export * from "./saml";

View File

@@ -0,0 +1,210 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib/helper";
const samlConfigFormSchema = z.object({
email_domain: z
.string()
.trim()
.min(1, { message: "Email domain is required" }),
metadata_xml: z
.string()
.trim()
.min(1, { message: "Metadata XML is required" }),
});
export const createSamlConfig = async (_prevState: any, formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const formDataObject = Object.fromEntries(formData);
const validatedData = samlConfigFormSchema.safeParse(formDataObject);
if (!validatedData.success) {
const formFieldErrors = validatedData.error.flatten().fieldErrors;
return {
errors: {
email_domain: formFieldErrors?.email_domain?.[0],
metadata_xml: formFieldErrors?.metadata_xml?.[0],
},
};
}
const { email_domain, metadata_xml } = validatedData.data;
try {
const url = new URL(`${apiBaseUrl}/saml-config`);
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "saml-configurations",
attributes: {
email_domain: email_domain.trim(),
metadata_xml: metadata_xml.trim(),
},
},
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.errors?.[0]?.detail ||
`Failed to create SAML config: ${response.statusText}`,
);
}
await response.json();
revalidatePath("/integrations");
return { success: "SAML configuration created successfully!" };
} catch (error) {
console.error("Error creating SAML config:", error);
return {
errors: {
general:
error instanceof Error
? error.message
: "Error creating SAML configuration. Please try again.",
},
};
}
};
export const updateSamlConfig = async (_prevState: any, formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const formDataObject = Object.fromEntries(formData);
const validatedData = samlConfigFormSchema.safeParse(formDataObject);
if (!validatedData.success) {
const formFieldErrors = validatedData.error.flatten().fieldErrors;
return {
errors: {
email_domain: formFieldErrors?.email_domain?.[0],
metadata_xml: formFieldErrors?.metadata_xml?.[0],
},
};
}
const { email_domain, metadata_xml } = validatedData.data;
try {
const url = new URL(`${apiBaseUrl}/saml-config/${formDataObject.id}`);
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "saml-configurations",
id: formDataObject.id,
attributes: {
email_domain: email_domain.trim(),
metadata_xml: metadata_xml.trim(),
},
},
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.errors?.[0]?.detail ||
`Failed to update SAML config: ${response.statusText}`,
);
}
await response.json();
revalidatePath("/integrations");
return { success: "SAML configuration updated successfully!" };
} catch (error) {
console.error("Error updating SAML config:", error);
return {
errors: {
general:
error instanceof Error
? error.message
: "Error creating SAML configuration. Please try again.",
},
};
}
};
export const getSamlConfig = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/saml-config`);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch SAML config: ${response.statusText}`);
}
const data = await response.json();
const parsedData = parseStringify(data);
return parsedData;
} catch (error) {
console.error("Error fetching SAML config:", error);
return undefined;
}
};
export const initiateSamlAuth = async (email: string) => {
try {
const response = await fetch(`${apiBaseUrl}/auth/saml/initiate/`, {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
},
body: JSON.stringify({
data: {
type: "saml-initiate",
attributes: {
email_domain: email,
},
},
}),
redirect: "manual",
});
if (response.status === 302) {
const location = response.headers.get("Location");
if (location) {
return {
success: true,
redirectUrl: location,
};
}
}
if (response.status === 403) {
return {
success: false,
error:
"Domain is not authorized for SAML authentication or SAML certificates are missing.",
};
}
// Add error other error case:
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error:
errorData.errors?.[0]?.detail ||
"An error occurred during SAML authentication.",
};
} catch (error) {
return {
success: false,
error: "Failed to connect to authentication service.",
};
}
};

View File

@@ -1,8 +1,10 @@
import React, { Suspense } from "react";
import { getSamlConfig } from "@/actions/integrations/saml";
import { getAllTenants } from "@/actions/users/tenants";
import { getUserInfo } from "@/actions/users/users";
import { getUserMemberships } from "@/actions/users/users";
import { SamlIntegrationCard } from "@/components/integrations/saml-integration-card";
import { ContentLayout } from "@/components/ui";
import { UserBasicInfoCard } from "@/components/users/profile";
import { MembershipsCard } from "@/components/users/profile/memberships-card";
@@ -22,6 +24,7 @@ export default async function Profile() {
}
const SSRDataUser = async () => {
const samlConfig = await getSamlConfig();
const userProfile = await getUserInfo();
if (!userProfile?.data) {
return null;
@@ -69,11 +72,11 @@ const SSRDataUser = async () => {
return (
<div className="flex w-full flex-col gap-6">
<UserBasicInfoCard user={userProfile?.data} tenantId={userTenant?.id} />
<div className="flex flex-col gap-6 lg:flex-row">
<div className="w-full md:w-1/2 lg:w-1/2 xl:w-1/2 2xl:w-1/2">
<div className="flex flex-col gap-6 xl:flex-row">
<div className="w-full lg:w-2/3 xl:w-1/2">
<RolesCard roles={roleDetails || []} roleDetails={roleDetailsMap} />
</div>
<div className="w-full md:w-1/2 lg:w-1/2 xl:w-1/2 2xl:w-1/2">
<div className="w-full lg:w-2/3 xl:w-1/2">
<MembershipsCard
memberships={memberships?.data || []}
tenantsMap={tenantsMap}
@@ -81,6 +84,11 @@ const SSRDataUser = async () => {
/>
</div>
</div>
<div className="w-full pr-0 lg:w-2/3 xl:w-1/2 xl:pr-3">
{samlConfig.data?.length > 0 && (
<SamlIntegrationCard id={samlConfig.data[0]?.id} />
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,55 @@
"use server";
import { NextResponse } from "next/server";
import { signIn } from "@/auth.config";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "ID parameter is missing" },
{ status: 400 },
);
}
try {
const response = await fetch(`${apiBaseUrl}/tokens/saml?id=${id}`, {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch tokens: ${response.statusText}`);
}
const tokenData = await response.json();
const { access, refresh } = tokenData.data;
if (!access || !refresh) {
throw new Error("Tokens not found in response");
}
const result = await signIn("social-oauth", {
accessToken: access,
refreshToken: refresh,
redirect: false,
callbackUrl: `${baseUrl}/`,
});
if (result?.error) {
throw new Error(result.error);
}
return NextResponse.redirect(new URL("/", baseUrl));
} catch (error) {
console.error("SAML authentication failed:", error);
return NextResponse.redirect(new URL("/sign-in", baseUrl));
}
}

View File

@@ -8,6 +8,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { authenticate, createNewUser } from "@/actions/auth";
import { initiateSamlAuth } from "@/actions/integrations/saml";
import { NotificationIcon, ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { useToast } from "@/components/ui";
@@ -45,6 +46,7 @@ export const AuthForm = ({
defaultValues: {
email: "",
password: "",
isSamlMode: false,
...(type === "sign-up" && {
name: "",
company: "",
@@ -56,9 +58,31 @@ export const AuthForm = ({
const isLoading = form.formState.isSubmitting;
const { toast } = useToast();
const isSamlMode = form.watch("isSamlMode");
const onSubmit = async (data: z.infer<typeof formSchema>) => {
if (type === "sign-in") {
if (data.isSamlMode) {
const email = data.email.toLowerCase();
if (isSamlMode) {
form.setValue("password", "");
}
const result = await initiateSamlAuth(email);
if (result.success && result.redirectUrl) {
window.location.href = result.redirectUrl;
} else {
toast({
variant: "destructive",
title: "SAML Authentication Error",
description:
result.error || "An error occurred during SAML authentication.",
});
}
return;
}
const result = await authenticate(null, {
email: data.email.toLowerCase(),
password: data.password,
@@ -150,7 +174,11 @@ export const AuthForm = ({
</div>
<div className="flex items-center justify-between">
<p className="pb-2 text-xl font-medium">
{type === "sign-in" ? "Sign In" : "Sign Up"}
{type === "sign-in"
? isSamlMode
? "Sign In with SAML SSO"
: "Sign In"
: "Sign Up"}
</p>
<ThemeSwitch aria-label="Toggle theme" />
</div>
@@ -181,7 +209,6 @@ export const AuthForm = ({
/>
</>
)}
<CustomInput
control={form.control}
name="email"
@@ -191,17 +218,17 @@ export const AuthForm = ({
isInvalid={!!form.formState.errors.email}
showFormMessage={type !== "sign-in"}
/>
<CustomInput
control={form.control}
name="password"
password
isInvalid={
!!form.formState.errors.password ||
!!form.formState.errors.email
}
/>
{!isSamlMode && type === "sign-in" && (
<CustomInput
control={form.control}
name="password"
password
isInvalid={
!!form.formState.errors.password ||
!!form.formState.errors.email
}
/>
)}
{/* {type === "sign-in" && (
<div className="flex items-center justify-between px-1 py-2">
<Checkbox name="remember" size="sm">
@@ -265,14 +292,12 @@ export const AuthForm = ({
)}
</>
)}
{type === "sign-in" && form.formState.errors?.email && (
<div className="flex flex-row items-center text-system-error">
<NotificationIcon size={16} />
<p className="text-small">Invalid email or password</p>
</div>
)}
<CustomButton
type="submit"
ariaLabel={type === "sign-in" ? "Log In" : "Sign Up"}
@@ -294,7 +319,7 @@ export const AuthForm = ({
</form>
</Form>
{!invitationToken && (
{!invitationToken && type === "sign-in" && (
<>
<div className="flex items-center gap-4 py-2">
<Divider className="flex-1" />
@@ -302,76 +327,98 @@ export const AuthForm = ({
<Divider className="flex-1" />
</div>
<div className="flex flex-col gap-2">
<Tooltip
content={
<div className="flex-inline text-small">
Social Login with Google is not enabled.{" "}
<Link
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
Read the docs
</Link>
</div>
}
placement="right-start"
shadow="sm"
isDisabled={isGoogleOAuthEnabled}
className="w-96"
>
<span>
<Button
startContent={
<Icon icon="flat-color-icons:google" width={24} />
{!isSamlMode && (
<>
<Tooltip
content={
<div className="flex-inline text-small">
Social Login with Google is not enabled.{" "}
<Link
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
Read the docs
</Link>
</div>
}
variant="bordered"
className="w-full"
as="a"
href={googleAuthUrl}
isDisabled={!isGoogleOAuthEnabled}
placement="right-start"
shadow="sm"
isDisabled={isGoogleOAuthEnabled}
className="w-96"
>
Continue with Google
</Button>
</span>
</Tooltip>
<Tooltip
content={
<div className="flex-inline text-small">
Social Login with Github is not enabled.{" "}
<Link
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
Read the docs
</Link>
</div>
}
placement="right-start"
shadow="sm"
isDisabled={isGithubOAuthEnabled}
className="w-96"
>
<span>
<Button
startContent={
<Icon
className="text-default-500"
icon="fe:github"
width={24}
/>
<span>
<Button
startContent={
<Icon icon="flat-color-icons:google" width={24} />
}
variant="bordered"
className="w-full"
as="a"
href={googleAuthUrl}
isDisabled={!isGoogleOAuthEnabled}
>
Continue with Google
</Button>
</span>
</Tooltip>
<Tooltip
content={
<div className="flex-inline text-small">
Social Login with Github is not enabled.{" "}
<Link
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
Read the docs
</Link>
</div>
}
variant="bordered"
className="w-full"
as="a"
href={githubAuthUrl}
isDisabled={!isGithubOAuthEnabled}
placement="right-start"
shadow="sm"
isDisabled={isGithubOAuthEnabled}
className="w-96"
>
Continue with Github
</Button>
</span>
</Tooltip>
<span>
<Button
startContent={
<Icon
className="text-default-500"
icon="fe:github"
width={24}
/>
}
variant="bordered"
className="w-full"
as="a"
href={githubAuthUrl}
isDisabled={!isGithubOAuthEnabled}
>
Continue with Github
</Button>
</span>
</Tooltip>
</>
)}
<Button
startContent={
!isSamlMode && (
<Icon
className="text-default-500"
icon="mdi:shield-key"
width={24}
/>
)
}
variant="bordered"
className="w-full"
onClick={() => {
form.setValue("isSamlMode", !isSamlMode);
}}
>
{isSamlMode ? "Back" : "Continue with SAML SSO"}
</Button>
</div>
</>
)}

View File

@@ -0,0 +1 @@
export * from "./saml-config-form";

View File

@@ -0,0 +1,262 @@
"use client";
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import { useFormState } from "react-dom";
import { createSamlConfig, updateSamlConfig } from "@/actions/integrations";
import { AddIcon } from "@/components/icons";
import { useToast } from "@/components/ui";
import { CustomButton, CustomServerInput } from "@/components/ui/custom";
import { SnippetChip } from "@/components/ui/entities";
import { FormButtons } from "@/components/ui/form";
export const SamlConfigForm = ({
setIsOpen,
id,
}: {
setIsOpen: Dispatch<SetStateAction<boolean>>;
id: string;
}) => {
const [state, formAction, isPending] = useFormState(
id ? updateSamlConfig : createSamlConfig,
null,
);
const [emailDomain, setEmailDomain] = useState("");
const [uploadedFile, setUploadedFile] = useState<{
name: string;
uploaded: boolean;
}>({ name: "", uploaded: false });
const formRef = useRef<HTMLFormElement>(null);
const { toast } = useToast();
useEffect(() => {
if (state?.success) {
toast({
title: "Configuration saved successfully",
description: state.success,
});
setIsOpen(false);
} else if (state?.errors?.general) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: state.errors.general,
});
}
}, [state, toast, setIsOpen]);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setUploadedFile({ name: "", uploaded: false });
return;
}
// Check file extension
const isXmlFile =
file.name.toLowerCase().endsWith(".xml") ||
file.type === "text/xml" ||
file.type === "application/xml";
if (!isXmlFile) {
toast({
variant: "destructive",
title: "Invalid file type",
description: "Please select a valid XML file (.xml extension).",
});
// Clear the file input
event.target.value = "";
setUploadedFile({ name: "", uploaded: false });
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
// Basic XML validation
if (!content.trim().startsWith("<") || !content.includes("</")) {
toast({
variant: "destructive",
title: "Invalid XML content",
description: "The file does not contain valid XML content.",
});
// Clear the file input
event.target.value = "";
setUploadedFile({ name: "", uploaded: false });
return;
}
// Set the XML content in a hidden input
const xmlInput = document.getElementById(
"metadata_xml",
) as HTMLInputElement;
if (xmlInput) {
xmlInput.value = content;
}
// Update file state
setUploadedFile({ name: file.name, uploaded: true });
toast({
title: "File uploaded successfully",
description: "XML metadata file has been loaded.",
});
};
reader.onerror = () => {
toast({
variant: "destructive",
title: "File read error",
description: "Failed to read the selected file.",
});
// Clear the file input
event.target.value = "";
setUploadedFile({ name: "", uploaded: false });
};
reader.readAsText(file);
};
const acsUrl = emailDomain
? `https://app.prowler.pro/saml/sp/consume/${emailDomain}`
: "https://app.prowler.pro/saml/sp/consume/your-domain.com";
return (
<form ref={formRef} action={formAction} className="flex flex-col space-y-6">
<input type="hidden" name="id" value={id} />
<div className="space-y-4">
<CustomServerInput
name="email_domain"
label="Email Domain"
placeholder="Enter your email domain (e.g., company.com)"
labelPlacement="outside"
variant="bordered"
isRequired={true}
isInvalid={!!state?.errors?.email_domain}
errorMessage={state?.errors?.email_domain}
value={emailDomain}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setEmailDomain(e.target.value);
}}
/>
<div className="flex flex-col items-start space-y-2">
<span className="text-xs text-default-500">
Metadata XML File <span className="text-red-500">*</span>
</span>
<CustomButton
type="button"
ariaLabel="Select Metadata XML File"
isDisabled={isPending}
onPress={() => {
const fileInput = document.getElementById(
"metadata_xml_file",
) as HTMLInputElement;
if (fileInput) {
fileInput.click();
}
}}
startContent={<AddIcon size={20} />}
className={`h-10 justify-start rounded-medium border-2 text-default-500 ${
state?.errors?.metadata_xml
? "border-red-500"
: uploadedFile.uploaded
? "border-green-500 bg-green-50 dark:bg-green-900/20"
: "border-default-200"
}`}
>
<span className="text-small">
{uploadedFile.uploaded ? (
<span className="flex items-center space-x-2">
<span className="max-w-36 truncate">{uploadedFile.name}</span>
</span>
) : (
"Choose File"
)}
</span>
</CustomButton>
<input
type="file"
id="metadata_xml_file"
name="metadata_xml_file"
accept=".xml,application/xml,text/xml"
className="hidden"
disabled={isPending}
onChange={handleFileUpload}
/>
<input type="hidden" id="metadata_xml" name="metadata_xml" />
<p className="text-xs text-gray-500">
Upload your Identity Provider&apos;s SAML metadata XML file
</p>
<span className="text-xs text-red-500">
{state?.errors?.metadata_xml}
</span>
</div>
</div>
<div className="space-y-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<h3 className="text-lg font-semibold">
Identity Provider Configuration
</h3>
<div className="space-y-4">
<div>
<span className="mb-2 block text-sm font-medium text-default-500">
ACS URL:
</span>
<SnippetChip
value={acsUrl}
ariaLabel="Copy ACS URL to clipboard"
className="w-full"
/>
</div>
<div>
<span className="mb-2 block text-sm font-medium text-default-500">
Audience:
</span>
<SnippetChip
value="urn:prowler.com:sp"
ariaLabel="Copy Audience to clipboard"
className="w-full"
/>
</div>
<div>
<span className="mb-2 block text-sm font-medium text-default-500">
Name ID Format:
</span>
<SnippetChip
value="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
ariaLabel="Copy Name ID Format to clipboard"
className="w-full"
/>
</div>
<div>
<span className="mb-2 block text-sm font-medium text-default-500">
Supported Assertion Attributes:
</span>
<ul className="ml-4 space-y-1 text-sm text-default-600">
<li> firstName</li>
<li> lastName</li>
<li> userType</li>
<li> organization</li>
</ul>
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
<strong>Note:</strong> The userType attribute will be used to
assign the user&apos;s role. If the role does not exist, one will
be created with minimal permissions. You can assign permissions to
roles on the Roles page.
</p>
</div>
</div>
</div>
<FormButtons setIsOpen={setIsOpen} submitText={id ? "Update" : "Save"} />
</form>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./forms";
export * from "./saml-integration-card";

View File

@@ -0,0 +1,59 @@
"use client";
import { Card, CardBody, CardHeader } from "@nextui-org/react";
import { CheckIcon } from "lucide-react";
import { useState } from "react";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { SamlConfigForm } from "./forms";
export const SamlIntegrationCard = ({ id }: { id: string }) => {
const [isSamlModalOpen, setIsSamlModalOpen] = useState(false);
return (
<>
<CustomAlertModal
isOpen={isSamlModalOpen}
onOpenChange={setIsSamlModalOpen}
title="Configure SAML SSO"
>
<SamlConfigForm setIsOpen={setIsSamlModalOpen} id={id} />
</CustomAlertModal>
<Card className="dark:bg-prowler-blue-400">
<CardHeader className="gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h4 className="text-lg font-bold">SAML SSO Integration</h4>
{id && <CheckIcon className="text-prowler-green" size={20} />}
</div>
<p className="text-xs text-gray-500">
{id
? "SAML Single Sign-On is enabled for this organization"
: "Configure SAML Single Sign-On for secure authentication"}
</p>
</div>
</CardHeader>
<CardBody>
<div className="flex items-center justify-between">
<div className="text-sm">
<span className="font-medium">Status: </span>
<span className={id ? "text-prowler-green" : "text-gray-500"}>
{id ? "Enabled" : "Disabled"}
</span>
</div>
<CustomButton
size="sm"
ariaLabel="Add SAML SSO"
color="action"
onPress={() => setIsSamlModalOpen(true)}
>
{id ? "Update" : "Enable"}
</CustomButton>
</div>
</CardBody>
</Card>
</>
);
};

View File

@@ -13,6 +13,8 @@ interface CustomServerInputProps {
isRequired?: boolean;
isInvalid?: boolean;
errorMessage?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
/**
@@ -28,6 +30,8 @@ export const CustomServerInput = ({
isRequired = false,
isInvalid = false,
errorMessage,
value,
onChange,
}: CustomServerInputProps) => {
return (
<div className="flex flex-col">
@@ -42,6 +46,8 @@ export const CustomServerInput = ({
isRequired={isRequired}
isInvalid={isInvalid}
errorMessage={errorMessage}
value={value}
onChange={onChange}
classNames={{
label: "tracking-tight font-light !text-default-500 text-xs !z-0",
input: "text-default-500 text-small",

View File

@@ -0,0 +1,82 @@
"use client";
import { Dispatch, SetStateAction } from "react";
import { useFormStatus } from "react-dom";
import { SaveIcon } from "@/components/icons";
import { CustomButton } from "../custom";
interface FormCancelButtonProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
children?: React.ReactNode;
}
interface FormSubmitButtonProps {
children?: React.ReactNode;
loadingText?: string;
}
interface FormButtonsProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
submitText?: string;
cancelText?: string;
loadingText?: string;
}
export const FormCancelButton = ({
setIsOpen,
children = "Cancel",
}: FormCancelButtonProps) => {
return (
<CustomButton
type="button"
ariaLabel="Cancel"
className="w-full bg-transparent"
variant="faded"
size="lg"
onPress={() => setIsOpen(false)}
>
<span>{children}</span>
</CustomButton>
);
};
export const FormSubmitButton = ({
children = "Save",
loadingText = "Loading",
}: FormSubmitButtonProps) => {
const { pending } = useFormStatus();
return (
<CustomButton
type="submit"
ariaLabel="Save"
className="w-full"
variant="solid"
color="action"
size="lg"
isLoading={pending}
startContent={!pending && <SaveIcon size={24} />}
>
{pending ? <>{loadingText}</> : <span>{children}</span>}
</CustomButton>
);
};
export const FormButtons = ({
setIsOpen,
submitText = "Save",
cancelText = "Cancel",
loadingText = "Loading",
}: FormButtonsProps) => {
return (
<div className="flex w-full justify-center space-x-6">
<FormCancelButton setIsOpen={setIsOpen}>{cancelText}</FormCancelButton>
<FormSubmitButton loadingText={loadingText}>
{submitText}
</FormSubmitButton>
</div>
);
};

View File

@@ -1,2 +1,3 @@
export * from "./Form";
export * from "./form-buttons";
export * from "./Label";

View File

@@ -1,31 +1,12 @@
"use client";
import { Dispatch, SetStateAction, useEffect } from "react";
import { useFormState, useFormStatus } from "react-dom";
import { useFormState } from "react-dom";
import { updateTenantName } from "@/actions/users/tenants";
import { SaveIcon } from "@/components/icons";
import { useToast } from "@/components/ui";
import { CustomButton, CustomServerInput } from "@/components/ui/custom";
const SubmitButton = () => {
const { pending } = useFormStatus();
return (
<CustomButton
type="submit"
ariaLabel="Save"
className="w-full"
variant="solid"
color="action"
size="lg"
isLoading={pending}
startContent={!pending && <SaveIcon size={24} />}
>
{pending ? <>Loading</> : <span>Save</span>}
</CustomButton>
);
};
import { CustomServerInput } from "@/components/ui/custom";
import { FormButtons } from "@/components/ui/form";
export const EditTenantForm = ({
tenantId,
@@ -76,20 +57,7 @@ export const EditTenantForm = ({
<input type="hidden" name="tenantId" value={tenantId} />
<input type="hidden" name="currentName" value={tenantName || ""} />
<div className="flex w-full justify-center space-x-6">
<CustomButton
type="button"
ariaLabel="Cancel"
className="w-full bg-transparent"
variant="faded"
size="lg"
onPress={() => setIsOpen(false)}
>
<span>Cancel</span>
</CustomButton>
<SubmitButton />
</div>
<FormButtons setIsOpen={setIsOpen} />
</form>
);
};

View File

@@ -41,9 +41,13 @@ export const authFormSchema = (type: string) =>
: z.string().min(12, {
message: "It must contain at least 12 characters.",
}),
isSamlMode: z.boolean().optional(),
})
.refine(
(data) => type === "sign-in" || data.password === data.confirmPassword,
(data) => {
if (data.isSamlMode) return true;
return type === "sign-in" || data.password === data.confirmPassword;
},
{
message: "The password must match",
path: ["confirmPassword"],