chore(ui): upgrade zod v4, zustand v5, and ai sdk v5 (#8801)

This commit is contained in:
Alejandro Bailo
2025-10-03 09:57:46 +02:00
committed by GitHub
parent 9c4a8782e4
commit 2408dbf855
43 changed files with 1469 additions and 916 deletions

View File

@@ -15,6 +15,9 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Upgraded Zod to version 4.1.11 with comprehensive migration of deprecated syntax [(#8801)](https://github.com/prowler-cloud/prowler/pull/8801)
- Upgraded Zustand to version 5.0.8 (no code changes required) [(#8801)](https://github.com/prowler-cloud/prowler/pull/8801)
- Upgraded AI SDK to version 5.0.59 with new transport and message structure [(#8801)](https://github.com/prowler-cloud/prowler/pull/8801)
- Upgraded React to version 19.1.1 with async components support [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- Upgraded Next.js to version 15.5.3 with enhanced App Router [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- Updated from NextUI to HeroUI [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)

View File

@@ -1,23 +1,14 @@
"use server";
import { AuthError } from "next-auth";
import { z } from "zod";
import { signIn, signOut } from "@/auth.config";
import { apiBaseUrl } from "@/lib";
import { authFormSchema } from "@/types";
const formSchemaSignIn = authFormSchema("sign-in");
const formSchemaSignUp = authFormSchema("sign-up");
const defaultValues: z.infer<typeof formSchemaSignIn> = {
email: "",
password: "",
};
import type { SignInFormData, SignUpFormData } from "@/types";
export async function authenticate(
prevState: unknown,
formData: z.infer<typeof formSchemaSignIn>,
formData: SignInFormData,
) {
try {
await signIn("credentials", {
@@ -34,7 +25,6 @@ export async function authenticate(
return {
message: "Credentials error",
errors: {
...defaultValues,
credentials: "Invalid email or password",
},
};
@@ -46,7 +36,6 @@ export async function authenticate(
return {
message: "Unknown error",
errors: {
...defaultValues,
unknown: "Unknown error",
},
};
@@ -55,9 +44,7 @@ export async function authenticate(
}
}
export const createNewUser = async (
formData: z.infer<typeof formSchemaSignUp>,
) => {
export const createNewUser = async (formData: SignUpFormData) => {
const url = new URL(`${apiBaseUrl}/users`);
if (formData.invitationToken) {
@@ -104,7 +91,7 @@ export const createNewUser = async (
}
};
export const getToken = async (formData: z.infer<typeof formSchemaSignIn>) => {
export const getToken = async (formData: SignInFormData) => {
const url = new URL(`${apiBaseUrl}/tokens`);
const bodyData = {

View File

@@ -1,12 +1,10 @@
import { LangChainAdapter, Message } from "ai";
import { toUIMessageStream } from "@ai-sdk/langchain";
import { createUIMessageStreamResponse, UIMessage } from "ai";
import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse";
import { getErrorMessage } from "@/lib/helper";
import { getCurrentDataSection } from "@/lib/lighthouse/data";
import {
convertLangChainMessageToVercelMessage,
convertVercelMessageToLangChainMessage,
} from "@/lib/lighthouse/utils";
import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse/utils";
import { initLighthouseWorkflow } from "@/lib/lighthouse/workflow";
export async function POST(req: Request) {
@@ -14,7 +12,7 @@ export async function POST(req: Request) {
const {
messages,
}: {
messages: Message[];
messages: UIMessage[];
} = await req.json();
if (!messages) {
@@ -32,14 +30,19 @@ export async function POST(req: Request) {
const currentData = await getCurrentDataSection();
// Add context messages at the beginning
const contextMessages: Message[] = [];
const contextMessages: UIMessage[] = [];
// Add business context if available
if (businessContext) {
contextMessages.push({
id: "business-context",
role: "assistant",
content: `Business Context Information:\n${businessContext}`,
parts: [
{
type: "text",
text: `Business Context Information:\n${businessContext}`,
},
],
});
}
@@ -48,7 +51,12 @@ export async function POST(req: Request) {
contextMessages.push({
id: "current-data",
role: "assistant",
content: currentData,
parts: [
{
type: "text",
text: currentData,
},
],
});
}
@@ -61,7 +69,7 @@ export async function POST(req: Request) {
{
messages: processedMessages
.filter(
(message: Message) =>
(message: UIMessage) =>
message.role === "user" || message.role === "assistant",
)
.map(convertVercelMessageToLangChainMessage),
@@ -75,12 +83,12 @@ export async function POST(req: Request) {
const stream = new ReadableStream({
async start(controller) {
try {
for await (const { event, data, tags } of agentStream) {
for await (const streamEvent of agentStream) {
const { event, data, tags } = streamEvent;
if (event === "on_chat_model_stream") {
if (data.chunk.content && !!tags && tags.includes("supervisor")) {
const chunk = data.chunk;
const aiMessage = convertLangChainMessageToVercelMessage(chunk);
controller.enqueue(aiMessage);
// Pass the raw LangChain stream event - toUIMessageStream will handle conversion
controller.enqueue(streamEvent);
}
}
}
@@ -88,17 +96,17 @@ export async function POST(req: Request) {
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
controller.enqueue({
id: "error-" + Date.now(),
role: "assistant",
content: `[LIGHTHOUSE_ANALYST_ERROR]: ${errorMessage}`,
});
// For errors, send a plain string that toUIMessageStream will convert to text chunks
controller.enqueue(`[LIGHTHOUSE_ANALYST_ERROR]: ${errorMessage}`);
controller.close();
}
},
});
return LangChainAdapter.toDataStreamResponse(stream);
// Convert LangChain stream to UI message stream and return as SSE response
return createUIMessageStreamResponse({
stream: toUIMessageStream(stream),
});
} catch (error) {
console.error("Error in POST request:", error);
return Response.json(

View File

@@ -74,14 +74,18 @@ export const authConfig = {
async authorize(credentials) {
const parsedCredentials = z
.object({
email: z.string().email(),
email: z.email(),
password: z.string().min(12),
})
.safeParse(credentials);
if (!parsedCredentials.success) return null;
const tokenResponse = await getToken(parsedCredentials.data);
const { email, password } = parsedCredentials.data;
const tokenResponse = await getToken({
email,
password,
});
if (!tokenResponse) return null;
const userMeResponse = await getUserByMe(tokenResponse.accessToken);

View File

@@ -0,0 +1,11 @@
import { Divider } from "@heroui/divider";
export const AuthDivider = () => {
return (
<div className="flex items-center gap-4 py-2">
<Divider className="flex-1" />
<p className="text-tiny text-default-500 shrink-0">OR</p>
<Divider className="flex-1" />
</div>
);
};

View File

@@ -0,0 +1,22 @@
import { CustomLink } from "@/components/ui/custom/custom-link";
interface AuthFooterLinkProps {
text: string;
linkText: string;
href: string;
}
export const AuthFooterLink = ({
text,
linkText,
href,
}: AuthFooterLinkProps) => {
return (
<p className="text-small text-center">
{text}&nbsp;
<CustomLink size="base" href={href} target="_self">
{linkText}
</CustomLink>
</p>
);
};

View File

@@ -1,36 +1,9 @@
"use client";
import { Button } from "@heroui/button";
import { Checkbox } from "@heroui/checkbox";
import { Divider } from "@heroui/divider";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { authenticate, createNewUser } from "@/actions/auth";
import { initiateSamlAuth } from "@/actions/integrations/saml";
import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { ApiError, authFormSchema } from "@/types";
import { SignInForm } from "@/components/auth/oss/sign-in-form";
import { SignUpForm } from "@/components/auth/oss/sign-up-form";
export const AuthForm = ({
type,
invitationToken,
isCloudEnv,
googleAuthUrl,
githubAuthUrl,
isGoogleOAuthEnabled,
@@ -38,387 +11,29 @@ export const AuthForm = ({
}: {
type: string;
invitationToken?: string | null;
isCloudEnv?: boolean;
googleAuthUrl?: string;
githubAuthUrl?: string;
isGoogleOAuthEnabled?: boolean;
isGithubOAuthEnabled?: boolean;
}) => {
const formSchema = authFormSchema(type);
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
useEffect(() => {
const samlError = searchParams.get("sso_saml_failed");
if (samlError) {
// Add a delay to the toast to ensure it is rendered
setTimeout(() => {
toast({
variant: "destructive",
title: "SAML Authentication Error",
description:
"An error occurred while attempting to login via your Identity Provider (IdP). Please check your IdP configuration.",
});
}, 100);
}
}, [searchParams, toast]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onSubmit",
reValidateMode: "onSubmit",
defaultValues: {
email: "",
password: "",
isSamlMode: false,
...(type === "sign-up" && {
name: "",
company: "",
confirmPassword: "",
...(invitationToken && { invitationToken }),
}),
},
});
const isLoading = form.formState.isSubmitting;
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,
});
if (result?.message === "Success") {
router.push("/");
} else if (result?.errors && "credentials" in result.errors) {
const message =
result.errors.credentials ?? "Invalid email or password";
form.setError("email", { type: "server", message });
form.setError("password", { type: "server", message });
} else if (result?.message === "User email is not verified") {
router.push("/email-verification");
} else {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: "An unexpected error occurred. Please try again.",
});
}
}
if (type === "sign-up") {
const newUser = await createNewUser(data);
if (!newUser.errors) {
toast({
title: "Success!",
description: "The user was registered successfully.",
});
form.reset();
if (isCloudEnv) {
router.push("/email-verification");
} else {
router.push("/sign-in");
}
} else {
newUser.errors.forEach((error: ApiError) => {
const errorMessage = error.detail;
const pointer = error.source?.pointer;
switch (pointer) {
case "/data/attributes/name":
form.setError("name", { type: "server", message: errorMessage });
break;
case "/data/attributes/email":
form.setError("email", { type: "server", message: errorMessage });
break;
case "/data/attributes/company_name":
form.setError("company", {
type: "server",
message: errorMessage,
});
break;
case "/data/attributes/password":
form.setError("password", {
type: "server",
message: errorMessage,
});
break;
case "/data":
form.setError("invitationToken", {
type: "server",
message: errorMessage,
});
break;
default:
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
}
});
}
}
};
if (type === "sign-in") {
return (
<SignInForm
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
/>
);
}
return (
<div className="relative flex h-screen w-screen">
{/* Auth Form */}
<div className="relative flex w-full items-center justify-center lg:w-full">
{/* Background Pattern */}
<div className="absolute h-full w-full bg-[radial-gradient(#6af400_1px,transparent_1px)] mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_10%,transparent_80%)] bg-size-[16px_16px]"></div>
<div className="rounded-large border-divider shadow-small dark:bg-background/85 relative z-10 flex w-full max-w-sm flex-col gap-4 border bg-white/90 px-8 py-10 md:max-w-md">
{/* Prowler Logo */}
<div className="absolute -top-[100px] left-1/2 z-10 flex h-fit w-fit -translate-x-1/2">
<ProwlerExtended width={300} />
</div>
<div className="flex items-center justify-between">
<p className="pb-2 text-xl font-medium">
{type === "sign-in"
? isSamlMode
? "Sign in with SAML SSO"
: "Sign in"
: "Sign up"}
</p>
<ThemeSwitch aria-label="Toggle theme" />
</div>
<Form {...form}>
<form
noValidate
method="post"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
{type === "sign-up" && (
<>
<CustomInput
control={form.control}
name="name"
type="text"
label="Name"
placeholder="Enter your name"
isInvalid={!!form.formState.errors.name}
/>
<CustomInput
control={form.control}
name="company"
type="text"
label="Company name"
placeholder="Enter your company name"
isRequired={false}
isInvalid={!!form.formState.errors.company}
/>
</>
)}
<CustomInput
control={form.control}
name="email"
type="email"
label="Email"
placeholder="Enter your email"
isInvalid={!!form.formState.errors.email}
// Always show field validation message, including on sign-in
showFormMessage
/>
{!isSamlMode && (
<>
<CustomInput
control={form.control}
name="password"
password
// Only mark invalid when the password field has an error
isInvalid={!!form.formState.errors.password}
/>
{type === "sign-up" && (
<PasswordRequirementsMessage
password={form.watch("password") || ""}
/>
)}
</>
)}
{/* {type === "sign-in" && (
<div className="flex items-center justify-between px-1 py-2">
<Checkbox name="remember" size="sm">
Remember me
</Checkbox>
<Link className="text-default-500" href="#">
Forgot password?
</Link>
</div>
)} */}
{type === "sign-up" && (
<>
<CustomInput
control={form.control}
name="confirmPassword"
confirmPassword
/>
{invitationToken && (
<CustomInput
control={form.control}
name="invitationToken"
type="text"
label="Invitation Token"
placeholder={invitationToken}
defaultValue={invitationToken}
isRequired={false}
isInvalid={!!form.formState.errors.invitationToken}
isDisabled={invitationToken !== null && true}
/>
)}
{process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
<FormField
control={form.control}
name="termsAndConditions"
render={({ field }) => (
<>
<FormControl>
<Checkbox
isRequired
className="py-4"
size="sm"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
>
I agree with the&nbsp;
<CustomLink
href="https://prowler.com/terms-of-service/"
size="sm"
>
Terms of Service
</CustomLink>
&nbsp;of Prowler
</Checkbox>
</FormControl>
<FormMessage className="text-system-error dark:text-system-error" />
</>
)}
/>
)}
</>
)}
<CustomButton
type="submit"
ariaLabel={type === "sign-in" ? "Log in" : "Sign up"}
ariaDisabled={isLoading}
className="w-full"
variant="solid"
color="action"
size="md"
radius="md"
isLoading={isLoading}
isDisabled={isLoading}
>
{isLoading ? (
<span>Loading</span>
) : (
<span>{type === "sign-in" ? "Log in" : "Sign up"}</span>
)}
</CustomButton>
</form>
</Form>
{!invitationToken && type === "sign-in" && (
<>
<div className="flex items-center gap-4 py-2">
<Divider className="flex-1" />
<p className="text-tiny text-default-500 shrink-0">OR</p>
<Divider className="flex-1" />
</div>
<div className="flex flex-col gap-2">
{!isSamlMode && (
<SocialButtons
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
/>
)}
<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>
</>
)}
{!invitationToken && type === "sign-up" && (
<>
<div className="flex items-center gap-4 py-2">
<Divider className="flex-1" />
<p className="text-tiny text-default-500 shrink-0">OR</p>
<Divider className="flex-1" />
</div>
<div className="flex flex-col gap-2">
<SocialButtons
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
/>
</div>
</>
)}
{type === "sign-in" ? (
<p className="text-small text-center">
Need to create an account?&nbsp;
<CustomLink size="base" href="/sign-up" target="_self">
Sign up
</CustomLink>
</p>
) : (
<p className="text-small text-center">
Already have an account?&nbsp;
<CustomLink size="base" href="/sign-in" target="_self">
Log in
</CustomLink>
</p>
)}
</div>
</div>
</div>
<SignUpForm
invitationToken={invitationToken}
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
/>
);
};

View File

@@ -0,0 +1,37 @@
import { ReactNode } from "react";
import { ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
interface AuthLayoutProps {
title: string;
children: ReactNode;
}
export const AuthLayout = ({ title, children }: AuthLayoutProps) => {
return (
<div className="relative flex h-screen w-screen">
<div className="relative flex w-full items-center justify-center lg:w-full">
{/* Background Pattern */}
<div className="absolute h-full w-full bg-[radial-gradient(#6af400_1px,transparent_1px)] mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_10%,transparent_80%)] bg-size-[16px_16px]"></div>
{/* Auth Form Container */}
<div className="rounded-large border-divider shadow-small dark:bg-background/85 relative z-10 flex w-full max-w-sm flex-col gap-4 border bg-white/90 px-8 py-10 md:max-w-md">
{/* Prowler Logo */}
<div className="absolute -top-[100px] left-1/2 z-10 flex h-fit w-fit -translate-x-1/2">
<ProwlerExtended width={300} />
</div>
{/* Header with Title and Theme Toggle */}
<div className="flex items-center justify-between">
<p className="pb-2 text-xl font-medium">{title}</p>
<ThemeSwitch aria-label="Toggle theme" />
</div>
{/* Content */}
{children}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,194 @@
"use client";
import { Button } from "@heroui/button";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { authenticate } from "@/actions/auth";
import { initiateSamlAuth } from "@/actions/integrations/saml";
import { AuthDivider } from "@/components/auth/oss/auth-divider";
import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { SignInFormData, signInSchema } from "@/types";
export const SignInForm = ({
googleAuthUrl,
githubAuthUrl,
isGoogleOAuthEnabled,
isGithubOAuthEnabled,
}: {
googleAuthUrl?: string;
githubAuthUrl?: string;
isGoogleOAuthEnabled?: boolean;
isGithubOAuthEnabled?: boolean;
}) => {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
useEffect(() => {
const samlError = searchParams.get("sso_saml_failed");
if (samlError) {
setTimeout(() => {
toast({
variant: "destructive",
title: "SAML Authentication Error",
description:
"An error occurred while attempting to login via your Identity Provider (IdP). Please check your IdP configuration.",
});
}, 100);
}
}, [searchParams, toast]);
const form = useForm<SignInFormData>({
resolver: zodResolver(signInSchema),
mode: "onSubmit",
reValidateMode: "onSubmit",
defaultValues: {
email: "",
password: "",
isSamlMode: false,
},
});
const isLoading = form.formState.isSubmitting;
const isSamlMode = form.watch("isSamlMode");
const onSubmit = async (data: SignInFormData) => {
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,
});
if (result?.message === "Success") {
router.push("/");
} else if (result?.errors && "credentials" in result.errors) {
const message = result.errors.credentials ?? "Invalid email or password";
form.setError("email", { type: "server", message });
form.setError("password", { type: "server", message });
} else if (result?.message === "User email is not verified") {
router.push("/email-verification");
} else {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: "An unexpected error occurred. Please try again.",
});
}
};
const title = isSamlMode ? "Sign in with SAML SSO" : "Sign in";
return (
<AuthLayout title={title}>
<Form {...form}>
<form
noValidate
method="post"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<CustomInput
control={form.control}
name="email"
type="email"
label="Email"
placeholder="Enter your email"
isInvalid={!!form.formState.errors.email}
showFormMessage
/>
{!isSamlMode && (
<CustomInput
control={form.control}
name="password"
password
isInvalid={!!form.formState.errors.password}
/>
)}
<CustomButton
type="submit"
ariaLabel="Log in"
ariaDisabled={isLoading}
className="w-full"
variant="solid"
color="action"
size="md"
radius="md"
isLoading={isLoading}
isDisabled={isLoading}
>
{isLoading ? <span>Loading</span> : <span>Log in</span>}
</CustomButton>
</form>
</Form>
<AuthDivider />
<div className="flex flex-col gap-2">
{!isSamlMode && (
<SocialButtons
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
/>
)}
<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>
<AuthFooterLink
text="Need to create an account?"
linkText="Sign up"
href="/sign-up"
/>
</AuthLayout>
);
};

View File

@@ -0,0 +1,252 @@
"use client";
import { Checkbox } from "@heroui/checkbox";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { createNewUser } from "@/actions/auth";
import { AuthDivider } from "@/components/auth/oss/auth-divider";
import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { ApiError, SignUpFormData, signUpSchema } from "@/types";
const AUTH_ERROR_PATHS = {
NAME: "/data/attributes/name",
EMAIL: "/data/attributes/email",
PASSWORD: "/data/attributes/password",
COMPANY_NAME: "/data/attributes/company_name",
INVITATION_TOKEN: "/data",
} as const;
export const SignUpForm = ({
invitationToken,
googleAuthUrl,
githubAuthUrl,
isGoogleOAuthEnabled,
isGithubOAuthEnabled,
}: {
invitationToken?: string | null;
googleAuthUrl?: string;
githubAuthUrl?: string;
isGoogleOAuthEnabled?: boolean;
isGithubOAuthEnabled?: boolean;
}) => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<SignUpFormData>({
resolver: zodResolver(signUpSchema),
mode: "onSubmit",
reValidateMode: "onSubmit",
defaultValues: {
email: "",
password: "",
isSamlMode: false,
name: "",
company: "",
confirmPassword: "",
...(invitationToken && { invitationToken }),
},
});
const isLoading = form.formState.isSubmitting;
const onSubmit = async (data: SignUpFormData) => {
const newUser = await createNewUser(data);
if (!newUser.errors) {
toast({
title: "Success!",
description: "The user was registered successfully.",
});
form.reset();
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
router.push("/email-verification");
} else {
router.push("/sign-in");
}
} else {
newUser.errors.forEach((error: ApiError) => {
const errorMessage = error.detail;
const pointer = error.source?.pointer;
switch (pointer) {
case AUTH_ERROR_PATHS.NAME:
form.setError("name", { type: "server", message: errorMessage });
break;
case AUTH_ERROR_PATHS.EMAIL:
form.setError("email", { type: "server", message: errorMessage });
break;
case AUTH_ERROR_PATHS.COMPANY_NAME:
form.setError("company", {
type: "server",
message: errorMessage,
});
break;
case AUTH_ERROR_PATHS.PASSWORD:
form.setError("password", {
type: "server",
message: errorMessage,
});
break;
case AUTH_ERROR_PATHS.INVITATION_TOKEN:
form.setError("invitationToken", {
type: "server",
message: errorMessage,
});
break;
default:
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
}
});
}
};
return (
<AuthLayout title="Sign up">
<Form {...form}>
<form
noValidate
method="post"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<CustomInput
control={form.control}
name="name"
type="text"
label="Name"
placeholder="Enter your name"
isInvalid={!!form.formState.errors.name}
/>
<CustomInput
control={form.control}
name="company"
type="text"
label="Company name"
placeholder="Enter your company name"
isRequired={false}
isInvalid={!!form.formState.errors.company}
/>
<CustomInput
control={form.control}
name="email"
type="email"
label="Email"
placeholder="Enter your email"
isInvalid={!!form.formState.errors.email}
showFormMessage
/>
<CustomInput
control={form.control}
name="password"
password
isInvalid={!!form.formState.errors.password}
/>
<PasswordRequirementsMessage
password={form.watch("password") || ""}
/>
<CustomInput
control={form.control}
name="confirmPassword"
confirmPassword
/>
{invitationToken && (
<CustomInput
control={form.control}
name="invitationToken"
type="text"
label="Invitation Token"
placeholder={invitationToken}
defaultValue={invitationToken}
isRequired={false}
isInvalid={!!form.formState.errors.invitationToken}
isDisabled={invitationToken !== null && true}
/>
)}
{process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
<FormField
control={form.control}
name="termsAndConditions"
render={({ field }) => (
<>
<FormControl>
<Checkbox
isRequired
className="py-4"
size="sm"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
>
I agree with the&nbsp;
<CustomLink
href="https://prowler.com/terms-of-service/"
size="sm"
>
Terms of Service
</CustomLink>
&nbsp;of Prowler
</Checkbox>
</FormControl>
<FormMessage className="text-system-error dark:text-system-error" />
</>
)}
/>
)}
<CustomButton
type="submit"
ariaLabel="Sign up"
ariaDisabled={isLoading}
className="w-full"
variant="solid"
color="action"
size="md"
radius="md"
isLoading={isLoading}
isDisabled={isLoading}
>
{isLoading ? <span>Loading</span> : <span>Sign up</span>}
</CustomButton>
</form>
</Form>
{!invitationToken && (
<>
<AuthDivider />
<div className="flex flex-col gap-2">
<SocialButtons
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
/>
</div>
</>
)}
<AuthFooterLink
text="Already have an account?"
linkText="Log in"
href="/sign-in"
/>
</AuthLayout>
);
};

View File

@@ -81,10 +81,13 @@ export const SecurityHubIntegrationForm = ({
integration_type: "aws_security_hub" as const,
provider_id: integration?.relationships?.providers?.data?.[0]?.id || "",
send_only_fails:
integration?.attributes.configuration.send_only_fails ?? true,
(integration?.attributes.configuration.send_only_fails as
| boolean
| undefined) ?? true,
archive_previous_findings:
integration?.attributes.configuration.archive_previous_findings ??
false,
(integration?.attributes.configuration.archive_previous_findings as
| boolean
| undefined) ?? false,
use_custom_credentials: false,
enabled: integration?.attributes.enabled ?? true,
credentials_type: "access-secret-key" as const,

View File

@@ -14,8 +14,8 @@ import { Form } from "@/components/ui/form";
import { ApiError } from "@/types";
const sendInvitationFormSchema = z.object({
email: z.string().email("Please enter a valid email"),
roleId: z.string().nonempty("Role is required"),
email: z.email({ error: "Please enter a valid email" }),
roleId: z.string().min(1, "Role is required"),
});
export type FormValues = z.infer<typeof sendInvitationFormSchema>;

View File

@@ -0,0 +1,81 @@
"use client";
import { Button } from "@heroui/button";
import type { PressEvent } from "@react-types/shared";
import { cn } from "@/lib/utils";
interface ActionsProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children?: React.ReactNode;
ref?: React.Ref<HTMLDivElement>;
}
const Actions = ({ className, children, ref, ...props }: ActionsProps) => {
return (
<div
ref={ref}
className={cn(
"border-default-200 bg-default-50 dark:border-default-100 dark:bg-default-100/50 flex flex-wrap items-center gap-2 rounded-lg border p-2",
className,
)}
{...props}
>
{children}
</div>
);
};
interface ActionProps {
/**
* Action label text
*/
label: string;
/**
* Optional icon component (Lucide React icon recommended)
*/
icon?: React.ReactNode;
/**
* Click handler
*/
onClick?: (e: PressEvent) => void;
/**
* Visual variant
* @default "light"
*/
variant?: "solid" | "bordered" | "light" | "flat" | "faded" | "shadow";
className?: string;
isDisabled?: boolean;
ref?: React.Ref<HTMLButtonElement>;
}
const Action = ({
label,
icon,
onClick,
variant = "light",
className,
isDisabled = false,
ref,
...props
}: ActionProps) => {
return (
<Button
ref={ref}
variant={variant}
size="sm"
onPress={onClick}
isDisabled={isDisabled}
className={cn(
"min-w-unit-16 gap-1.5 text-xs font-medium transition-all hover:scale-105",
className,
)}
startContent={icon}
{...props}
>
{label}
</Button>
);
};
export { Action, Actions };

View File

@@ -1,10 +1,15 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Copy, Play, Plus, RotateCcw, Square } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Action, Actions } from "@/components/lighthouse/actions";
import { Loader } from "@/components/lighthouse/loader";
import { MemoizedMarkdown } from "@/components/lighthouse/memoized-markdown";
import { useToast } from "@/components/ui";
import { CustomButton, CustomTextarea } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form } from "@/components/ui/form";
@@ -26,53 +31,58 @@ interface ChatFormData {
export const Chat = ({ hasConfig, isActive }: ChatProps) => {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { toast } = useToast();
const {
messages,
handleSubmit,
handleInputChange,
append,
status,
error,
setMessages,
} = useChat({
api: "/api/lighthouse/analyst",
credentials: "same-origin",
experimental_throttle: 100,
sendExtraMessageFields: true,
onFinish: (message) => {
// There is no specific way to output the error message from langgraph supervisor
// Hence, all error messages are sent as normal messages with the prefix [LIGHTHOUSE_ANALYST_ERROR]:
// Detect error messages sent from backend using specific prefix and display the error
if (message.content?.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")) {
const errorText = message.content
.replace("[LIGHTHOUSE_ANALYST_ERROR]:", "")
.trim();
setErrorMessage(errorText);
// Remove error message from chat history
setMessages((prev) =>
prev.filter(
(m) => !m.content?.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:"),
),
const { messages, sendMessage, status, error, setMessages, regenerate } =
useChat({
transport: new DefaultChatTransport({
api: "/api/lighthouse/analyst",
credentials: "same-origin",
}),
experimental_throttle: 100,
onFinish: ({ message }) => {
// There is no specific way to output the error message from langgraph supervisor
// Hence, all error messages are sent as normal messages with the prefix [LIGHTHOUSE_ANALYST_ERROR]:
// Detect error messages sent from backend using specific prefix and display the error
const firstTextPart = message.parts.find((p) => p.type === "text");
if (
firstTextPart &&
"text" in firstTextPart &&
firstTextPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")
) {
const errorText = firstTextPart.text
.replace("[LIGHTHOUSE_ANALYST_ERROR]:", "")
.trim();
setErrorMessage(errorText);
// Remove error message from chat history
setMessages((prev) =>
prev.filter((m) => {
const textPart = m.parts.find((p) => p.type === "text");
return !(
textPart &&
"text" in textPart &&
textPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")
);
}),
);
}
},
onError: (error) => {
console.error("Chat error:", error);
if (
error?.message?.includes("<html>") &&
error?.message?.includes("<title>403 Forbidden</title>")
) {
setErrorMessage("403 Forbidden");
return;
}
setErrorMessage(
error?.message || "An error occurred. Please retry your message.",
);
}
},
onError: (error) => {
console.error("Chat error:", error);
if (
error?.message?.includes("<html>") &&
error?.message?.includes("<title>403 Forbidden</title>")
) {
setErrorMessage("403 Forbidden");
return;
}
setErrorMessage(
error?.message || "An error occurred. Please retry your message.",
);
},
});
},
});
const form = useForm<ChatFormData>({
defaultValues: {
@@ -98,7 +108,10 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
.pop();
if (lastUserMessage) {
form.setValue("message", lastUserMessage.content);
const textPart = lastUserMessage.parts.find((p) => p.type === "text");
if (textPart && "text" in textPart) {
form.setValue("message", textPart.text);
}
// Remove the last user message from history since it's now in the input
return currentMessages.slice(0, -1);
}
@@ -108,14 +121,6 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
}
}, [errorMessage, form, setMessages]);
// Sync form value with chat input
useEffect(() => {
const syntheticEvent = {
target: { value: messageValue },
} as React.ChangeEvent<HTMLInputElement>;
handleInputChange(syntheticEvent);
}, [messageValue, handleInputChange]);
// Reset form when message is sent
useEffect(() => {
if (status === "submitted") {
@@ -123,11 +128,25 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
}
}, [status, form]);
// Auto-scroll to bottom when new messages arrive or when streaming
useEffect(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
}
}, [messages, status]);
const onFormSubmit = form.handleSubmit((data) => {
// Block submission while streaming or submitted
if (status === "streaming" || status === "submitted") {
return;
}
if (data.message.trim()) {
// Clear error on new submission
setErrorMessage(null);
handleSubmit();
sendMessage({ text: data.message });
form.reset();
}
});
@@ -136,7 +155,12 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (messageValue?.trim()) {
// Block enter key while streaming or submitted
if (
messageValue?.trim() &&
status !== "streaming" &&
status !== "submitted"
) {
onFormSubmit();
}
}
@@ -144,7 +168,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [messageValue, onFormSubmit]);
}, [messageValue, onFormSubmit, status]);
const suggestedActions: SuggestedAction[] = [
{
@@ -173,8 +197,32 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
// Determine if chat should be disabled
const shouldDisableChat = !hasConfig || !isActive;
const handleNewChat = () => {
setMessages([]);
setErrorMessage(null);
form.reset({ message: "" });
};
return (
<div className="bg-background relative flex h-[calc(100vh-(--spacing(16)))] min-w-0 flex-col">
{/* Header with New Chat button */}
{messages.length > 0 && (
<div className="border-default-200 dark:border-default-100 border-b px-4 py-3">
<div className="flex items-center justify-end">
<CustomButton
ariaLabel="Start new chat"
variant="bordered"
size="sm"
startContent={<Plus className="h-4 w-4" />}
onPress={handleNewChat}
className="gap-1"
>
New Chat
</CustomButton>
</div>
</div>
)}
{shouldDisableChat && (
<div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="bg-card max-w-md rounded-lg p-6 text-center shadow-lg">
@@ -257,9 +305,8 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
key={`suggested-action-${index}`}
ariaLabel={`Send message: ${action.action}`}
onPress={() => {
append({
role: "user",
content: action.action,
sendMessage({
text: action.action,
});
}}
className="hover:bg-muted flex h-auto w-full flex-col items-start justify-start rounded-xl border bg-gray-50 px-4 py-3.5 text-left font-sans text-sm dark:bg-gray-900"
@@ -273,7 +320,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
</div>
) : (
<div
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
className="no-scrollbar flex flex-1 flex-col gap-4 overflow-y-auto p-4"
ref={messagesContainerRef}
>
{messages.map((message, idx) => {
@@ -283,47 +330,98 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
.pop();
const isLatestUserMsg =
message.role === "user" && lastUserIdx === idx;
const isLastMessage = idx === messages.length - 1;
const messageText = message.parts
.filter((p) => p.type === "text")
.map((p) => ("text" in p ? p.text : ""))
.join("");
// Check if this is the streaming assistant message (last message, assistant role, while streaming)
const isStreamingAssistant =
isLastMessage &&
message.role === "assistant" &&
status === "streaming";
// Use a composite key to ensure uniqueness even if IDs are duplicated temporarily
const uniqueKey = `${message.id}-${idx}-${message.role}`;
return (
<div
key={message.id}
ref={isLatestUserMsg ? latestUserMsgRef : undefined}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div key={uniqueKey}>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
message.role === "user"
? "bg-primary text-primary-foreground dark:text-black!"
: "bg-muted"
ref={isLatestUserMsg ? latestUserMsgRef : undefined}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`prose dark:prose-invert ${message.role === "user" ? "dark:text-black!" : ""}`}
className={`max-w-[80%] rounded-lg px-4 py-2 ${
message.role === "user"
? "bg-primary text-primary-foreground dark:text-black!"
: "bg-muted"
}`}
>
<MemoizedMarkdown
id={message.id}
content={message.content}
/>
{/* Show loader before text appears or while streaming empty content */}
{isStreamingAssistant && !messageText ? (
<Loader size="default" text="Thinking..." />
) : (
<div
className={`prose dark:prose-invert ${message.role === "user" ? "dark:text-black!" : ""}`}
>
<MemoizedMarkdown
id={message.id}
content={messageText}
/>
</div>
)}
</div>
</div>
{/* Actions for assistant messages */}
{message.role === "assistant" &&
isLastMessage &&
messageText &&
status !== "streaming" && (
<div className="mt-2 flex justify-start">
<Actions className="max-w-[80%]">
<Action
label="Copy"
icon={<Copy className="h-3 w-3" />}
onClick={() => {
navigator.clipboard.writeText(messageText);
toast({
title: "Copied",
description: "Message copied to clipboard",
});
}}
/>
<Action
label="Retry"
icon={<RotateCcw className="h-3 w-3" />}
onClick={() => regenerate()}
/>
</Actions>
</div>
)}
</div>
);
})}
{status === "submitted" && (
<div className="flex justify-start">
<div className="bg-muted max-w-[80%] rounded-lg px-4 py-2">
<div className="animate-pulse">Thinking...</div>
{/* Show loader only if no assistant message exists yet */}
{(status === "submitted" || status === "streaming") &&
messages.length > 0 &&
messages[messages.length - 1].role === "user" && (
<div className="flex justify-start">
<div className="bg-muted max-w-[80%] rounded-lg px-4 py-2">
<Loader size="default" text="Thinking..." />
</div>
</div>
</div>
)}
)}
</div>
)}
<Form {...form}>
<form
onSubmit={onFormSubmit}
className="mx-auto flex w-full gap-2 px-4 pb-4 md:max-w-3xl md:pb-6"
className="mx-auto flex w-full gap-2 px-4 pb-16 md:max-w-3xl md:pb-16"
>
<div className="flex w-full items-end gap-2">
<div className="w-full flex-1">
@@ -346,12 +444,22 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
<CustomButton
type="submit"
ariaLabel={
status === "submitted" ? "Stop generation" : "Send message"
status === "streaming" || status === "submitted"
? "Generating response..."
: "Send message"
}
isDisabled={
status === "streaming" ||
status === "submitted" ||
!messageValue?.trim()
}
isDisabled={status === "submitted" || !messageValue?.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90 dark:bg-primary/90 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg p-2 disabled:opacity-50"
>
{status === "submitted" ? <span></span> : <span></span>}
{status === "streaming" || status === "submitted" ? (
<Square className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</CustomButton>
</div>
</form>

View File

@@ -1,9 +1,9 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { Spacer } from "@heroui/spacer";
import { zodResolver } from "@hookform/resolvers/zod";
import { SaveIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
@@ -21,8 +21,8 @@ import {
import { Form } from "@/components/ui/form";
const chatbotConfigSchema = z.object({
model: z.string().nonempty("Model selection is required"),
apiKey: z.string().nonempty("API Key is required").optional(),
model: z.string().min(1, "Model selection is required"),
apiKey: z.string().min(1, "API Key is required"),
businessContext: z
.string()
.max(1000, "Business context cannot exceed 1000 characters")
@@ -40,6 +40,7 @@ export const ChatbotConfig = ({
initialValues,
configExists: initialConfigExists,
}: ChatbotConfigClientProps) => {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [configExists, setConfigExists] = useState(initialConfigExists);
@@ -52,6 +53,16 @@ export const ChatbotConfig = ({
const onSubmit = async (data: FormValues) => {
if (isLoading) return;
// Validate API key: required for new config, or if changing an existing masked key
if (!configExists && (!data.apiKey || data.apiKey.trim().length === 0)) {
form.setError("apiKey", {
type: "manual",
message: "API Key is required",
});
return;
}
setIsLoading(true);
try {
const configData: any = {
@@ -74,6 +85,8 @@ export const ChatbotConfig = ({
configExists ? "updated" : "created"
} successfully`,
});
// Navigate to lighthouse chat page after successful save
router.push("/lighthouse");
} else {
throw new Error("Failed to save configuration");
}
@@ -101,48 +114,52 @@ export const ChatbotConfig = ({
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<Controller
name="model"
control={form.control}
render={({ field }) => (
<Select
label="Model"
placeholder="Select a model"
<div className="flex flex-col gap-6 md:flex-row">
<div className="md:w-1/3">
<Controller
name="model"
control={form.control}
render={({ field }) => (
<Select
label="Model"
placeholder="Select a model"
labelPlacement="inside"
value={field.value}
defaultSelectedKeys={[field.value]}
onChange={(e) => field.onChange(e.target.value)}
variant="bordered"
size="md"
isRequired
>
<SelectItem key="gpt-4o-2024-08-06">
GPT-4o (Recommended)
</SelectItem>
<SelectItem key="gpt-4o-mini-2024-07-18">
GPT-4o Mini
</SelectItem>
<SelectItem key="gpt-5-2025-08-07">GPT-5</SelectItem>
<SelectItem key="gpt-5-mini-2025-08-07">
GPT-5 Mini
</SelectItem>
</Select>
)}
/>
</div>
<div className="md:flex-1">
<CustomInput
control={form.control}
name="apiKey"
type="password"
label="API Key"
labelPlacement="inside"
value={field.value}
defaultSelectedKeys={[field.value]}
onChange={(e) => field.onChange(e.target.value)}
placeholder="Enter your API key"
variant="bordered"
size="md"
isRequired
>
<SelectItem key="gpt-4o-2024-08-06">
GPT-4o (Recommended)
</SelectItem>
<SelectItem key="gpt-4o-mini-2024-07-18">
GPT-4o Mini
</SelectItem>
<SelectItem key="gpt-5-2025-08-07">GPT-5</SelectItem>
<SelectItem key="gpt-5-mini-2025-08-07">GPT-5 Mini</SelectItem>
</Select>
)}
/>
<Spacer y={2} />
<CustomInput
control={form.control}
name="apiKey"
type="password"
label="API Key"
labelPlacement="inside"
placeholder="Enter your API key"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.apiKey}
/>
<Spacer y={2} />
isInvalid={!!form.formState.errors.apiKey}
/>
</div>
</div>
<CustomTextarea
control={form.control}
@@ -157,8 +174,6 @@ export const ChatbotConfig = ({
isInvalid={!!form.formState.errors.businessContext}
/>
<Spacer y={4} />
<div className="flex w-full justify-end">
<CustomButton
type="submit"

View File

@@ -0,0 +1,52 @@
"use client";
import { SpinnerIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
interface LoaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Size of the loader spinner
* @default "default"
*/
size?: "sm" | "default" | "lg";
/**
* Optional loading text to display
*/
text?: string;
className?: string;
ref?: React.Ref<HTMLDivElement>;
}
const loaderSizes = {
sm: 16,
default: 24,
lg: 32,
};
const Loader = ({
size = "default",
text,
className,
ref,
...props
}: LoaderProps) => {
return (
<div
ref={ref}
className={cn("flex items-center gap-2", className)}
role="status"
aria-live="polite"
aria-label={text || "Loading"}
{...props}
>
<SpinnerIcon
size={loaderSizes[size]}
className="text-prowler-green animate-spin"
/>
{text && <span className="text-muted-foreground text-sm">{text}</span>}
<span className="sr-only">{text || "Loading..."}</span>
</div>
);
};
export { Loader };

View File

@@ -16,7 +16,7 @@ import { Form } from "@/components/ui/form";
import { ApiError } from "@/types";
const addGroupSchema = z.object({
name: z.string().nonempty("Provider group name is required"),
name: z.string().min(1, "Provider group name is required"),
providers: z.array(z.string()).optional(),
roles: z.array(z.string()).optional(),
});

View File

@@ -18,7 +18,7 @@ import { Form } from "@/components/ui/form";
import { ApiError } from "@/types";
const editGroupSchema = z.object({
name: z.string().nonempty("Provider group name is required"),
name: z.string().min(1, "Provider group name is required"),
providers: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
roles: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
});

View File

@@ -25,7 +25,7 @@ import { ApiError, testConnectionFormSchema } from "@/types";
import { ProviderInfo } from "../..";
type FormValues = z.infer<typeof testConnectionFormSchema>;
type FormValues = z.input<typeof testConnectionFormSchema>;
export const TestConnectionForm = ({
searchParams,

View File

@@ -22,7 +22,7 @@ import { Form } from "@/components/ui/form";
import { getErrorMessage, permissionFormFields } from "@/lib";
import { addRoleFormSchema, ApiError } from "@/types";
type FormValues = z.infer<typeof addRoleFormSchema>;
type FormValues = z.input<typeof addRoleFormSchema>;
export const AddRoleForm = ({
groups,

View File

@@ -22,7 +22,7 @@ import { Form } from "@/components/ui/form";
import { getErrorMessage, permissionFormFields } from "@/lib";
import { ApiError, editRoleFormSchema } from "@/types";
type FormValues = z.infer<typeof editRoleFormSchema>;
type FormValues = z.input<typeof editRoleFormSchema>;
export const EditRoleForm = ({
roleId,

View File

@@ -1,4 +1,20 @@
[
{
"section": "dependencies",
"name": "@ai-sdk/langchain",
"from": "1.0.59",
"to": "1.0.59",
"strategy": "installed",
"generatedAt": "2025-10-01T11:13:12.025Z"
},
{
"section": "dependencies",
"name": "@ai-sdk/react",
"from": "2.0.59",
"to": "2.0.59",
"strategy": "installed",
"generatedAt": "2025-10-01T11:13:12.025Z"
},
{
"section": "dependencies",
"name": "@heroui/react",
@@ -11,9 +27,9 @@
"section": "dependencies",
"name": "@hookform/resolvers",
"from": "3.10.0",
"to": "3.10.0",
"to": "5.2.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-01T15:09:44.056Z"
},
{
"section": "dependencies",
@@ -171,9 +187,9 @@
"section": "dependencies",
"name": "ai",
"from": "4.3.16",
"to": "4.3.16",
"to": "5.0.59",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-01T10:03:22.788Z"
},
{
"section": "dependencies",
@@ -395,17 +411,17 @@
"section": "dependencies",
"name": "zod",
"from": "3.25.73",
"to": "3.25.73",
"to": "4.1.11",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-01T09:40:25.207Z"
},
{
"section": "dependencies",
"name": "zustand",
"from": "4.5.7",
"to": "4.5.7",
"to": "5.0.8",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-01T09:40:25.207Z"
},
{
"section": "devDependencies",

View File

@@ -14,12 +14,6 @@ import {
ProviderType,
} from "@/types";
type CredentialsFormData = {
providerId: string;
providerType: ProviderType;
[key: string]: any;
};
type UseCredentialsFormProps = {
providerType: ProviderType;
providerId: string;
@@ -56,7 +50,7 @@ export const useCredentialsForm = ({
const formSchema = getFormSchema();
// Get default values based on provider type and via parameter
const getDefaultValues = (): CredentialsFormData => {
const getDefaultValues = () => {
const baseDefaults = {
[ProviderCredentialFields.PROVIDER_ID]: providerId,
[ProviderCredentialFields.PROVIDER_TYPE]: providerType,
@@ -152,7 +146,7 @@ export const useCredentialsForm = ({
}
};
const form = useForm<CredentialsFormData>({
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: getDefaultValues(),
});
@@ -170,7 +164,7 @@ export const useCredentialsForm = ({
};
// Form submit handler
const handleSubmit = async (values: CredentialsFormData) => {
const handleSubmit = async (values: Record<string, unknown>) => {
const formData = new FormData();
// Filter out empty values first, then append all remaining values

View File

@@ -1,4 +1,5 @@
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import {
getLighthouseCheckDetails,
@@ -7,12 +8,13 @@ import {
import { checkDetailsSchema, checkSchema } from "@/types/lighthouse";
export const getProviderChecksTool = tool(
async ({ providerType, service, severity, compliances }) => {
async (input) => {
const typedInput = input as z.infer<typeof checkSchema>;
const checks = await getLighthouseProviderChecks({
providerType,
service: service || [],
severity: severity || [],
compliances: compliances || [],
providerType: typedInput.providerType,
service: typedInput.service || [],
severity: typedInput.severity || [],
compliances: typedInput.compliances || [],
});
return checks;
},
@@ -25,8 +27,11 @@ export const getProviderChecksTool = tool(
);
export const getProviderCheckDetailsTool = tool(
async ({ checkId }: { checkId: string }) => {
const check = await getLighthouseCheckDetails({ checkId });
async (input) => {
const typedInput = input as z.infer<typeof checkDetailsSchema>;
const check = await getLighthouseCheckDetails({
checkId: typedInput.checkId,
});
return check;
},
{

View File

@@ -1,4 +1,5 @@
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { getLighthouseComplianceFrameworks } from "@/actions/lighthouse/complianceframeworks";
import {
@@ -12,14 +13,15 @@ import {
} from "@/types/lighthouse";
export const getCompliancesOverviewTool = tool(
async ({ scanId, fields, filters, page, pageSize, sort }) => {
async (input) => {
const typedInput = input as z.infer<typeof getCompliancesOverviewSchema>;
return await getLighthouseCompliancesOverview({
scanId,
fields,
filters,
page,
pageSize,
sort,
scanId: typedInput.scanId,
fields: typedInput.fields,
filters: typedInput.filters,
page: typedInput.page,
pageSize: typedInput.pageSize,
sort: typedInput.sort,
});
},
{
@@ -31,8 +33,9 @@ export const getCompliancesOverviewTool = tool(
);
export const getComplianceFrameworksTool = tool(
async ({ providerType }) => {
return await getLighthouseComplianceFrameworks(providerType);
async (input) => {
const typedInput = input as z.infer<typeof getComplianceFrameworksSchema>;
return await getLighthouseComplianceFrameworks(typedInput.providerType);
},
{
name: "getComplianceFrameworks",
@@ -43,8 +46,12 @@ export const getComplianceFrameworksTool = tool(
);
export const getComplianceOverviewTool = tool(
async ({ complianceId, fields }) => {
return await getLighthouseComplianceOverview({ complianceId, fields });
async (input) => {
const typedInput = input as z.infer<typeof getComplianceOverviewSchema>;
return await getLighthouseComplianceOverview({
complianceId: typedInput.complianceId,
fields: typedInput.fields,
});
},
{
name: "getComplianceOverview",

View File

@@ -1,11 +1,19 @@
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { getFindings, getMetadataInfo } from "@/actions/findings";
import { getFindingsSchema, getMetadataInfoSchema } from "@/types/lighthouse";
export const getFindingsTool = tool(
async ({ page, pageSize, query, sort, filters }) => {
return await getFindings({ page, pageSize, query, sort, filters });
async (input) => {
const typedInput = input as z.infer<typeof getFindingsSchema>;
return await getFindings({
page: typedInput.page,
pageSize: typedInput.pageSize,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},
{
name: "getFindings",
@@ -16,8 +24,13 @@ export const getFindingsTool = tool(
);
export const getMetadataInfoTool = tool(
async ({ query, sort, filters }) => {
return await getMetadataInfo({ query, sort, filters });
async (input) => {
const typedInput = input as z.infer<typeof getMetadataInfoSchema>;
return await getMetadataInfo({
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},
{
name: "getMetadataInfo",

View File

@@ -1,4 +1,5 @@
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import {
getFindingsBySeverity,
@@ -12,8 +13,14 @@ import {
} from "@/types/lighthouse";
export const getProvidersOverviewTool = tool(
async ({ page, query, sort, filters }) => {
return await getProvidersOverview({ page, query, sort, filters });
async (input) => {
const typedInput = input as z.infer<typeof getProvidersOverviewSchema>;
return await getProvidersOverview({
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},
{
name: "getProvidersOverview",
@@ -24,8 +31,14 @@ export const getProvidersOverviewTool = tool(
);
export const getFindingsByStatusTool = tool(
async ({ page, query, sort, filters }) => {
return await getFindingsByStatus({ page, query, sort, filters });
async (input) => {
const typedInput = input as z.infer<typeof getFindingsByStatusSchema>;
return await getFindingsByStatus({
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},
{
name: "getFindingsByStatus",
@@ -36,8 +49,14 @@ export const getFindingsByStatusTool = tool(
);
export const getFindingsBySeverityTool = tool(
async ({ page, query, sort, filters }) => {
return await getFindingsBySeverity({ page, query, sort, filters });
async (input) => {
const typedInput = input as z.infer<typeof getFindingsBySeveritySchema>;
return await getFindingsBySeverity({
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},
{
name: "getFindingsBySeverity",

View File

@@ -1,15 +1,17 @@
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { getProvider, getProviders } from "@/actions/providers";
import { getProviderSchema, getProvidersSchema } from "@/types/lighthouse";
export const getProvidersTool = tool(
async ({ page, query, sort, filters }) => {
async (input) => {
const typedInput = input as z.infer<typeof getProvidersSchema>;
return await getProviders({
page: page,
query: query,
sort: sort,
filters: filters,
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},
{
@@ -21,9 +23,10 @@ export const getProvidersTool = tool(
);
export const getProviderTool = tool(
async ({ id }) => {
async (input) => {
const typedInput = input as z.infer<typeof getProviderSchema>;
const formData = new FormData();
formData.append("id", id);
formData.append("id", typedInput.id);
return await getProvider(formData);
},
{

View File

@@ -1,4 +1,5 @@
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import {
getLighthouseLatestResources,
@@ -7,9 +8,19 @@ import {
} from "@/actions/lighthouse/resources";
import { getResourceSchema, getResourcesSchema } from "@/types/lighthouse";
const parseResourcesInput = (input: unknown) =>
input as z.infer<typeof getResourcesSchema>;
export const getResourcesTool = tool(
async ({ page, query, sort, filters, fields }) => {
return await getLighthouseResources({ page, query, sort, filters, fields });
async (input) => {
const typedInput = parseResourcesInput(input);
return await getLighthouseResources({
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
fields: typedInput.fields,
});
},
{
name: "getResources",
@@ -20,8 +31,13 @@ export const getResourcesTool = tool(
);
export const getResourceTool = tool(
async ({ id, fields, include }) => {
return await getLighthouseResourceById({ id, fields, include });
async (input) => {
const typedInput = input as z.infer<typeof getResourceSchema>;
return await getLighthouseResourceById({
id: typedInput.id,
fields: typedInput.fields,
include: typedInput.include,
});
},
{
name: "getResource",
@@ -32,13 +48,14 @@ export const getResourceTool = tool(
);
export const getLatestResourcesTool = tool(
async ({ page, query, sort, filters, fields }) => {
async (input) => {
const typedInput = parseResourcesInput(input);
return await getLighthouseLatestResources({
page,
query,
sort,
filters,
fields,
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
fields: typedInput.fields,
});
},
{

View File

@@ -1,11 +1,18 @@
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { getRoleInfoById, getRoles } from "@/actions/roles";
import { getRoleSchema, getRolesSchema } from "@/types/lighthouse";
export const getRolesTool = tool(
async ({ page, query, sort, filters }) => {
return await getRoles({ page, query, sort, filters });
async (input) => {
const typedInput = input as z.infer<typeof getRolesSchema>;
return await getRoles({
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},
{
name: "getRoles",
@@ -15,8 +22,9 @@ export const getRolesTool = tool(
);
export const getRoleTool = tool(
async ({ id }) => {
return await getRoleInfoById(id);
async (input) => {
const typedInput = input as z.infer<typeof getRoleSchema>;
return await getRoleInfoById(typedInput.id);
},
{
name: "getRole",

View File

@@ -1,11 +1,18 @@
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { getScan, getScans } from "@/actions/scans";
import { getScanSchema, getScansSchema } from "@/types/lighthouse";
export const getScansTool = tool(
async ({ page, query, sort, filters }) => {
const scans = await getScans({ page, query, sort, filters });
async (input) => {
const typedInput = input as z.infer<typeof getScansSchema>;
const scans = await getScans({
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
return scans;
},
@@ -18,8 +25,9 @@ export const getScansTool = tool(
);
export const getScanTool = tool(
async ({ id }) => {
return await getScan(id);
async (input) => {
const typedInput = input as z.infer<typeof getScanSchema>;
return await getScan(typedInput.id);
},
{
name: "getScan",

View File

@@ -4,9 +4,17 @@ import { z } from "zod";
import { getUserInfo, getUsers } from "@/actions/users/users";
import { getUsersSchema } from "@/types/lighthouse";
const emptySchema = z.object({});
export const getUsersTool = tool(
async ({ page, query, sort, filters }) => {
return await getUsers({ page, query, sort, filters });
async (input) => {
const typedInput = input as z.infer<typeof getUsersSchema>;
return await getUsers({
page: typedInput.page,
query: typedInput.query,
sort: typedInput.sort,
filters: typedInput.filters,
});
},
{
name: "getUsers",
@@ -17,13 +25,13 @@ export const getUsersTool = tool(
);
export const getMyProfileInfoTool = tool(
async () => {
async (_input) => {
return await getUserInfo();
},
{
name: "getMyProfileInfo",
description:
"Fetches detailed information about the current authenticated user.",
schema: z.object({}),
schema: emptySchema,
},
);

View File

@@ -4,7 +4,7 @@ import {
ChatMessage,
HumanMessage,
} from "@langchain/core/messages";
import type { Message } from "ai";
import type { UIMessage } from "ai";
import type { ModelParams } from "@/types/lighthouse";
@@ -15,37 +15,22 @@ import type { ModelParams } from "@/types/lighthouse";
* @returns The converted LangChain message.
*/
export const convertVercelMessageToLangChainMessage = (
message: Message,
message: UIMessage,
): BaseMessage => {
// Extract text content from message parts
const content =
message.parts
?.filter((p) => p.type === "text")
.map((p) => ("text" in p ? p.text : ""))
.join("") || "";
switch (message.role) {
case "user":
return new HumanMessage({ content: message.content });
return new HumanMessage({ content });
case "assistant":
return new AIMessage({ content: message.content });
return new AIMessage({ content });
default:
return new ChatMessage({ content: message.content, role: message.role });
}
};
/**
* Converts a LangChain message to a Vercel message.
* @param message - The message to convert.
* @returns The converted Vercel message.
*/
export const convertLangChainMessageToVercelMessage = (
message: BaseMessage,
) => {
switch (message._getType()) {
case "human":
return { content: message.content, role: "user" };
case "ai":
return {
content: message.content,
role: "assistant",
tool_calls: (message as AIMessage).tool_calls,
};
default:
return { content: message.content, role: message._getType() };
return new ChatMessage({ content, role: message.role });
}
};

344
ui/package-lock.json generated
View File

@@ -9,8 +9,10 @@
"version": "0.0.1",
"hasInstallScript": true,
"dependencies": {
"@ai-sdk/langchain": "1.0.59",
"@ai-sdk/react": "2.0.59",
"@heroui/react": "2.8.4",
"@hookform/resolvers": "3.10.0",
"@hookform/resolvers": "5.2.2",
"@langchain/core": "0.3.77",
"@langchain/langgraph": "0.4.9",
"@langchain/langgraph-supervisor": "0.0.20",
@@ -30,7 +32,7 @@
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@types/js-yaml": "4.0.9",
"ai": "4.3.16",
"ai": "5.0.59",
"alert": "6.0.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -58,8 +60,8 @@
"tailwind-merge": "3.3.1",
"tailwindcss-animate": "1.0.7",
"uuid": "11.1.0",
"zod": "3.25.73",
"zustand": "4.5.7"
"zod": "4.1.11",
"zustand": "5.0.8"
},
"devDependencies": {
"@iconify/react": "5.2.1",
@@ -94,10 +96,38 @@
"typescript": "5.5.4"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "1.0.32",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.32.tgz",
"integrity": "sha512-TQRIM63EI/ccJBc7RxeB8nq/CnGNnyl7eu5stWdLwL41stkV5skVeZJe0QRvFbaOrwCkgUVE0yrUqJi4tgDC1A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.10"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/langchain": {
"version": "1.0.59",
"resolved": "https://registry.npmjs.org/@ai-sdk/langchain/-/langchain-1.0.59.tgz",
"integrity": "sha512-bElhcuSSIxJ3ffgtS1wlYO8q0WD+eYkR32+Tfmcj1ni0lpoIFYEmFqQvunKiMPvtwNYqhFg6OEclcqsz2qBobA==",
"license": "Apache-2.0",
"dependencies": {
"ai": "5.0.59"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz",
"integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz",
"integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
@@ -107,30 +137,30 @@
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz",
"integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==",
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.10.tgz",
"integrity": "sha512-T1gZ76gEIwffep6MWI0QNy9jgoybUHE7TRaHB5k54K8mF91ciGFlbtCGxDYhMH3nCRergKwYFIDeFF0hJSIQHQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"nanoid": "^3.3.8",
"secure-json-parse": "^2.7.0"
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.23.8"
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/react": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz",
"integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==",
"version": "2.0.59",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.59.tgz",
"integrity": "sha512-whuMGkiRugJIQNJEIpt3gv53EsvQ6ub7Qh19ujbUcvXZKwoCCZlEGmUqEJqvPVRm95d4uYXFxEk0wqpxOpsm6g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "2.2.8",
"@ai-sdk/ui-utils": "1.2.11",
"@ai-sdk/provider-utils": "3.0.10",
"ai": "5.0.59",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
@@ -139,7 +169,7 @@
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.23.8"
"zod": "^3.25.76 || ^4.1.8"
},
"peerDependenciesMeta": {
"zod": {
@@ -147,23 +177,6 @@
}
}
},
"node_modules/@ai-sdk/ui-utils": {
"version": "1.2.11",
"resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz",
"integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"@ai-sdk/provider-utils": "2.2.8",
"zod-to-json-schema": "^3.24.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.23.8"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -2737,12 +2750,15 @@
}
},
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.0.0"
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanwhocodes/config-array": {
@@ -3550,6 +3566,24 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/core/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@langchain/core/node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/@langchain/langgraph": {
"version": "0.4.9",
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.4.9.tgz",
@@ -3673,6 +3707,15 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/langgraph-supervisor/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@langchain/langgraph/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
@@ -3686,6 +3729,15 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/langgraph/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@langchain/openai": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.5.18.tgz",
@@ -3703,6 +3755,36 @@
"@langchain/core": ">=0.3.58 <0.4.0"
}
},
"node_modules/@langchain/openai/node_modules/openai": {
"version": "5.23.2",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz",
"integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@langchain/openai/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.1.tgz",
@@ -3726,6 +3808,24 @@
"node": ">=18"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/@mswjs/interceptors": {
"version": "0.39.7",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.7.tgz",
@@ -7075,6 +7175,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
@@ -7590,12 +7702,6 @@
"@types/ms": "*"
}
},
"node_modules/@types/diff-match-patch": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -8218,29 +8324,21 @@
}
},
"node_modules/ai": {
"version": "4.3.16",
"resolved": "https://registry.npmjs.org/ai/-/ai-4.3.16.tgz",
"integrity": "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g==",
"version": "5.0.59",
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.59.tgz",
"integrity": "sha512-SuAFxKXt2Ha9FiXB3gaOITkOg9ek/3QNVatGVExvTT4gNXc+hJpuNe1dmuwf6Z5Op4fzc8wdbsrYP27ZCXBzlw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"@ai-sdk/provider-utils": "2.2.8",
"@ai-sdk/react": "1.2.12",
"@ai-sdk/ui-utils": "1.2.11",
"@opentelemetry/api": "1.9.0",
"jsondiffpatch": "0.6.0"
"@ai-sdk/gateway": "1.0.32",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.10",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": {
@@ -9681,12 +9779,6 @@
"node": ">=0.3.1"
}
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
"license": "Apache-2.0"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -12640,35 +12732,6 @@
"node": ">=6"
}
},
"node_modules/jsondiffpatch": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz",
"integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==",
"license": "MIT",
"dependencies": {
"@types/diff-match-patch": "^1.0.36",
"chalk": "^5.3.0",
"diff-match-patch": "^1.0.5"
},
"bin": {
"jsondiffpatch": "bin/jsondiffpatch.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/jsondiffpatch/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@@ -12726,9 +12789,9 @@
}
},
"node_modules/langsmith": {
"version": "0.3.69",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.69.tgz",
"integrity": "sha512-YKzu92YAP2o+d+1VmR38xqFX0RIRLKYj1IqdflVEY83X0FoiVlrWO3xDLXgnu7vhZ2N2M6jx8VO9fVF8yy9gHA==",
"version": "0.3.71",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.71.tgz",
"integrity": "sha512-xl00JZso7J3OaurUQ+seT2qRJ34OGZXYAvCYj3vNC3TB+JOcdcYZ1uLvENqOloKB8VCiADh1eZ0FG3Cj/cy2FQ==",
"license": "MIT",
"dependencies": {
"@types/uuid": "^10.0.0",
@@ -15030,27 +15093,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openai": {
"version": "5.23.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.23.0.tgz",
"integrity": "sha512-Cfq155NHzI7VWR67LUNJMIgPZy2oSh7Fld/OKhxq648BiUjELAvcge7g30xJ6vAfwwXf6TVK0KKuN+3nmIJG/A==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -16538,12 +16580,6 @@
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@@ -16875,6 +16911,24 @@
"node": ">=6"
}
},
"node_modules/shadcn/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/shadcn/node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
@@ -18669,38 +18723,27 @@
}
},
"node_modules/zod": {
"version": "3.25.73",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.73.tgz",
"integrity": "sha512-fuIKbQAWQl22Ba5d1quwEETQYjqnpKVyZIWAhbnnHgnDd3a+z4YgEfkI5SZ2xMELnLAXo/Flk2uXgysZNf0uaA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz",
"integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=16.8"
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -18711,6 +18754,9 @@
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},

View File

@@ -23,8 +23,10 @@
"test:e2e:install": "playwright install"
},
"dependencies": {
"@ai-sdk/langchain": "1.0.59",
"@ai-sdk/react": "2.0.59",
"@heroui/react": "2.8.4",
"@hookform/resolvers": "3.10.0",
"@hookform/resolvers": "5.2.2",
"@langchain/core": "0.3.77",
"@langchain/langgraph": "0.4.9",
"@langchain/langgraph-supervisor": "0.0.20",
@@ -44,7 +46,7 @@
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@types/js-yaml": "4.0.9",
"ai": "4.3.16",
"ai": "5.0.59",
"alert": "6.0.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -72,8 +74,8 @@
"tailwind-merge": "3.3.1",
"tailwindcss-animate": "1.0.7",
"uuid": "11.1.0",
"zod": "3.25.73",
"zustand": "4.5.7"
"zod": "4.1.11",
"zustand": "5.0.8"
},
"devDependencies": {
"@iconify/react": "5.2.1",

View File

@@ -35,7 +35,12 @@
@layer utilities {
/* Hide scrollbar */
.no-scrollbar {
scrollbar-width: none;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.checkbox-update {

View File

@@ -48,7 +48,10 @@ test.describe("Login Flow", () => {
test("should handle empty form submission", async ({ page }) => {
// Submit empty form
await submitLoginForm(page);
// Should show both email and password validation errors
await verifyLoginError(page, ERROR_MESSAGES.INVALID_EMAIL);
await verifyLoginError(page, ERROR_MESSAGES.PASSWORD_REQUIRED);
// Verify we're still on login page
await expect(page).toHaveURL(URLS.LOGIN);
@@ -63,6 +66,18 @@ test.describe("Login Flow", () => {
await expect(page).toHaveURL(URLS.LOGIN);
});
test("should require password when email is filled", async ({ page }) => {
// Fill only email, leave password empty
await page.getByLabel("Email").fill(TEST_CREDENTIALS.VALID.email);
await submitLoginForm(page);
// Should show password required error
await verifyLoginError(page, ERROR_MESSAGES.PASSWORD_REQUIRED);
// Verify we're still on login page
await expect(page).toHaveURL(URLS.LOGIN);
});
test("should toggle SAML SSO mode", async ({ page }) => {
// Toggle to SAML mode
await toggleSamlMode(page);

View File

@@ -3,6 +3,7 @@ import { Page, expect } from "@playwright/test";
export const ERROR_MESSAGES = {
INVALID_CREDENTIALS: "Invalid email or password",
INVALID_EMAIL: "Please enter a valid email address.",
PASSWORD_REQUIRED: "Password is required.",
} as const;
export const URLS = {

View File

@@ -64,54 +64,66 @@ export const validatePassword = () => {
);
};
const baseAuthSchema = z.object({
email: z
.email({ message: "Please enter a valid email address." })
.trim()
.toLowerCase(),
password: z.string(),
isSamlMode: z.boolean().optional(),
});
export const signInSchema = baseAuthSchema
.extend({
password: z.string().min(1, { message: "Password is required." }),
})
.refine(
(data) => {
// If SAML mode, password is not required
if (data.isSamlMode) return true;
// Otherwise, password must be filled
return data.password.length > 0;
},
{
message: "Password is required.",
path: ["password"],
},
);
export const signUpSchema = baseAuthSchema
.extend({
name: z
.string()
.min(3, {
message: "The name must be at least 3 characters.",
})
.max(20),
password: validatePassword(),
confirmPassword: z.string().min(1, {
message: "Please confirm your password.",
}),
company: z.string().optional(),
invitationToken: z.string().optional(),
termsAndConditions:
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"
? z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions.",
})
: z.boolean().optional(),
})
.refine(
(data) => {
if (data.isSamlMode) return true;
return data.password === data.confirmPassword;
},
{
message: "The password must match",
path: ["confirmPassword"],
},
);
export const authFormSchema = (type: string) =>
z
.object({
// Sign Up
company:
type === "sign-in" ? z.string().optional() : z.string().optional(),
name:
type === "sign-in"
? z.string().optional()
: z
.string()
.min(3, {
message: "The name must be at least 3 characters.",
})
.max(20),
confirmPassword:
type === "sign-in"
? z.string().optional()
: z.string().min(1, {
message: "Please confirm your password.",
}),
invitationToken:
type === "sign-in" ? z.string().optional() : z.string().optional(),
type === "sign-in" ? signInSchema : signUpSchema;
termsAndConditions:
type === "sign-in" || process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true"
? z.boolean().optional()
: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions.",
}),
// Fields for Sign In and Sign Up
// Trim and normalize email, and provide consistent message
email: z
.string()
.trim()
.toLowerCase()
.email({ message: "Please enter a valid email address." }),
password: type === "sign-in" ? z.string() : validatePassword(),
isSamlMode: z.boolean().optional(),
})
.refine(
(data) => {
if (data.isSamlMode) return true;
return type === "sign-in" || data.password === data.confirmPassword;
},
{
message: "The password must match",
path: ["confirmPassword"],
},
);
export type SignInFormData = z.infer<typeof signInSchema>;
export type SignUpFormData = z.infer<typeof signUpSchema>;

View File

@@ -72,7 +72,7 @@ export const awsCredentialsTypeSchema = z.object({
export const addProviderFormSchema = z
.object({
providerType: z.enum(PROVIDER_TYPES, {
required_error: "Please select a provider type",
error: "Please select a provider type",
}),
})
.and(
@@ -125,53 +125,53 @@ export const addCredentialsFormSchema = (
? {
[ProviderCredentialFields.AWS_ACCESS_KEY_ID]: z
.string()
.nonempty("AWS Access Key ID is required"),
.min(1, "AWS Access Key ID is required"),
[ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: z
.string()
.nonempty("AWS Secret Access Key is required"),
.min(1, "AWS Secret Access Key is required"),
[ProviderCredentialFields.AWS_SESSION_TOKEN]: z.string().optional(),
}
: providerType === "azure"
? {
[ProviderCredentialFields.CLIENT_ID]: z
.string()
.nonempty("Client ID is required"),
.min(1, "Client ID is required"),
[ProviderCredentialFields.CLIENT_SECRET]: z
.string()
.nonempty("Client Secret is required"),
.min(1, "Client Secret is required"),
[ProviderCredentialFields.TENANT_ID]: z
.string()
.nonempty("Tenant ID is required"),
.min(1, "Tenant ID is required"),
}
: providerType === "gcp"
? {
[ProviderCredentialFields.CLIENT_ID]: z
.string()
.nonempty("Client ID is required"),
.min(1, "Client ID is required"),
[ProviderCredentialFields.CLIENT_SECRET]: z
.string()
.nonempty("Client Secret is required"),
.min(1, "Client Secret is required"),
[ProviderCredentialFields.REFRESH_TOKEN]: z
.string()
.nonempty("Refresh Token is required"),
.min(1, "Refresh Token is required"),
}
: providerType === "kubernetes"
? {
[ProviderCredentialFields.KUBECONFIG_CONTENT]: z
.string()
.nonempty("Kubeconfig Content is required"),
.min(1, "Kubeconfig Content is required"),
}
: providerType === "m365"
? {
[ProviderCredentialFields.CLIENT_ID]: z
.string()
.nonempty("Client ID is required"),
.min(1, "Client ID is required"),
[ProviderCredentialFields.CLIENT_SECRET]: z
.string()
.nonempty("Client Secret is required"),
.min(1, "Client Secret is required"),
[ProviderCredentialFields.TENANT_ID]: z
.string()
.nonempty("Tenant ID is required"),
.min(1, "Tenant ID is required"),
[ProviderCredentialFields.USER]: z.string().optional(),
[ProviderCredentialFields.PASSWORD]: z.string().optional(),
}
@@ -259,7 +259,7 @@ export const addCredentialsRoleFormSchema = (providerType: string) =>
[ProviderCredentialFields.PROVIDER_TYPE]: z.string(),
[ProviderCredentialFields.ROLE_ARN]: z
.string()
.nonempty("AWS Role ARN is required"),
.min(1, "AWS Role ARN is required"),
[ProviderCredentialFields.EXTERNAL_ID]: z.string().optional(),
[ProviderCredentialFields.AWS_ACCESS_KEY_ID]: z.string().optional(),
[ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: z
@@ -347,8 +347,8 @@ export const editProviderFormSchema = (currentAlias: string) =>
});
export const editInviteFormSchema = z.object({
invitationId: z.string().uuid(),
invitationEmail: z.string().email(),
invitationId: z.uuid(),
invitationEmail: z.email(),
expires_at: z.string().optional(),
role: z.string().optional(),
});
@@ -360,10 +360,7 @@ export const editUserFormSchema = () =>
.min(3, { message: "The name must have at least 3 characters." })
.max(150, { message: "The name cannot exceed 150 characters." })
.optional(),
email: z
.string()
.email({ message: "Please enter a valid email address." })
.optional(),
email: z.email({ error: "Please enter a valid email address." }).optional(),
password: z
.string()
.min(1, { message: "The password cannot be empty." })

View File

@@ -289,7 +289,7 @@ export const editSecurityHubIntegrationFormSchema =
export const jiraIntegrationFormSchema = z.object({
integration_type: z.literal("jira"),
domain: z.string().min(1, "Domain is required"),
user_mail: z.string().email("Invalid email format"),
user_mail: z.email({ error: "Invalid email format" }),
api_token: z.string().min(1, "API token is required"),
enabled: z.boolean().default(true),
});
@@ -297,7 +297,7 @@ export const jiraIntegrationFormSchema = z.object({
export const editJiraIntegrationFormSchema = z.object({
integration_type: z.literal("jira"),
domain: z.string().min(1, "Domain is required").optional(),
user_mail: z.string().email("Invalid email format").optional(),
user_mail: z.email({ error: "Invalid email format" }).optional(),
api_token: z.string().min(1, "API token is required").optional(),
});

View File

@@ -40,9 +40,9 @@ export const getFindingsSchema = z.object({
query: z
.string()
.describe("The query to search for. Default is empty string."),
sort: z
.string(sortFieldsEnum)
.describe("The sort order to use. Default is empty string."),
sort: sortFieldsEnum.describe(
"The sort order to use. Default is empty string.",
),
filters: z
.object({
"filter[check_id]": z

View File

@@ -32,9 +32,9 @@ export const getScansSchema = z.object({
query: z
.string()
.describe("The query to search for. Default is empty string."),
sort: z
.string(getScansSortEnum)
.describe("The sort order to use. Default is empty string."),
sort: getScansSortEnum.describe(
"The sort order to use. Default is empty string.",
),
filters: z
.object({
// Date filters