mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
chore(ui): upgrade zod v4, zustand v5, and ai sdk v5 (#8801)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
ui/components/auth/oss/auth-divider.tsx
Normal file
11
ui/components/auth/oss/auth-divider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
ui/components/auth/oss/auth-footer-link.tsx
Normal file
22
ui/components/auth/oss/auth-footer-link.tsx
Normal 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}
|
||||
<CustomLink size="base" href={href} target="_self">
|
||||
{linkText}
|
||||
</CustomLink>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
<CustomLink
|
||||
href="https://prowler.com/terms-of-service/"
|
||||
size="sm"
|
||||
>
|
||||
Terms of Service
|
||||
</CustomLink>
|
||||
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?
|
||||
<CustomLink size="base" href="/sign-up" target="_self">
|
||||
Sign up
|
||||
</CustomLink>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-small text-center">
|
||||
Already have an account?
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
37
ui/components/auth/oss/auth-layout.tsx
Normal file
37
ui/components/auth/oss/auth-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
194
ui/components/auth/oss/sign-in-form.tsx
Normal file
194
ui/components/auth/oss/sign-in-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
252
ui/components/auth/oss/sign-up-form.tsx
Normal file
252
ui/components/auth/oss/sign-up-form.tsx
Normal 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
|
||||
<CustomLink
|
||||
href="https://prowler.com/terms-of-service/"
|
||||
size="sm"
|
||||
>
|
||||
Terms of Service
|
||||
</CustomLink>
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
81
ui/components/lighthouse/actions.tsx
Normal file
81
ui/components/lighthouse/actions.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
52
ui/components/lighthouse/loader.tsx
Normal file
52
ui/components/lighthouse/loader.tsx
Normal 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 };
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
344
ui/package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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." })
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user