mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat: saml sso ui integration (#8094)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
1
ui/actions/integrations/index.ts
Normal file
1
ui/actions/integrations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./saml";
|
||||
210
ui/actions/integrations/saml.ts
Normal file
210
ui/actions/integrations/saml.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
55
ui/app/api/auth/callback/saml/route.ts
Normal file
55
ui/app/api/auth/callback/saml/route.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
1
ui/components/integrations/forms/index.ts
Normal file
1
ui/components/integrations/forms/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./saml-config-form";
|
||||
262
ui/components/integrations/forms/saml-config-form.tsx
Normal file
262
ui/components/integrations/forms/saml-config-form.tsx
Normal 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'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'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>
|
||||
);
|
||||
};
|
||||
2
ui/components/integrations/index.ts
Normal file
2
ui/components/integrations/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./forms";
|
||||
export * from "./saml-integration-card";
|
||||
59
ui/components/integrations/saml-integration-card.tsx
Normal file
59
ui/components/integrations/saml-integration-card.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
82
ui/components/ui/form/form-buttons.tsx
Normal file
82
ui/components/ui/form/form-buttons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./Form";
|
||||
export * from "./form-buttons";
|
||||
export * from "./Label";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user