From a0267daf409653c1f183a31ede087fe2238b5aaa Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:22:43 +0530 Subject: [PATCH 1/9] feat(*): Admin flow initiate --- app/(main)/settings/onboarding/page.tsx | 228 ++++++++++++++++++ app/api/onboard/route.ts | 17 ++ .../organization/[orgId]/projects/route.ts | 21 ++ app/api/organization/route.ts | 20 ++ app/api/users/me/route.ts | 14 ++ app/components/Button.tsx | 65 +++++ app/components/Field.tsx | 59 +++++ app/components/Sidebar.tsx | 19 +- app/components/icons/common/ArrowLeftIcon.tsx | 21 ++ app/components/icons/common/EyeIcon.tsx | 26 ++ app/components/icons/common/EyeOffIcon.tsx | 21 ++ app/components/icons/index.tsx | 3 + app/components/index.ts | 5 + .../settings/onboarding/OnboardingForm.tsx | 166 +++++++++++++ .../settings/onboarding/OnboardingSuccess.tsx | 86 +++++++ .../settings/onboarding/OrganizationList.tsx | 74 ++++++ .../settings/onboarding/ProjectList.tsx | 108 +++++++++ .../settings/onboarding/StepIndicator.tsx | 38 +++ app/components/settings/onboarding/index.ts | 5 + app/lib/context/AuthContext.tsx | 34 ++- app/lib/types/onboarding.ts | 60 +++++ app/lib/utils.ts | 12 + 22 files changed, 1096 insertions(+), 6 deletions(-) create mode 100644 app/(main)/settings/onboarding/page.tsx create mode 100644 app/api/onboard/route.ts create mode 100644 app/api/organization/[orgId]/projects/route.ts create mode 100644 app/api/organization/route.ts create mode 100644 app/api/users/me/route.ts create mode 100644 app/components/Button.tsx create mode 100644 app/components/Field.tsx create mode 100644 app/components/icons/common/ArrowLeftIcon.tsx create mode 100644 app/components/icons/common/EyeIcon.tsx create mode 100644 app/components/icons/common/EyeOffIcon.tsx create mode 100644 app/components/index.ts create mode 100644 app/components/settings/onboarding/OnboardingForm.tsx create mode 100644 app/components/settings/onboarding/OnboardingSuccess.tsx create mode 100644 app/components/settings/onboarding/OrganizationList.tsx create mode 100644 app/components/settings/onboarding/ProjectList.tsx create mode 100644 app/components/settings/onboarding/StepIndicator.tsx create mode 100644 app/components/settings/onboarding/index.ts create mode 100644 app/lib/types/onboarding.ts diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx new file mode 100644 index 0000000..f38ca9b --- /dev/null +++ b/app/(main)/settings/onboarding/page.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import Sidebar from "@/app/components/Sidebar"; +import PageHeader from "@/app/components/PageHeader"; +import { useApp } from "@/app/lib/context/AppContext"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { usePaginatedList } from "@/app/hooks/usePaginatedList"; +import { useInfiniteScroll } from "@/app/hooks/useInfiniteScroll"; +import { + OnboardingForm, + OnboardingSuccess, + OrganizationList, + ProjectList, + StepIndicator, +} from "@/app/components/settings/onboarding"; +import { + Organization, + Project, + ProjectListResponse, + OnboardResponseData, +} from "@/app/lib/types/onboarding"; +import { apiFetch } from "@/app/lib/apiClient"; +import { colors } from "@/app/lib/colors"; +import { ArrowLeftIcon, RefreshIcon } from "@/app/components/icons"; + +type View = "loading" | "list" | "projects" | "form" | "success"; + +export default function OnboardingPage() { + const router = useRouter(); + const { sidebarCollapsed } = useApp(); + const { activeKey, currentUser, isHydrated } = useAuth(); + const [view, setView] = useState("loading"); + const [selectedOrg, setSelectedOrg] = useState(null); + const [projects, setProjects] = useState([]); + const [isLoadingProjects, setIsLoadingProjects] = useState(false); + const [onboardData, setOnboardData] = useState( + null, + ); + + const { + items: organizations, + isLoading: isLoadingOrgs, + isLoadingMore, + hasMore, + loadMore, + } = usePaginatedList({ + endpoint: "/api/organization", + limit: 10, + }); + + const scrollRef = useInfiniteScroll({ + onLoadMore: loadMore, + hasMore, + isLoading: isLoadingOrgs || isLoadingMore, + }); + + useEffect(() => { + if (isLoadingOrgs) { + setView("loading"); + return; + } + if (view === "loading") { + setView(organizations.length > 0 ? "list" : "form"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingOrgs, organizations.length]); + + // Redirect if no API key or not a superuser + useEffect(() => { + if (!isHydrated) return; + if (!activeKey) { + router.replace("/"); + return; + } + if (currentUser && !currentUser.is_superuser) { + router.replace("/settings/credentials"); + } + }, [isHydrated, activeKey, currentUser, router]); + + const fetchProjects = useCallback( + async (org: Organization) => { + setSelectedOrg(org); + setView("projects"); + setIsLoadingProjects(true); + setProjects([]); + + try { + const result = await apiFetch( + `/api/organization/${org.id}/projects`, + activeKey?.key ?? "", + ); + + if (result.success && result.data) { + setProjects(result.data); + } + } catch { + // keep empty list + } finally { + setIsLoadingProjects(false); + } + }, + [activeKey], + ); + + const handleSuccess = (data: OnboardResponseData) => { + setOnboardData(data); + setView("success"); + }; + + const handleAddUsers = () => { + window.location.href = "/settings/credentials"; + }; + + const handleBackToOrgs = () => { + setSelectedOrg(null); + setProjects([]); + setView("list"); + }; + + return ( +
+
+ + +
+ + +
+
+ {view === "loading" && ( +
+ +
+ )} + + {view === "list" && ( + setView("form")} + onSelectOrg={fetchProjects} + scrollRef={scrollRef} + /> + )} + + {view === "projects" && selectedOrg && ( + + )} + + {view === "form" && ( + <> +
+ +
+ +
+ + {organizations.length > 0 && ( + + )} + + + + )} + + {view === "success" && onboardData && ( + <> +
+ +
+ +
+ + + + )} +
+
+
+
+
+ ); +} diff --git a/app/api/onboard/route.ts b/app/api/onboard/route.ts new file mode 100644 index 0000000..1253475 --- /dev/null +++ b/app/api/onboard/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function POST(request: Request) { + try { + const { status, data } = await apiClient(request, "/api/v1/onboard", { + method: "POST", + body: JSON.stringify(await request.json()), + }); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organization/[orgId]/projects/route.ts b/app/api/organization/[orgId]/projects/route.ts new file mode 100644 index 0000000..d3c0148 --- /dev/null +++ b/app/api/organization/[orgId]/projects/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const { status, data } = await apiClient( + request, + `/api/v1/projects/organization/${orgId}`, + ); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organization/route.ts b/app/api/organization/route.ts new file mode 100644 index 0000000..f04430d --- /dev/null +++ b/app/api/organization/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const queryString = searchParams.toString(); + + const { status, data } = await apiClient( + request, + `/api/v1/organizations/${queryString ? `?${queryString}` : ""}`, + ); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts new file mode 100644 index 0000000..f910e1c --- /dev/null +++ b/app/api/users/me/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET(request: NextRequest) { + try { + const { status, data } = await apiClient(request, "/api/v1/users/me"); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/components/Button.tsx b/app/components/Button.tsx new file mode 100644 index 0000000..30fb389 --- /dev/null +++ b/app/components/Button.tsx @@ -0,0 +1,65 @@ +import { ButtonHTMLAttributes, ReactNode } from "react"; + +type ButtonVariant = "primary" | "outline" | "ghost" | "danger"; +type ButtonSize = "sm" | "md" | "lg"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + fullWidth?: boolean; + children: ReactNode; +} + +const variantStyles: Record = + { + primary: { + base: "bg-accent-primary text-white hover:bg-accent-hover", + disabled: "bg-neutral-200 text-text-secondary cursor-not-allowed", + }, + outline: { + base: "bg-white text-text-primary border border-border hover:bg-neutral-50", + disabled: + "bg-white text-text-secondary border border-border cursor-not-allowed opacity-50", + }, + ghost: { + base: "bg-transparent text-text-secondary hover:bg-neutral-100 hover:text-text-primary", + disabled: + "bg-transparent text-text-secondary cursor-not-allowed opacity-50", + }, + danger: { + base: "bg-red-600 text-white hover:bg-red-700", + disabled: "bg-neutral-200 text-text-secondary cursor-not-allowed", + }, + }; + +const sizeStyles: Record = { + sm: "px-3 py-1.5 text-xs", + md: "px-4 py-2 text-sm", + lg: "px-5 py-2.5 text-sm", +}; + +export default function Button({ + variant = "primary", + size = "md", + fullWidth = false, + disabled, + className = "", + children, + ...props +}: ButtonProps) { + const styles = variantStyles[variant]; + + return ( + + ); +} diff --git a/app/components/Field.tsx b/app/components/Field.tsx new file mode 100644 index 0000000..eb32b1a --- /dev/null +++ b/app/components/Field.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { EyeIcon, EyeOffIcon } from "@/app/components/icons"; + +interface FieldProps { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + error?: string; + type?: string; + disabled?: boolean; +} + +export default function Field({ + label, + value, + onChange, + placeholder, + error, + type = "text", + disabled = false, +}: FieldProps) { + const [showPassword, setShowPassword] = useState(false); + const isPassword = type === "password"; + const inputType = isPassword ? (showPassword ? "text" : "password") : type; + + return ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className={`w-full px-3 py-2 rounded-lg border text-sm text-text-primary bg-white placeholder:text-neutral-400 focus:outline-none focus:ring-accent-primary/20 focus:border-accent-primary transition-colors ${ + isPassword ? "pr-10" : "" + } ${error ? "border-red-400" : "border-border"} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`} + /> + {isPassword && ( + + )} +
+ {error &&

{error}

} +
+ ); +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 63d00aa..359c320 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; +import { useAuth } from "@/app/lib/context/AuthContext"; import { ClipboardIcon, DocumentFileIcon, @@ -41,6 +42,7 @@ export default function Sidebar({ activeRoute = "/evaluations", }: SidebarProps) { const router = useRouter(); + const { currentUser } = useAuth(); const [expandedMenus, setExpandedMenus] = useState>({ Evaluations: true, Configurations: false, @@ -91,11 +93,18 @@ export default function Sidebar({ { name: "Prompt Editor", route: "/configurations/prompt-editor" }, ], }, - { - name: "Settings", - route: "/settings/credentials", - icon: , - }, + ...(currentUser?.is_superuser + ? [ + { + name: "Settings", + icon: , + submenu: [ + { name: "Credentials", route: "/settings/credentials" }, + { name: "Onboarding", route: "/settings/onboarding" }, + ], + }, + ] + : []), ]; const bottomItem: MenuItem = { diff --git a/app/components/icons/common/ArrowLeftIcon.tsx b/app/components/icons/common/ArrowLeftIcon.tsx new file mode 100644 index 0000000..9023243 --- /dev/null +++ b/app/components/icons/common/ArrowLeftIcon.tsx @@ -0,0 +1,21 @@ +interface IconProps { + className?: string; +} + +export default function ArrowLeftIcon({ className }: IconProps) { + return ( + + + + ); +} diff --git a/app/components/icons/common/EyeIcon.tsx b/app/components/icons/common/EyeIcon.tsx new file mode 100644 index 0000000..6394309 --- /dev/null +++ b/app/components/icons/common/EyeIcon.tsx @@ -0,0 +1,26 @@ +interface IconProps { + className?: string; +} + +export default function EyeIcon({ className }: IconProps) { + return ( + + + + + ); +} diff --git a/app/components/icons/common/EyeOffIcon.tsx b/app/components/icons/common/EyeOffIcon.tsx new file mode 100644 index 0000000..2392b65 --- /dev/null +++ b/app/components/icons/common/EyeOffIcon.tsx @@ -0,0 +1,21 @@ +interface IconProps { + className?: string; +} + +export default function EyeOffIcon({ className }: IconProps) { + return ( + + + + ); +} diff --git a/app/components/icons/index.tsx b/app/components/icons/index.tsx index 2513e12..086c50e 100644 --- a/app/components/icons/index.tsx +++ b/app/components/icons/index.tsx @@ -1,6 +1,9 @@ // Common Icons (shared across multiple pages) +export { default as ArrowLeftIcon } from "./common/ArrowLeftIcon"; export { default as ChevronDownIcon } from "./common/ChevronDownIcon"; export { default as CheckIcon } from "./common/CheckIcon"; +export { default as EyeIcon } from "./common/EyeIcon"; +export { default as EyeOffIcon } from "./common/EyeOffIcon"; export { default as RefreshIcon } from "./common/RefreshIcon"; export { default as GearIcon } from "./common/GearIcon"; export { default as WarningTriangleIcon } from "./common/WarningTriangleIcon"; diff --git a/app/components/index.ts b/app/components/index.ts new file mode 100644 index 0000000..318a498 --- /dev/null +++ b/app/components/index.ts @@ -0,0 +1,5 @@ +export { default as Button } from "./Button"; +export { default as Field } from "./Field"; +export { default as Modal } from "./Modal"; +export { default as PageHeader } from "./PageHeader"; +export { default as Sidebar } from "./Sidebar"; diff --git a/app/components/settings/onboarding/OnboardingForm.tsx b/app/components/settings/onboarding/OnboardingForm.tsx new file mode 100644 index 0000000..a40a3fc --- /dev/null +++ b/app/components/settings/onboarding/OnboardingForm.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import { useToast } from "@/app/components/Toast"; +import { Button, Field } from "@/app/components"; +import { apiFetch } from "@/app/lib/apiClient"; +import { isValidEmail, isValidPassword, isNonEmpty } from "@/app/lib/utils"; +import { + OnboardRequest, + OnboardResponse, + OnboardResponseData, +} from "@/app/lib/types/onboarding"; +import { useAuth } from "@/app/lib/context/AuthContext"; + +interface OnboardingFormProps { + onSuccess: (data: OnboardResponseData) => void; +} + +export default function OnboardingForm({ onSuccess }: OnboardingFormProps) { + const toast = useToast(); + const { activeKey } = useAuth(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [form, setForm] = useState({ + organization_name: "", + project_name: "", + user_name: "", + email: "", + password: "", + }); + const [fieldErrors, setFieldErrors] = useState>({}); + + const update = (field: keyof OnboardRequest, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + setFieldErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + }; + + const validate = (): boolean => { + const errors: Record = {}; + if (!isNonEmpty(form.organization_name)) + errors.organization_name = "Organization name is required"; + if (!isNonEmpty(form.project_name)) + errors.project_name = "Project name is required"; + if (!isNonEmpty(form.user_name)) errors.user_name = "User name is required"; + if (!isNonEmpty(form.email)) errors.email = "Email is required"; + else if (!isValidEmail(form.email)) + errors.email = "Enter a valid email address"; + if (!form.password) errors.password = "Password is required"; + else if (!isValidPassword(form.password)) + errors.password = "Password must be at least 8 characters"; + + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + setIsSubmitting(true); + + try { + const result = await apiFetch( + "/api/onboard", + activeKey?.key, + { + method: "POST", + body: JSON.stringify(form), + }, + ); + + if (!result.success || !result.data) { + if (result.errors && result.errors.length > 0) { + const errors: Record = {}; + result.errors.forEach((err) => { + errors[err.field] = err.message; + }); + setFieldErrors(errors); + } + toast.error(result.error || "Onboarding failed. Please try again."); + return; + } + + toast.success("Onboarding completed successfully!"); + onSuccess(result.data); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to connect to server.", + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+

+ Organization +

+ update("organization_name", v)} + placeholder="e.g. Acme Corp" + error={fieldErrors.organization_name} + /> +
+ +
+

+ Project +

+ update("project_name", v)} + placeholder="e.g. Main Project" + error={fieldErrors.project_name} + /> +
+
+ +
+

+ Admin User +

+
+ update("user_name", v)} + placeholder="e.g. John Doe" + error={fieldErrors.user_name} + /> + update("email", v)} + placeholder="e.g. admin@acme.com" + error={fieldErrors.email} + /> +
+ update("password", v)} + placeholder="Min. 8 characters" + error={fieldErrors.password} + /> +
+
+
+ + +
+ ); +} diff --git a/app/components/settings/onboarding/OnboardingSuccess.tsx b/app/components/settings/onboarding/OnboardingSuccess.tsx new file mode 100644 index 0000000..1064676 --- /dev/null +++ b/app/components/settings/onboarding/OnboardingSuccess.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { useToast } from "@/app/components/Toast"; +import { Button } from "@/app/components"; +import { OnboardResponseData } from "@/app/lib/types/onboarding"; + +interface OnboardingSuccessProps { + data: OnboardResponseData; + onAddUsers: () => void; +} + +export default function OnboardingSuccess({ + data, + onAddUsers, +}: OnboardingSuccessProps) { + const toast = useToast(); + const [copied, setCopied] = useState(false); + + const copyApiKey = async () => { + try { + await navigator.clipboard.writeText(data.api_key); + setCopied(true); + toast.success("API key copied to clipboard"); + setTimeout(() => setCopied(false), 3000); + } catch { + toast.error("Failed to copy. Please select and copy manually."); + } + }; + + return ( +
+
+

+ Onboarding completed successfully! +

+

+ Your organization, project, and admin user have been created. +

+
+ +
+ + + +
+ +
+
+

Your API Key

+ + Shown only once + +
+

+ Copy this key now. You will not be able to see it again. +

+
+ + {data.api_key} + + +
+
+ + {/* Next step */} + +
+ ); +} + +function SummaryRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/app/components/settings/onboarding/OrganizationList.tsx b/app/components/settings/onboarding/OrganizationList.tsx new file mode 100644 index 0000000..48c1486 --- /dev/null +++ b/app/components/settings/onboarding/OrganizationList.tsx @@ -0,0 +1,74 @@ +import { RefObject } from "react"; +import { Organization } from "@/app/lib/types/onboarding"; +import { formatRelativeTime } from "@/app/lib/utils"; +import { Button } from "@/app/components"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { ChevronRightIcon, RefreshIcon } from "@/app/components/icons"; + +interface OrganizationListProps { + organizations: Organization[]; + isLoadingMore: boolean; + onNewOrg: () => void; + onSelectOrg: (org: Organization) => void; + scrollRef: RefObject; +} + +export default function OrganizationList({ + organizations, + isLoadingMore, + onNewOrg, + onSelectOrg, + scrollRef, +}: OrganizationListProps) { + const { currentUser } = useAuth(); + return ( +
+
+
+

+ Organizations +

+

+ {organizations.length} organization + {organizations.length !== 1 ? "s" : ""} +

+
+ {currentUser?.is_superuser && ( + + )} +
+ +
+ {organizations.map((org) => ( + + ))} +
+ + {isLoadingMore && ( +
+ +

Loading more...

+
+ )} +
+ ); +} diff --git a/app/components/settings/onboarding/ProjectList.tsx b/app/components/settings/onboarding/ProjectList.tsx new file mode 100644 index 0000000..170161d --- /dev/null +++ b/app/components/settings/onboarding/ProjectList.tsx @@ -0,0 +1,108 @@ +import { Organization, Project } from "@/app/lib/types/onboarding"; +import { formatRelativeTime } from "@/app/lib/utils"; +import { ArrowLeftIcon } from "@/app/components/icons"; + +interface ProjectListProps { + organization: Organization; + projects: Project[]; + isLoading: boolean; + onBack: () => void; +} + +export default function ProjectList({ + organization, + projects, + isLoading, + onBack, +}: ProjectListProps) { + const renderProjectLoader = () => { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); + }; + + return ( +
+ + +
+
+

+ {organization.name} +

+

+ {isLoading + ? "Loading projects..." + : `${projects.length} project${projects.length !== 1 ? "s" : ""}`} +

+
+
+ + {isLoading ? ( + renderProjectLoader() + ) : projects.length === 0 ? ( +
+ No projects found for this organization. +
+ ) : ( +
+ {projects.map((project) => ( +
+
+

+ {project.name} +

+ {project.description && ( +

+ {project.description} +

+ )} +

+ Created {formatRelativeTime(project.inserted_at)} +

+
+
+ + {project.is_active ? "Active" : "Inactive"} + + + ID: {project.id} + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/components/settings/onboarding/StepIndicator.tsx b/app/components/settings/onboarding/StepIndicator.tsx new file mode 100644 index 0000000..56a46a2 --- /dev/null +++ b/app/components/settings/onboarding/StepIndicator.tsx @@ -0,0 +1,38 @@ +import { CheckIcon } from "@/app/components/icons"; + +interface StepIndicatorProps { + number: number; + label: string; + active: boolean; + completed: boolean; +} + +export default function StepIndicator({ + number, + label, + active, + completed, +}: StepIndicatorProps) { + return ( +
+
+ {completed ? : number} +
+ + {label} + +
+ ); +} diff --git a/app/components/settings/onboarding/index.ts b/app/components/settings/onboarding/index.ts new file mode 100644 index 0000000..ad930bb --- /dev/null +++ b/app/components/settings/onboarding/index.ts @@ -0,0 +1,5 @@ +export { default as OnboardingForm } from "./OnboardingForm"; +export { default as OnboardingSuccess } from "./OnboardingSuccess"; +export { default as OrganizationList } from "./OrganizationList"; +export { default as ProjectList } from "./ProjectList"; +export { default as StepIndicator } from "./StepIndicator"; diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx index 1b1321c..c9e2ad6 100644 --- a/app/lib/context/AuthContext.tsx +++ b/app/lib/context/AuthContext.tsx @@ -8,13 +8,23 @@ import { useEffect, } from "react"; import { APIKey } from "@/app/lib/types/credentials"; +import { apiFetch } from "../apiClient"; const STORAGE_KEY = "kaapi_api_keys"; +export interface User { + id: number; + email: string; + full_name: string; + is_active: boolean; + is_superuser: boolean; +} + interface AuthContextValue { apiKeys: APIKey[]; activeKey: APIKey | null; isHydrated: boolean; + currentUser: User | null; addKey: (key: APIKey) => void; removeKey: (id: string) => void; setKeys: (keys: APIKey[]) => void; @@ -25,9 +35,9 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: React.ReactNode }) { const [apiKeys, setApiKeys] = useState([]); const [isHydrated, setIsHydrated] = useState(false); + const [currentUser, setCurrentUser] = useState(null); // Initialize from localStorage after hydration to avoid SSR mismatch. - // setState in effect is intentional here — this is a one-time external storage read. useEffect(() => { try { const stored = localStorage.getItem(STORAGE_KEY); @@ -38,6 +48,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setIsHydrated(true); }, []); + // Fetch current user when an API key is available + useEffect(() => { + const apiKey = apiKeys[0]?.key; + if (!apiKey || !isHydrated) return; + + let cancelled = false; + + (async () => { + try { + const data = await apiFetch("/api/users/me", apiKey); + if (!cancelled) setCurrentUser(data); + } catch { + // silently ignore — user info is non-critical + } + })(); + + return () => { + cancelled = true; + }; + }, [apiKeys, isHydrated]); + const persist = useCallback((keys: APIKey[]) => { setApiKeys(keys); if (keys.length > 0) { @@ -63,6 +94,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { apiKeys, activeKey: apiKeys[0] ?? null, isHydrated, + currentUser, addKey, removeKey, setKeys, diff --git a/app/lib/types/onboarding.ts b/app/lib/types/onboarding.ts new file mode 100644 index 0000000..b5cd49e --- /dev/null +++ b/app/lib/types/onboarding.ts @@ -0,0 +1,60 @@ +export interface Organization { + id: number; + name: string; + is_active: boolean; + inserted_at: string; + updated_at: string; +} + +export interface OrganizationListResponse { + success: boolean; + data?: Organization[]; + error?: string; + errors?: { field: string; message: string }[]; + metadata?: Record; +} + +export interface Project { + id: number; + name: string; + description: string; + is_active: boolean; + organization_id: number; + inserted_at: string; + updated_at: string; +} + +export interface ProjectListResponse { + success: boolean; + data?: Project[]; + error?: string; + errors?: { field: string; message: string }[]; + metadata?: Record; +} + +export interface OnboardRequest { + organization_name: string; + project_name: string; + email: string; + password: string; + user_name: string; + credentials?: Record[]; +} + +export interface OnboardResponseData { + organization_id: number; + organization_name: string; + project_id: number; + project_name: string; + user_id: number; + user_email: string; + api_key: string; +} + +export interface OnboardResponse { + success: boolean; + data?: OnboardResponseData; + error?: string; + errors?: { field: string; message: string }[]; + metadata?: Record; +} diff --git a/app/lib/utils.ts b/app/lib/utils.ts index ea87190..ec7571e 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -158,3 +158,15 @@ export const groupConfigs = ( }; }); }; + +// ---- Validation helpers ---- + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MIN_PASSWORD_LENGTH = 8; + +export const isValidEmail = (email: string): boolean => EMAIL_REGEX.test(email); + +export const isValidPassword = (password: string): boolean => + password.length >= MIN_PASSWORD_LENGTH; + +export const isNonEmpty = (value: string): boolean => value.trim().length > 0; From feea8c583fb78d34321b353a0fe4ab29794d08db Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:30:00 +0530 Subject: [PATCH 2/9] fix(*): added the note of after creating the key --- app/(main)/settings/onboarding/page.tsx | 9 +------ .../settings/onboarding/OnboardingSuccess.tsx | 25 ++++++++++++------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index f38ca9b..a569dcb 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -109,10 +109,6 @@ export default function OnboardingPage() { setView("success"); }; - const handleAddUsers = () => { - window.location.href = "/settings/credentials"; - }; - const handleBackToOrgs = () => { setSelectedOrg(null); setProjects([]); @@ -213,10 +209,7 @@ export default function OnboardingPage() { />
- + )}
diff --git a/app/components/settings/onboarding/OnboardingSuccess.tsx b/app/components/settings/onboarding/OnboardingSuccess.tsx index 1064676..816183c 100644 --- a/app/components/settings/onboarding/OnboardingSuccess.tsx +++ b/app/components/settings/onboarding/OnboardingSuccess.tsx @@ -7,13 +7,9 @@ import { OnboardResponseData } from "@/app/lib/types/onboarding"; interface OnboardingSuccessProps { data: OnboardResponseData; - onAddUsers: () => void; } -export default function OnboardingSuccess({ - data, - onAddUsers, -}: OnboardingSuccessProps) { +export default function OnboardingSuccess({ data }: OnboardingSuccessProps) { const toast = useToast(); const [copied, setCopied] = useState(false); @@ -68,10 +64,21 @@ export default function OnboardingSuccess({
- {/* Next step */} - +
+

+ What's next? +

+

+ Add this API key in the{" "} + + Keystore + {" "} + to start using configurations, evaluations, and other features. +

+
); } From 39604a24fdaf0e906bdbfa8247d87b831913d290 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:38:55 +0530 Subject: [PATCH 3/9] fix(*): added the index.ts file for easy to import from the index --- app/(main)/configurations/page.tsx | 3 +-- app/(main)/configurations/prompt-editor/page.tsx | 2 +- app/(main)/document/page.tsx | 3 +-- app/(main)/settings/onboarding/page.tsx | 3 +-- app/components/ConfigSelector.tsx | 2 +- app/hooks/index.ts | 4 ++++ 6 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 app/hooks/index.ts diff --git a/app/(main)/configurations/page.tsx b/app/(main)/configurations/page.tsx index 09a93f3..1e35c43 100644 --- a/app/(main)/configurations/page.tsx +++ b/app/(main)/configurations/page.tsx @@ -10,8 +10,7 @@ import { useRouter } from "next/navigation"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; import { colors } from "@/app/lib/colors"; -import { usePaginatedList } from "@/app/hooks/usePaginatedList"; -import { useInfiniteScroll } from "@/app/hooks/useInfiniteScroll"; +import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import ConfigCard from "@/app/components/ConfigCard"; import Loader, { LoaderBox } from "@/app/components/Loader"; import { EvalJob } from "@/app/components/types"; diff --git a/app/(main)/configurations/prompt-editor/page.tsx b/app/(main)/configurations/prompt-editor/page.tsx index 897c0ec..995f8c4 100644 --- a/app/(main)/configurations/prompt-editor/page.tsx +++ b/app/(main)/configurations/prompt-editor/page.tsx @@ -19,7 +19,7 @@ import { useToast } from "@/app/components/Toast"; import Loader from "@/app/components/Loader"; import { useApp } from "@/app/lib/context/AppContext"; import { useAuth } from "@/app/lib/context/AuthContext"; -import { useConfigs } from "@/app/hooks/useConfigs"; +import { useConfigs } from "@/app/hooks"; import { SavedConfig, ConfigCreate, diff --git a/app/(main)/document/page.tsx b/app/(main)/document/page.tsx index 168bb81..648691e 100644 --- a/app/(main)/document/page.tsx +++ b/app/(main)/document/page.tsx @@ -6,8 +6,7 @@ import { useApp } from "@/app/lib/context/AppContext"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; import { useToast } from "@/app/components/Toast"; -import { usePaginatedList } from "@/app/hooks/usePaginatedList"; -import { useInfiniteScroll } from "@/app/hooks/useInfiniteScroll"; +import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import { apiFetch } from "@/app/lib/apiClient"; import { DocumentListing } from "@/app/components/document/DocumentListing"; import { DocumentPreview } from "@/app/components/document/DocumentPreview"; diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index a569dcb..ebecc58 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -6,8 +6,7 @@ import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; import { useApp } from "@/app/lib/context/AppContext"; import { useAuth } from "@/app/lib/context/AuthContext"; -import { usePaginatedList } from "@/app/hooks/usePaginatedList"; -import { useInfiniteScroll } from "@/app/hooks/useInfiniteScroll"; +import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import { OnboardingForm, OnboardingSuccess, diff --git a/app/components/ConfigSelector.tsx b/app/components/ConfigSelector.tsx index fa604ad..0b39733 100644 --- a/app/components/ConfigSelector.tsx +++ b/app/components/ConfigSelector.tsx @@ -8,7 +8,7 @@ import { useState, useRef, useLayoutEffect, useEffect } from "react"; import { useRouter } from "next/navigation"; import { colors } from "@/app/lib/colors"; -import { useConfigs } from "@/app/hooks/useConfigs"; +import { useConfigs } from "@/app/hooks"; import { ChevronUpIcon, ChevronDownIcon, diff --git a/app/hooks/index.ts b/app/hooks/index.ts new file mode 100644 index 0000000..0b9cedd --- /dev/null +++ b/app/hooks/index.ts @@ -0,0 +1,4 @@ +export { useConfigs } from "./useConfigs"; +export { useInfiniteScroll } from "./useInfiniteScroll"; +export { usePaginatedList } from "./usePaginatedList"; +export type { UsePaginatedListResult } from "./usePaginatedList"; From fc7837f407b6ac70e63a25fca721dda23f534885 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:42:18 +0530 Subject: [PATCH 4/9] fix(*): off the exhaustive deps --- app/(main)/settings/onboarding/page.tsx | 4 ++-- app/lib/context/AuthContext.tsx | 1 - eslint.config.mjs | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index ebecc58..d7e38fb 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -23,6 +23,7 @@ import { import { apiFetch } from "@/app/lib/apiClient"; import { colors } from "@/app/lib/colors"; import { ArrowLeftIcon, RefreshIcon } from "@/app/components/icons"; +import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants"; type View = "loading" | "list" | "projects" | "form" | "success"; @@ -46,7 +47,7 @@ export default function OnboardingPage() { loadMore, } = usePaginatedList({ endpoint: "/api/organization", - limit: 10, + limit: DEFAULT_PAGE_LIMIT, }); const scrollRef = useInfiniteScroll({ @@ -63,7 +64,6 @@ export default function OnboardingPage() { if (view === "loading") { setView(organizations.length > 0 ? "list" : "form"); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoadingOrgs, organizations.length]); // Redirect if no API key or not a superuser diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx index c9e2ad6..bb27083 100644 --- a/app/lib/context/AuthContext.tsx +++ b/app/lib/context/AuthContext.tsx @@ -48,7 +48,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setIsHydrated(true); }, []); - // Fetch current user when an API key is available useEffect(() => { const apiKey = apiKeys[0]?.key; if (!apiKey || !isHydrated) return; diff --git a/eslint.config.mjs b/eslint.config.mjs index 76b6934..9d91a46 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,6 +32,7 @@ const eslintConfig = defineConfig([ "no-var": "error", "no-console": ["warn", { allow: ["warn", "error"] }], "react-hooks/set-state-in-effect": "off", + "react-hooks/exhaustive-deps": "off", }, }, ]); From e355d4a0ebc5b802e7d1a84af9c7c809effacecf Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:19:27 +0530 Subject: [PATCH 5/9] fix(*): added the skeleton loader --- app/(main)/settings/onboarding/page.tsx | 39 +++++++++++++--- .../settings/onboarding/ProjectList.tsx | 46 +++++++++---------- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index d7e38fb..e914ff7 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -22,11 +22,42 @@ import { } from "@/app/lib/types/onboarding"; import { apiFetch } from "@/app/lib/apiClient"; import { colors } from "@/app/lib/colors"; -import { ArrowLeftIcon, RefreshIcon } from "@/app/components/icons"; +import { ArrowLeftIcon } from "@/app/components/icons"; import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants"; type View = "loading" | "list" | "projects" | "form" | "success"; +function OrganizationListSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} + export default function OnboardingPage() { const router = useRouter(); const { sidebarCollapsed } = useApp(); @@ -133,11 +164,7 @@ export default function OnboardingPage() {
- {view === "loading" && ( -
- -
- )} + {view === "loading" && } {view === "list" && ( void; } +function ProjectListSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + export default function ProjectList({ organization, projects, isLoading, onBack, }: ProjectListProps) { - const renderProjectLoader = () => { - return ( -
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
- ); - }; - return (
))}
diff --git a/app/components/settings/onboarding/ProjectList.tsx b/app/components/settings/onboarding/ProjectList.tsx index 2a56921..25e5227 100644 --- a/app/components/settings/onboarding/ProjectList.tsx +++ b/app/components/settings/onboarding/ProjectList.tsx @@ -46,7 +46,7 @@ export default function ProjectList({ Back to organizations -
+

{organization.name} @@ -85,20 +85,15 @@ export default function ProjectList({ Created {formatRelativeTime(project.inserted_at)}

-
- - {project.is_active ? "Active" : "Inactive"} - - - ID: {project.id} - -
+ + {project.is_active ? "Active" : "Inactive"} +
))}
From a4da4452bd036a3dd670d91e8c99744e07e156b2 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:52:03 +0530 Subject: [PATCH 7/9] fix(*): few clenaups --- .../settings/onboarding/OnboardingSuccess.tsx | 18 +++++++++--------- app/lib/context/AuthContext.tsx | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/components/settings/onboarding/OnboardingSuccess.tsx b/app/components/settings/onboarding/OnboardingSuccess.tsx index 816183c..69e3e6d 100644 --- a/app/components/settings/onboarding/OnboardingSuccess.tsx +++ b/app/components/settings/onboarding/OnboardingSuccess.tsx @@ -9,6 +9,15 @@ interface OnboardingSuccessProps { data: OnboardResponseData; } +function SummaryRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + export default function OnboardingSuccess({ data }: OnboardingSuccessProps) { const toast = useToast(); const [copied, setCopied] = useState(false); @@ -82,12 +91,3 @@ export default function OnboardingSuccess({ data }: OnboardingSuccessProps) {
); } - -function SummaryRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx index bb27083..c977f09 100644 --- a/app/lib/context/AuthContext.tsx +++ b/app/lib/context/AuthContext.tsx @@ -8,7 +8,7 @@ import { useEffect, } from "react"; import { APIKey } from "@/app/lib/types/credentials"; -import { apiFetch } from "../apiClient"; +import { apiFetch } from "@/app/lib/apiClient"; const STORAGE_KEY = "kaapi_api_keys"; From 8c4e3ccfe9501411a1e75aea9a9d206425636a08 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:32:45 +0530 Subject: [PATCH 8/9] fix(*): implement the edit projects --- app/(main)/datasets/page.tsx | 2 - app/(main)/evaluations/[id]/page.tsx | 1 - app/(main)/knowledge-base/page.tsx | 3 - app/(main)/settings/credentials/page.tsx | 1 - app/(main)/settings/onboarding/page.tsx | 40 +++- app/(main)/speech-to-text/page.tsx | 1 - app/(main)/text-to-speech/page.tsx | 1 - app/api/projects/[projectId]/route.ts | 26 +++ app/api/projects/route.ts | 18 ++ app/components/Button.tsx | 2 +- app/components/ConfigSelector.tsx | 2 - app/components/Modal.tsx | 2 +- .../settings/onboarding/AddProjectModal.tsx | 128 ++++++++++++ .../settings/onboarding/AddUserModal.tsx | 193 ++++++++++++++++++ .../settings/onboarding/EditProjectModal.tsx | 129 ++++++++++++ .../settings/onboarding/OrganizationList.tsx | 11 +- .../settings/onboarding/ProjectList.tsx | 91 +++++++-- .../settings/onboarding/UserList.tsx | 178 ++++++++++++++++ app/components/settings/onboarding/index.ts | 4 + .../speech-to-text/ModelComparisonCard.tsx | 1 - app/lib/types/onboarding.ts | 89 ++++++++ 21 files changed, 878 insertions(+), 45 deletions(-) create mode 100644 app/api/projects/[projectId]/route.ts create mode 100644 app/api/projects/route.ts create mode 100644 app/components/settings/onboarding/AddProjectModal.tsx create mode 100644 app/components/settings/onboarding/AddUserModal.tsx create mode 100644 app/components/settings/onboarding/EditProjectModal.tsx create mode 100644 app/components/settings/onboarding/UserList.tsx diff --git a/app/(main)/datasets/page.tsx b/app/(main)/datasets/page.tsx index ad241bb..cc9f20b 100644 --- a/app/(main)/datasets/page.tsx +++ b/app/(main)/datasets/page.tsx @@ -15,7 +15,6 @@ import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; import { useToast } from "@/app/components/Toast"; -// Backend response interface export interface Dataset { dataset_id: number; dataset_name: string; @@ -52,7 +51,6 @@ export default function Datasets() { if (apiKey) { fetchDatasets(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKey]); const fetchDatasets = async () => { diff --git a/app/(main)/evaluations/[id]/page.tsx b/app/(main)/evaluations/[id]/page.tsx index a9a5843..6d86167 100644 --- a/app/(main)/evaluations/[id]/page.tsx +++ b/app/(main)/evaluations/[id]/page.tsx @@ -124,7 +124,6 @@ export default function EvaluationReport() { } finally { setIsLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKeys, selectedKeyId, jobId, exportFormat]); const fetchAssistantConfig = async (assistantId: string, apiKey: string) => { diff --git a/app/(main)/knowledge-base/page.tsx b/app/(main)/knowledge-base/page.tsx index 3467d5d..5ba4c35 100644 --- a/app/(main)/knowledge-base/page.tsx +++ b/app/(main)/knowledge-base/page.tsx @@ -293,8 +293,6 @@ export default function KnowledgeBasePage() { return jobStatusMap; }; - // Fetch collections - // eslint-disable-next-line react-hooks/exhaustive-deps const fetchCollections = async () => { if (!apiKey) return; @@ -846,7 +844,6 @@ export default function KnowledgeBasePage() { fetchCollections(); fetchDocuments(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKey]); // Keep apiKeyRef in sync so polling always has the current key diff --git a/app/(main)/settings/credentials/page.tsx b/app/(main)/settings/credentials/page.tsx index 35ce665..d4af78b 100644 --- a/app/(main)/settings/credentials/page.tsx +++ b/app/(main)/settings/credentials/page.tsx @@ -45,7 +45,6 @@ export default function CredentialsPage() { useEffect(() => { if (apiKeys.length === 0) return; loadCredentials(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKeys]); // Re-populate form when provider or credentials change diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index 40abfca..1f1f697 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -13,6 +13,7 @@ import { OrganizationList, ProjectList, StepIndicator, + UserList, } from "@/app/components/settings/onboarding"; import { Organization, @@ -25,7 +26,7 @@ import { colors } from "@/app/lib/colors"; import { ArrowLeftIcon } from "@/app/components/icons"; import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants"; -type View = "loading" | "list" | "projects" | "form" | "success"; +type View = "loading" | "list" | "projects" | "users" | "form" | "success"; function OrganizationListSkeleton() { return ( @@ -64,6 +65,7 @@ export default function OnboardingPage() { const { activeKey, currentUser, isHydrated } = useAuth(); const [view, setView] = useState("loading"); const [selectedOrg, setSelectedOrg] = useState(null); + const [selectedProject, setSelectedProject] = useState(null); const [projects, setProjects] = useState([]); const [isLoadingProjects, setIsLoadingProjects] = useState(false); const [onboardData, setOnboardData] = useState( @@ -134,17 +136,43 @@ export default function OnboardingPage() { [activeKey], ); + const refreshProjects = useCallback(async () => { + if (!selectedOrg) return; + try { + const result = await apiFetch( + `/api/organization/${selectedOrg.id}/projects`, + activeKey?.key ?? "", + ); + if (result.success && result.data) { + setProjects(result.data); + } + } catch { + // keep current list + } + }, [selectedOrg, activeKey]); + const handleSuccess = (data: OnboardResponseData) => { setOnboardData(data); setView("success"); }; + const handleSelectProject = (project: Project) => { + setSelectedProject(project); + setView("users"); + }; + const handleBackToOrgs = () => { setSelectedOrg(null); + setSelectedProject(null); setProjects([]); setView("list"); }; + const handleBackToProjects = () => { + setSelectedProject(null); + setView("projects"); + }; + return (
+ )} + + {view === "users" && selectedOrg && selectedProject && ( + )} diff --git a/app/(main)/speech-to-text/page.tsx b/app/(main)/speech-to-text/page.tsx index 7b76edf..4104844 100644 --- a/app/(main)/speech-to-text/page.tsx +++ b/app/(main)/speech-to-text/page.tsx @@ -487,7 +487,6 @@ export default function SpeechToTextPage() { if (activeTab === "evaluations") { loadRuns(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKeys, activeTab]); // Handle audio file selection and upload diff --git a/app/(main)/text-to-speech/page.tsx b/app/(main)/text-to-speech/page.tsx index 0127b11..700dc54 100644 --- a/app/(main)/text-to-speech/page.tsx +++ b/app/(main)/text-to-speech/page.tsx @@ -408,7 +408,6 @@ export default function TextToSpeechPage() { if (activeTab === "evaluations") { loadRuns(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKeys, activeTab]); // Add a new text sample diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts new file mode 100644 index 0000000..01f4e82 --- /dev/null +++ b/app/api/projects/[projectId]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> }, +) { + try { + const { projectId } = await params; + const body = await request.json(); + const { status, data } = await apiClient( + request, + `/api/v1/projects/${projectId}`, + { + method: "PATCH", + body: JSON.stringify(body), + }, + ); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..ae73477 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { status, data } = await apiClient(request, "/api/v1/projects/", { + method: "POST", + body: JSON.stringify(body), + }); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 30fb389..73a8a04 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -52,7 +52,7 @@ export default function Button({ return ( + +
+
+ + ); +} diff --git a/app/components/settings/onboarding/AddUserModal.tsx b/app/components/settings/onboarding/AddUserModal.tsx new file mode 100644 index 0000000..55c00af --- /dev/null +++ b/app/components/settings/onboarding/AddUserModal.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState } from "react"; +import Modal from "@/app/components/Modal"; +import { Button, Field } from "@/app/components"; +import { isValidEmail } from "@/app/lib/utils"; +import { + AddUserModalProps, + UserProjectListResponse, +} from "@/app/lib/types/onboarding"; +import { apiFetch } from "@/app/lib/apiClient"; +import { useToast } from "@/app/components/Toast"; + +export default function AddUserModal({ + open, + onClose, + organizationId, + projectId, + apiKey, + onUsersAdded, +}: AddUserModalProps) { + const toast = useToast(); + const [userRows, setUserRows] = useState([{ email: "", full_name: "" }]); + const [rowErrors, setRowErrors] = useState>({}); + const [isAdding, setIsAdding] = useState(false); + + const updateRow = ( + index: number, + field: "email" | "full_name", + value: string, + ) => { + setUserRows((prev) => + prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)), + ); + if (rowErrors[index]) { + setRowErrors((prev) => { + const next = { ...prev }; + delete next[index]; + return next; + }); + } + }; + + const addRow = () => { + setUserRows((prev) => [...prev, { email: "", full_name: "" }]); + }; + + const removeRow = (index: number) => { + if (userRows.length === 1) return; + setUserRows((prev) => prev.filter((_, i) => i !== index)); + setRowErrors((prev) => { + const next: Record = {}; + Object.entries(prev).forEach(([k, v]) => { + const key = Number(k); + if (key < index) next[key] = v; + else if (key > index) next[key - 1] = v; + }); + return next; + }); + }; + + const resetForm = () => { + setUserRows([{ email: "", full_name: "" }]); + setRowErrors({}); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSubmit = async () => { + const filledRows = userRows.filter((r) => r.email.trim()); + + if (filledRows.length === 0) { + setRowErrors({ 0: "Please enter an email address" }); + return; + } + + const errors: Record = {}; + userRows.forEach((row, i) => { + if (row.email.trim() && !isValidEmail(row.email.trim())) { + errors[i] = "Invalid email address"; + } + }); + + if (Object.keys(errors).length > 0) { + setRowErrors(errors); + return; + } + + setRowErrors({}); + setIsAdding(true); + + try { + const payload = filledRows.map((r) => { + const entry: { email: string; full_name?: string } = { + email: r.email.trim(), + }; + if (r.full_name.trim()) entry.full_name = r.full_name.trim(); + return entry; + }); + + await apiFetch("/api/user-projects", apiKey, { + method: "POST", + body: JSON.stringify({ + organization_id: organizationId, + project_id: projectId, + users: payload, + }), + }); + + toast.success( + `${filledRows.length} user${filledRows.length > 1 ? "s" : ""} added`, + ); + resetForm(); + onUsersAdded(); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to add users"); + } finally { + setIsAdding(false); + } + }; + + const filledCount = userRows.filter((r) => r.email.trim()).length; + + return ( + +
+

+ Add users by email. Full name is optional. +

+ +
+ {userRows.map((row, index) => ( +
+
+ updateRow(index, "email", val)} + placeholder="Email address" + error={rowErrors[index]} + /> +
+
+ updateRow(index, "full_name", val)} + placeholder="Full name (optional)" + /> +
+ {userRows.length > 1 && ( + + )} +
+ ))} +
+ +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/app/components/settings/onboarding/EditProjectModal.tsx b/app/components/settings/onboarding/EditProjectModal.tsx new file mode 100644 index 0000000..07509f9 --- /dev/null +++ b/app/components/settings/onboarding/EditProjectModal.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Modal from "@/app/components/Modal"; +import { Button, Field } from "@/app/components"; +import { + EditProjectModalProps, + ProjectResponse, +} from "@/app/lib/types/onboarding"; +import { apiFetch } from "@/app/lib/apiClient"; +import { useToast } from "@/app/components/Toast"; + +export default function EditProjectModal({ + open, + onClose, + project, + apiKey, + onProjectUpdated, +}: EditProjectModalProps) { + const toast = useToast(); + const [name, setName] = useState(project.name); + const [description, setDescription] = useState(project.description); + const [isActive, setIsActive] = useState(project.is_active); + const [isSubmitting, setIsSubmitting] = useState(false); + const [nameError, setNameError] = useState(""); + + useEffect(() => { + setName(project.name); + setDescription(project.description); + setIsActive(project.is_active); + setNameError(""); + }, [project]); + + const handleClose = () => { + setName(project.name); + setDescription(project.description); + setIsActive(project.is_active); + setNameError(""); + onClose(); + }; + + const handleSubmit = async () => { + if (!name.trim()) { + setNameError("Project name is required"); + return; + } + + setNameError(""); + setIsSubmitting(true); + + try { + await apiFetch(`/api/projects/${project.id}`, apiKey, { + method: "PATCH", + body: JSON.stringify({ + name: name.trim(), + description: description.trim(), + is_active: isActive, + }), + }); + + toast.success("Project updated"); + onProjectUpdated(); + onClose(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to update project", + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ { + setName(val); + if (nameError) setNameError(""); + }} + placeholder="Enter project name" + error={nameError} + /> + + + +
+ +

+ Inactive projects won't be available for use. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/app/components/settings/onboarding/OrganizationList.tsx b/app/components/settings/onboarding/OrganizationList.tsx index b62cfdb..b1ab9bf 100644 --- a/app/components/settings/onboarding/OrganizationList.tsx +++ b/app/components/settings/onboarding/OrganizationList.tsx @@ -1,18 +1,9 @@ -import { RefObject } from "react"; -import { Organization } from "@/app/lib/types/onboarding"; +import { OrganizationListProps } from "@/app/lib/types/onboarding"; import { formatRelativeTime } from "@/app/lib/utils"; import { Button } from "@/app/components"; import { useAuth } from "@/app/lib/context/AuthContext"; import { ChevronRightIcon, RefreshIcon } from "@/app/components/icons"; -interface OrganizationListProps { - organizations: Organization[]; - isLoadingMore: boolean; - onNewOrg: () => void; - onSelectOrg: (org: Organization) => void; - scrollRef: RefObject; -} - export default function OrganizationList({ organizations, isLoadingMore, diff --git a/app/components/settings/onboarding/ProjectList.tsx b/app/components/settings/onboarding/ProjectList.tsx index 25e5227..71ce2ac 100644 --- a/app/components/settings/onboarding/ProjectList.tsx +++ b/app/components/settings/onboarding/ProjectList.tsx @@ -1,13 +1,17 @@ -import { Organization, Project } from "@/app/lib/types/onboarding"; -import { formatRelativeTime } from "@/app/lib/utils"; -import { ArrowLeftIcon } from "@/app/components/icons"; +"use client"; -interface ProjectListProps { - organization: Organization; - projects: Project[]; - isLoading: boolean; - onBack: () => void; -} +import { useState } from "react"; +import { Project, ProjectListProps } from "@/app/lib/types/onboarding"; +import { formatRelativeTime } from "@/app/lib/utils"; +import { + ArrowLeftIcon, + ChevronRightIcon, + EditIcon, +} from "@/app/components/icons"; +import { Button } from "@/app/components"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import AddProjectModal from "./AddProjectModal"; +import EditProjectModal from "./EditProjectModal"; function ProjectListSkeleton() { return ( @@ -36,7 +40,13 @@ export default function ProjectList({ projects, isLoading, onBack, + onSelectProject, + onProjectAdded, }: ProjectListProps) { + const { activeKey, currentUser } = useAuth(); + const [showAddModal, setShowAddModal] = useState(false); + const [editingProject, setEditingProject] = useState(null); + return (
+ {currentUser?.is_superuser && ( + + )}
{isLoading ? ( @@ -70,9 +85,12 @@ export default function ProjectList({ {projects.map((project) => (
-
+ +
+ + {project.is_active ? "Active" : "Inactive"} + + {currentUser?.is_superuser && ( + + )} +
- - {project.is_active ? "Active" : "Inactive"} -
))}
)} + + setShowAddModal(false)} + organizationId={organization.id} + apiKey={activeKey?.key ?? ""} + onProjectAdded={onProjectAdded} + /> + + {editingProject && ( + setEditingProject(null)} + project={editingProject} + apiKey={activeKey?.key ?? ""} + onProjectUpdated={onProjectAdded} + /> + )}
); } diff --git a/app/components/settings/onboarding/UserList.tsx b/app/components/settings/onboarding/UserList.tsx new file mode 100644 index 0000000..419468b --- /dev/null +++ b/app/components/settings/onboarding/UserList.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + UserListProps, + UserProject, + UserProjectListResponse, + UserProjectDeleteResponse, +} from "@/app/lib/types/onboarding"; +import { formatRelativeTime } from "@/app/lib/utils"; +import { + ArrowLeftIcon, + CheckCircleIcon, + TrashIcon, +} from "@/app/components/icons"; +import { Button } from "@/app/components"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { apiFetch } from "@/app/lib/apiClient"; +import { useToast } from "@/app/components/Toast"; +import AddUserModal from "./AddUserModal"; + +function UserListSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +export default function UserList({ + organization, + project, + onBack, +}: UserListProps) { + const toast = useToast(); + const { activeKey, currentUser } = useAuth(); + const apiKey = activeKey?.key ?? ""; + + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showAddModal, setShowAddModal] = useState(false); + const [removingId, setRemovingId] = useState(null); + + const fetchUsers = useCallback(async () => { + setIsLoading(true); + try { + const data = await apiFetch( + `/api/user-projects?project_id=${project.id}`, + apiKey, + ); + const list = data.data || []; + setUsers(list); + } catch (err) { + console.error("Failed to fetch users:", err); + } finally { + setIsLoading(false); + } + }, [apiKey, project.id]); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + const handleRemoveUser = async (userId: number) => { + setRemovingId(userId); + try { + await apiFetch( + `/api/user-projects/${userId}?project_id=${project.id}`, + apiKey, + { method: "DELETE" }, + ); + toast.success("User removed"); + setUsers((prev) => prev.filter((u) => u.user_id !== userId)); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to remove user"); + } finally { + setRemovingId(null); + } + }; + + return ( +
+ + +
+
+

+ {project.name} +

+

+ {organization.name} ·{" "} + {isLoading + ? "Loading users..." + : `${users.length} user${users.length !== 1 ? "s" : ""}`} +

+
+ {currentUser?.is_superuser && ( + + )} +
+ + {isLoading ? ( + + ) : users.length === 0 ? ( +
+ No users found for this project. +
+ ) : ( +
+ {users.map((user) => ( +
+
+

+ {user.full_name} +

+

+ {user.email} +

+

+ Added {formatRelativeTime(user.inserted_at)} +

+
+
+ + + {user.is_active ? "Active" : "Inactive"} + + {currentUser?.is_superuser && ( + + )} +
+
+ ))} +
+ )} + + setShowAddModal(false)} + organizationId={organization.id} + projectId={project.id} + apiKey={apiKey} + onUsersAdded={fetchUsers} + /> +
+ ); +} diff --git a/app/components/settings/onboarding/index.ts b/app/components/settings/onboarding/index.ts index ad930bb..b9f06c5 100644 --- a/app/components/settings/onboarding/index.ts +++ b/app/components/settings/onboarding/index.ts @@ -3,3 +3,7 @@ export { default as OnboardingSuccess } from "./OnboardingSuccess"; export { default as OrganizationList } from "./OrganizationList"; export { default as ProjectList } from "./ProjectList"; export { default as StepIndicator } from "./StepIndicator"; +export { default as UserList } from "./UserList"; +export { default as AddUserModal } from "./AddUserModal"; +export { default as AddProjectModal } from "./AddProjectModal"; +export { default as EditProjectModal } from "./EditProjectModal"; diff --git a/app/components/speech-to-text/ModelComparisonCard.tsx b/app/components/speech-to-text/ModelComparisonCard.tsx index 38ad0bb..687b30a 100644 --- a/app/components/speech-to-text/ModelComparisonCard.tsx +++ b/app/components/speech-to-text/ModelComparisonCard.tsx @@ -71,7 +71,6 @@ export default function ModelComparisonCard({ // Also reset when modelId changes (new model added) useEffect(() => { if (status === "pending") { - // eslint-disable-next-line react-hooks/set-state-in-effect setIsExpanded(false); } }, [status, modelId]); diff --git a/app/lib/types/onboarding.ts b/app/lib/types/onboarding.ts index b5cd49e..ee22401 100644 --- a/app/lib/types/onboarding.ts +++ b/app/lib/types/onboarding.ts @@ -1,3 +1,5 @@ +import React from "react"; + export interface Organization { id: number; name: string; @@ -32,6 +34,93 @@ export interface ProjectListResponse { metadata?: Record; } +export interface ProjectCreateRequest { + name: string; + description: string; + is_active: boolean; + organization_id: number; +} + +export interface ProjectResponse { + success: boolean; + data?: Project; + error?: string; + errors?: { field: string; message: string }[]; + metadata?: Record; +} + +export interface UserProject { + user_id: number; + email: string; + full_name: string; + is_active: boolean; + inserted_at: string; +} + +export interface UserProjectListResponse { + success: boolean; + data?: UserProject[]; + error?: string; + errors?: { field: string; message: string }[]; + metadata?: Record; +} + +export interface UserProjectDeleteResponse { + success: boolean; + data?: { message: string }; + error?: string; + errors?: { field: string; message: string }[]; + metadata?: Record; +} + +export interface OrganizationListProps { + organizations: Organization[]; + isLoadingMore: boolean; + onNewOrg: () => void; + onSelectOrg: (org: Organization) => void; + scrollRef: React.RefObject; +} + +export interface ProjectListProps { + organization: Organization; + projects: Project[]; + isLoading: boolean; + onBack: () => void; + onSelectProject: (project: Project) => void; + onProjectAdded: () => void; +} + +export interface UserListProps { + organization: Organization; + project: Project; + onBack: () => void; +} + +export interface AddProjectModalProps { + open: boolean; + onClose: () => void; + organizationId: number; + apiKey: string; + onProjectAdded: () => void; +} + +export interface EditProjectModalProps { + open: boolean; + onClose: () => void; + project: Project; + apiKey: string; + onProjectUpdated: () => void; +} + +export interface AddUserModalProps { + open: boolean; + onClose: () => void; + organizationId: number; + projectId: number; + apiKey: string; + onUsersAdded: () => void; +} + export interface OnboardRequest { organization_name: string; project_name: string; From 16f139ef7d538282e9360b47133e17e847443ee4 Mon Sep 17 00:00:00 2001 From: Ayush <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:35:04 +0530 Subject: [PATCH 9/9] Feat: Google Integration (#102) --- .env.example | 3 +- app/(main)/configurations/page.tsx | 28 +- .../configurations/prompt-editor/page.tsx | 8 +- app/(main)/datasets/page.tsx | 164 +- app/(main)/document/page.tsx | 17 +- app/(main)/evaluations/[id]/page.tsx | 55 +- app/(main)/evaluations/page.tsx | 97 +- app/(main)/knowledge-base/page.tsx | 1269 +++----- app/(main)/settings/credentials/page.tsx | 88 +- app/(main)/settings/onboarding/page.tsx | 13 +- app/(main)/speech-to-text/page.tsx | 2857 +---------------- app/(main)/text-to-speech/page.tsx | 2397 +------------- app/api/auth/google/route.ts | 41 + app/api/auth/logout/route.ts | 19 + app/api/document/route.ts | 11 +- app/api/user-projects/[userId]/route.ts | 25 + app/api/user-projects/route.ts | 40 + app/api/v1/auth/refresh/route.ts | 19 + app/components/Button.tsx | 8 +- app/components/ConfigCard.tsx | 4 +- app/components/ConfigModal.tsx | 53 +- app/components/GatePopover.tsx | 54 + app/components/PageHeader.tsx | 97 +- app/components/Sidebar.tsx | 290 +- app/components/SimplifiedConfigEditor.tsx | 100 +- app/components/auth/FeatureGateModal.tsx | 31 + app/components/auth/LoginModal.tsx | 99 + app/components/auth/ProtectedPage.tsx | 38 + app/components/auth/index.ts | 3 + app/components/document/DocumentListing.tsx | 36 +- app/components/document/DocumentPreview.tsx | 24 +- app/components/evaluations/DatasetsTab.tsx | 27 +- app/components/evaluations/EvaluationsTab.tsx | 33 +- app/components/icons/index.tsx | 1 + app/components/icons/sidebar/LogoutIcon.tsx | 21 + app/components/providers/Providers.tsx | 20 + app/components/providers/index.ts | 1 + app/components/settings/SettingsSidebar.tsx | 139 + .../settings/credentials/CredentialForm.tsx | 2 +- .../settings/onboarding/AddUserModal.tsx | 2 +- .../settings/onboarding/OrganizationList.tsx | 4 +- .../settings/onboarding/ProjectList.tsx | 8 +- .../settings/onboarding/UserList.tsx | 13 +- app/components/speech-to-text/AudioPlayer.tsx | 122 + .../speech-to-text/AudioPlayerFromUrl.tsx | 111 + .../speech-to-text/DatasetDescription.tsx | 40 + app/components/speech-to-text/DatasetsTab.tsx | 995 ++++++ .../speech-to-text/EvaluationsTab.tsx | 1346 ++++++++ .../text-to-speech/AudioPlayerFromUrl.tsx | 145 + .../text-to-speech/DatasetDescription.tsx | 40 + app/components/text-to-speech/DatasetsTab.tsx | 692 ++++ .../text-to-speech/EvaluationsTab.tsx | 1277 ++++++++ app/components/user-menu/Branding.tsx | 12 + app/components/user-menu/UserMenuPopover.tsx | 83 + app/components/user-menu/index.ts | 2 + app/hooks/useConfigs.ts | 22 +- app/hooks/usePaginatedList.ts | 16 +- app/layout.tsx | 10 +- app/lib/apiClient.ts | 128 +- app/lib/constants.ts | 15 +- app/lib/context/AuthContext.tsx | 129 +- app/lib/navConfig.ts | 46 + app/lib/types/auth.ts | 70 + app/lib/types/dataset.ts | 10 + app/lib/types/knowledgeBase.ts | 39 + app/lib/types/nav.ts | 53 + app/lib/types/speechToText.ts | 148 + app/lib/types/textToSpeech.ts | 117 + app/lib/utils.ts | 8 +- next.config.ts | 9 +- package-lock.json | 11 + package.json | 1 + test.json | 73 - 73 files changed, 7314 insertions(+), 6715 deletions(-) create mode 100644 app/api/auth/google/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/user-projects/[userId]/route.ts create mode 100644 app/api/user-projects/route.ts create mode 100644 app/api/v1/auth/refresh/route.ts create mode 100644 app/components/GatePopover.tsx create mode 100644 app/components/auth/FeatureGateModal.tsx create mode 100644 app/components/auth/LoginModal.tsx create mode 100644 app/components/auth/ProtectedPage.tsx create mode 100644 app/components/auth/index.ts create mode 100644 app/components/icons/sidebar/LogoutIcon.tsx create mode 100644 app/components/providers/Providers.tsx create mode 100644 app/components/providers/index.ts create mode 100644 app/components/settings/SettingsSidebar.tsx create mode 100644 app/components/speech-to-text/AudioPlayer.tsx create mode 100644 app/components/speech-to-text/AudioPlayerFromUrl.tsx create mode 100644 app/components/speech-to-text/DatasetDescription.tsx create mode 100644 app/components/speech-to-text/DatasetsTab.tsx create mode 100644 app/components/speech-to-text/EvaluationsTab.tsx create mode 100644 app/components/text-to-speech/AudioPlayerFromUrl.tsx create mode 100644 app/components/text-to-speech/DatasetDescription.tsx create mode 100644 app/components/text-to-speech/DatasetsTab.tsx create mode 100644 app/components/text-to-speech/EvaluationsTab.tsx create mode 100644 app/components/user-menu/Branding.tsx create mode 100644 app/components/user-menu/UserMenuPopover.tsx create mode 100644 app/components/user-menu/index.ts create mode 100644 app/lib/navConfig.ts create mode 100644 app/lib/types/auth.ts create mode 100644 app/lib/types/dataset.ts create mode 100644 app/lib/types/knowledgeBase.ts create mode 100644 app/lib/types/nav.ts create mode 100644 app/lib/types/speechToText.ts create mode 100644 app/lib/types/textToSpeech.ts delete mode 100644 test.json diff --git a/.env.example b/.env.example index ae81a04..3e6995f 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ -BACKEND_URL=http://localhost:8000 \ No newline at end of file +BACKEND_URL=http://localhost:8000 +NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com \ No newline at end of file diff --git a/app/(main)/configurations/page.tsx b/app/(main)/configurations/page.tsx index 1e35c43..d9243f0 100644 --- a/app/(main)/configurations/page.tsx +++ b/app/(main)/configurations/page.tsx @@ -45,8 +45,8 @@ export default function ConfigLibraryPage() { Record >({}); const { sidebarCollapsed } = useApp(); - const { activeKey } = useAuth(); - const apiKey = activeKey?.key; + const { activeKey, isAuthenticated } = useAuth(); + const apiKey = activeKey?.key ?? ""; const [searchInput, setSearchInput] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const [columnCount, setColumnCount] = useState(3); @@ -100,11 +100,11 @@ export default function ConfigLibraryPage() { useEffect(() => { const fetchEvaluationCounts = async () => { - if (!activeKey) return; + if (!isAuthenticated) return; try { const data = await apiFetch( "/api/evaluations", - activeKey.key, + apiKey, ); const jobs: EvalJob[] = Array.isArray(data) ? data : data.data || []; const counts: Record = {}; @@ -129,7 +129,7 @@ export default function ConfigLibraryPage() { await existing; return; } - if (!apiKey) return; + if (!isAuthenticated) return; const loadPromise = (async () => { const res = await apiFetch<{ @@ -144,7 +144,7 @@ export default function ConfigLibraryPage() { pendingVersionLoads.set(configId, loadPromise); await loadPromise; }, - [apiKey], + [apiKey, isAuthenticated], ); const loadSingleVersion = useCallback( @@ -152,7 +152,7 @@ export default function ConfigLibraryPage() { const key = `${configId}:${version}`; const existing = pendingSingleVersionLoads.get(key); if (existing) return existing; - if (!apiKey) return null; + if (!isAuthenticated) return null; const configPublic = configs.find((c) => c.id === configId) ?? @@ -179,7 +179,7 @@ export default function ConfigLibraryPage() { pendingSingleVersionLoads.set(key, loadPromise); return loadPromise; }, - [apiKey, configs], + [apiKey, configs, isAuthenticated], ); const handleCreateNew = () => { @@ -277,17 +277,7 @@ export default function ConfigLibraryPage() { ) : error ? (
-

{error}

- +

{error}

) : configs.length === 0 ? (
(null); - const { activeKey: apiKey } = useAuth(); - - // Pagination state + const { activeKey: apiKey, isAuthenticated } = useAuth(); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; - // Fetch datasets from backend when API key is available useEffect(() => { - if (apiKey) { + if (isAuthenticated) { fetchDatasets(); } - }, [apiKey]); + }, [apiKey, isAuthenticated]); const fetchDatasets = async () => { - if (!apiKey) { - setError("No API key found. Please add an API key in the Keystore."); + if (!isAuthenticated) { + setError("Please log in to continue."); return; } @@ -63,23 +49,10 @@ export default function Datasets() { setError(null); try { - const response = await fetch("/api/evaluations/datasets", { - method: "GET", - headers: { - "X-API-KEY": apiKey.key, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || - errorData.message || - `Failed to fetch datasets: ${response.status}`, - ); - } - - const data = await response.json(); + const data = await apiFetch( + "/api/evaluations/datasets", + apiKey?.key ?? "", + ); const datasetList = Array.isArray(data) ? data : data.data || []; setDatasets(datasetList); } catch (err: unknown) { @@ -117,49 +90,29 @@ export default function Datasets() { return; } - if (!apiKey) { - toast.error("No API key found. Please add an API key in the Keystore."); + if (!isAuthenticated) { + toast.error("Please log in to continue."); return; } setIsUploading(true); try { - // Prepare FormData for upload const formData = new FormData(); formData.append("file", selectedFile); formData.append("dataset_name", datasetName.trim()); formData.append("duplication_factor", duplicationFactor || "1"); - // Upload to backend - const response = await fetch("/api/evaluations/datasets", { + await apiFetch("/api/evaluations/datasets", apiKey?.key ?? "", { method: "POST", body: formData, - headers: { - "X-API-KEY": apiKey.key, - }, }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || - errorData.message || - `Upload failed with status ${response.status}`, - ); - } - - await response.json(); - // Refresh datasets list await fetchDatasets(); - // Reset form setSelectedFile(null); setDatasetName(""); setDuplicationFactor("1"); - - // Close modal setIsModalOpen(false); toast.success("Dataset uploaded successfully!"); @@ -174,8 +127,8 @@ export default function Datasets() { }; const handleDeleteDataset = async (datasetId: number) => { - if (!apiKey) { - toast.error("No API key found"); + if (!isAuthenticated) { + toast.error("Please log in to continue"); return; } @@ -185,23 +138,14 @@ export default function Datasets() { } try { - const response = await fetch(`/api/evaluations/datasets/${datasetId}`, { - method: "DELETE", - headers: { - "X-API-KEY": apiKey.key, + await apiFetch( + `/api/evaluations/datasets/${datasetId}`, + apiKey?.key ?? "", + { + method: "DELETE", }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || - errorData.message || - `Delete failed with status ${response.status}`, - ); - } + ); - // Refresh datasets list await fetchDatasets(); toast.success("Dataset deleted successfully"); } catch (error) { @@ -212,7 +156,6 @@ export default function Datasets() { } }; - // Pagination calculations const indexOfLastItem = currentPage * itemsPerPage; const indexOfFirstItem = indexOfLastItem - itemsPerPage; const currentDatasets = datasets.slice(indexOfFirstItem, indexOfLastItem); @@ -221,26 +164,17 @@ export default function Datasets() { const paginate = (pageNumber: number) => setCurrentPage(pageNumber); return ( -
+
- {/* Sidebar */} - {/* Main Content */}
- {/* Content Area */} -
+
setIsModalOpen(true)} isLoading={isLoading} error={error} - apiKey={apiKey} + isAuthenticated={isAuthenticated} totalPages={totalPages} currentPage={currentPage} onPageChange={paginate} @@ -258,7 +192,6 @@ export default function Datasets() {
- {/* Upload Dataset Modal */} {isModalOpen && ( void; isLoading: boolean; error: string | null; - apiKey: APIKey | null; + isAuthenticated: boolean; totalPages: number; currentPage: number; onPageChange: (page: number) => void; @@ -300,7 +233,7 @@ function DatasetListing({ onUploadNew, isLoading, error, - apiKey, + isAuthenticated, totalPages, currentPage, onPageChange, @@ -362,43 +295,10 @@ function DatasetListing({

Loading datasets...

- ) : !apiKey ? ( -
- - - -

- No API key found -

-

- Please add an API key in the Keystore to manage datasets -

- - Go to Keystore - + ) : !isAuthenticated ? ( +
+

Login required

+

Please log in to manage datasets

) : error ? (
0 && totalPages > 1 && (
("uploading"); const abortUploadRef = useRef<(() => void) | null>(null); - const { activeKey: apiKey } = useAuth(); + const { activeKey: apiKey, isAuthenticated } = useAuth(); const { items: documents, @@ -72,7 +72,7 @@ export default function DocumentPage() { }; const handleUpload = async () => { - if (!apiKey || !selectedFile) return; + if (!isAuthenticated || !selectedFile) return; setIsUploading(true); setUploadProgress(0); @@ -84,7 +84,7 @@ export default function DocumentPage() { const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>( "/api/document", - apiKey.key, + apiKey?.key ?? "", formData, (percent, phase) => { setUploadProgress(percent); @@ -121,8 +121,8 @@ export default function DocumentPage() { }; const handleDeleteDocument = async (documentId: string) => { - if (!apiKey) { - toast.error("No API key found"); + if (!isAuthenticated) { + toast.error("Please log in to continue"); return; } @@ -131,7 +131,7 @@ export default function DocumentPage() { } try { - await apiFetch(`/api/document/${documentId}`, apiKey.key, { + await apiFetch(`/api/document/${documentId}`, apiKey?.key ?? "", { method: "DELETE", }); @@ -150,13 +150,13 @@ export default function DocumentPage() { }; const handleSelectDocument = async (doc: Document) => { - if (!apiKey) return; + if (!isAuthenticated) return; setIsLoadingDocument(true); try { const data = await apiFetch<{ data?: Document }>( `/api/document/${doc.id}`, - apiKey.key, + apiKey?.key ?? "", ); const documentDetails: Document = data.data ?? (data as unknown as Document); @@ -201,7 +201,6 @@ export default function DocumentPage() { isLoading={isLoading} isLoadingMore={isLoadingMore} error={error} - apiKey={apiKey} scrollRef={scrollRef} />
diff --git a/app/(main)/evaluations/[id]/page.tsx b/app/(main)/evaluations/[id]/page.tsx index 300e61c..d2e583f 100644 --- a/app/(main)/evaluations/[id]/page.tsx +++ b/app/(main)/evaluations/[id]/page.tsx @@ -48,26 +48,16 @@ export default function EvaluationReport() { >(undefined); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const { apiKeys } = useAuth(); + const { apiKeys, isAuthenticated } = useAuth(); + const apiKey = apiKeys[0]?.key ?? ""; const { sidebarCollapsed, setSidebarCollapsed } = useApp(); - const [selectedKeyId, setSelectedKeyId] = useState(""); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [exportFormat, setExportFormat] = useState<"row" | "grouped">("row"); const [isResyncing, setIsResyncing] = useState(false); const [showNoTracesModal, setShowNoTracesModal] = useState(false); - useEffect(() => { - if (apiKeys.length > 0 && !selectedKeyId) { - setSelectedKeyId(apiKeys[0].id); - } - }, [apiKeys, selectedKeyId]); - - // Fetch job details const fetchJobDetails = useCallback(async () => { - if (!selectedKeyId || !jobId) return; - - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) return; + if (!isAuthenticated || !jobId) return; setIsLoading(true); setError(null); @@ -76,7 +66,7 @@ export default function EvaluationReport() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = await apiFetch( `/api/evaluations/${jobId}?export_format=${exportFormat}`, - selectedKey.key, + apiKey, ); if (data.success === false && data.error) { @@ -91,14 +81,10 @@ export default function EvaluationReport() { setJob(foundJob); if (foundJob.assistant_id) { - fetchAssistantConfig(foundJob.assistant_id, selectedKey.key); + fetchAssistantConfig(foundJob.assistant_id); } if (foundJob.config_id && foundJob.config_version) { - fetchConfigInfo( - foundJob.config_id, - foundJob.config_version, - selectedKey.key, - ); + fetchConfigInfo(foundJob.config_id, foundJob.config_version); } } catch (err: unknown) { setError( @@ -107,9 +93,9 @@ export default function EvaluationReport() { } finally { setIsLoading(false); } - }, [apiKeys, selectedKeyId, jobId, exportFormat]); + }, [apiKey, isAuthenticated, jobId, exportFormat]); - const fetchAssistantConfig = async (assistantId: string, apiKey: string) => { + const fetchAssistantConfig = async (assistantId: string) => { try { const result = await apiFetch<{ success: boolean; @@ -124,11 +110,7 @@ export default function EvaluationReport() { } }; - const fetchConfigInfo = async ( - configId: string, - configVersion: number, - apiKey: string, - ) => { + const fetchConfigInfo = async (configId: string, configVersion: number) => { try { await apiFetch(`/api/configs/${configId}`, apiKey); await apiFetch( @@ -141,8 +123,8 @@ export default function EvaluationReport() { }; useEffect(() => { - if (selectedKeyId && jobId) fetchJobDetails(); - }, [selectedKeyId, jobId, fetchJobDetails]); + if (isAuthenticated && jobId) fetchJobDetails(); + }, [isAuthenticated, jobId, fetchJobDetails]); // Export grouped format CSV const exportGroupedCSV = (traces: GroupedTraceItem[]) => { @@ -285,16 +267,14 @@ export default function EvaluationReport() { }; const handleResync = async () => { - if (!selectedKeyId || !jobId) return; - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) return; + if (!isAuthenticated || !jobId) return; setIsResyncing(true); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = await apiFetch( `/api/evaluations/${jobId}?get_trace_info=true&resync_score=true&export_format=${exportFormat}`, - selectedKey.key, + apiKey, ); const foundJob = data.data || data; if (!foundJob) throw new Error("Evaluation job not found"); @@ -307,14 +287,9 @@ export default function EvaluationReport() { } setJob(foundJob); - if (foundJob.assistant_id) - fetchAssistantConfig(foundJob.assistant_id, selectedKey.key); + if (foundJob.assistant_id) fetchAssistantConfig(foundJob.assistant_id); if (foundJob.config_id && foundJob.config_version) - fetchConfigInfo( - foundJob.config_id, - foundJob.config_version, - selectedKey.key, - ); + fetchConfigInfo(foundJob.config_id, foundJob.config_version); toast.success("Metrics resynced successfully"); } catch (error: unknown) { toast.error( diff --git a/app/(main)/evaluations/page.tsx b/app/(main)/evaluations/page.tsx index df6c5a5..d7900f3 100644 --- a/app/(main)/evaluations/page.tsx +++ b/app/(main)/evaluations/page.tsx @@ -8,16 +8,17 @@ "use client"; import { useState, useEffect, useCallback, Suspense } from "react"; +import { apiFetch } from "@/app/lib/apiClient"; import { colors } from "@/app/lib/colors"; import { useSearchParams } from "next/navigation"; -import { Dataset } from "@/app/(main)/datasets/page"; +import { Dataset } from "@/app/lib/types/dataset"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; import TabNavigation from "@/app/components/TabNavigation"; import { useToast } from "@/app/components/Toast"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; -import { apiFetch } from "@/app/lib/apiClient"; +import { FeatureGateModal, LoginModal } from "@/app/components/auth"; import Loader from "@/app/components/Loader"; import DatasetsTab from "@/app/components/evaluations/DatasetsTab"; import EvaluationsTab from "@/app/components/evaluations/EvaluationsTab"; @@ -38,7 +39,9 @@ function SimplifiedEvalContent() { }); const { sidebarCollapsed } = useApp(); - const { activeKey } = useAuth(); + const { activeKey, isAuthenticated } = useAuth(); + const apiKey = activeKey?.key ?? ""; + const [showLoginModal, setShowLoginModal] = useState(false); const [mounted, setMounted] = useState(false); // Dataset creation state const [datasetName, setDatasetName] = useState(""); @@ -74,15 +77,12 @@ function SimplifiedEvalContent() { }, []); const loadStoredDatasets = useCallback(async () => { - if (!activeKey?.key) { - console.error("No selected API key found for loading datasets"); - return; - } + if (!isAuthenticated) return; setIsDatasetsLoading(true); try { const data = await apiFetch( "/api/evaluations/datasets", - activeKey.key, + apiKey, ); setStoredDatasets(Array.isArray(data) ? data : data.data || []); } catch (e) { @@ -90,11 +90,11 @@ function SimplifiedEvalContent() { } finally { setIsDatasetsLoading(false); } - }, [activeKey]); + }, [apiKey, isAuthenticated]); useEffect(() => { - if (activeKey?.key) loadStoredDatasets(); - }, [activeKey, loadStoredDatasets]); + if (isAuthenticated) loadStoredDatasets(); + }, [isAuthenticated, loadStoredDatasets]); const handleFileSelect = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -159,8 +159,8 @@ function SimplifiedEvalContent() { toast.error("Please enter a dataset name"); return; } - if (!activeKey?.key) { - toast.error("No API key selected. Please select one in the Keystore."); + if (!isAuthenticated) { + toast.error("Please log in to create datasets."); return; } @@ -178,7 +178,7 @@ function SimplifiedEvalContent() { const data = await apiFetch<{ dataset_id?: number }>( "/api/evaluations/datasets", - activeKey.key, + apiKey, { method: "POST", body: formData }, ); await loadStoredDatasets(); @@ -193,7 +193,7 @@ function SimplifiedEvalContent() { setDuplicationFactor("1"); toast.success("Dataset created successfully!"); - } catch (error) { + } catch (error: unknown) { toast.error( `Failed to create dataset: ${error instanceof Error ? error.message : "Unknown error"}`, ); @@ -203,8 +203,8 @@ function SimplifiedEvalContent() { }; const handleRunEvaluation = async () => { - if (!activeKey?.key) { - toast.error("Please select an API key first"); + if (!isAuthenticated) { + toast.error("Please log in to run evaluations."); return; } if (!selectedDatasetId) { @@ -229,7 +229,7 @@ function SimplifiedEvalContent() { config_version: selectedConfigVersion, }; - await apiFetch("/api/evaluations", activeKey.key, { + await apiFetch("/api/evaluations", apiKey, { method: "POST", body: JSON.stringify(payload), }); @@ -271,51 +271,18 @@ function SimplifiedEvalContent() { /> {/* Tab Content */} - {!mounted || !activeKey ? ( -
-
- - - -

- API key required -

-

- Add an API key in the Keystore to start creating datasets and - running evaluations -

- - Go to Keystore - -
-
+ {!mounted || !isAuthenticated ? ( + <> + setShowLoginModal(true)} + /> + setShowLoginModal(false)} + /> + ) : activeTab === "datasets" ? ( ) : ( (null); - const { activeKey: apiKey } = useAuth(); + const { activeKey: apiKey, isAuthenticated } = useAuth(); const [showAllDocs, setShowAllDocs] = useState(false); // Polling refs — persist across renders, no stale closures @@ -206,7 +221,7 @@ export default function KnowledgeBasePage() { ): Promise< Map > => { - if (!apiKey) return new Map(); + if (!isAuthenticated) return new Map(); const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); @@ -240,22 +255,18 @@ export default function KnowledgeBasePage() { const results = await Promise.all( Array.from(jobIdsToFetch).map(async (jobId) => { try { - const response = await fetch(`/api/collections/jobs/${jobId}`, { - headers: { "X-API-KEY": apiKey.key }, - }); - - if (response.ok) { - const result = await response.json(); - const jobData = result.data || result; - const collectionId = - jobData.collection?.id || jobData.collection_id || null; - - return { - jobId, - status: jobData.status || null, - collectionId: collectionId, - }; - } + const result = await apiFetch< + { data?: JobStatusData } & JobStatusData + >(`/api/collections/jobs/${jobId}`, apiKey?.key ?? ""); + const jobData = result.data || result; + const collectionId = + jobData.collection?.id || jobData.collection_id || null; + + return { + jobId, + status: jobData.status || null, + collectionId: collectionId, + }; } catch (error) { console.error("Error fetching job status:", error); } @@ -272,79 +283,75 @@ export default function KnowledgeBasePage() { return jobStatusMap; }; + // Fetch collections + const fetchCollections = async () => { - if (!apiKey) return; + if (!isAuthenticated) return; setIsLoading(true); try { - const response = await fetch("/api/collections", { - headers: { "X-API-KEY": apiKey.key }, - }); - - if (response.ok) { - const result = await response.json(); - const collections = result.data || []; + const result = await apiFetch( + "/api/collections", + apiKey?.key ?? "", + ); + const collections = ( + Array.isArray(result.data) ? result.data : [] + ) as Collection[]; + + // Pre-fetch job statuses only for collections that need it + const jobStatusMap = await preFetchJobStatuses(collections); + + // Enrich collections with cached names and live status + const enrichedCollections = await Promise.all( + collections.map((collection: Collection) => + enrichCollectionWithCache(collection, jobStatusMap), + ), + ); - // Pre-fetch job statuses only for collections that need it - const jobStatusMap = await preFetchJobStatuses(collections); + // Remove cache entries whose collection no longer exists on the backend + const liveIds = new Set( + enrichedCollections.map((c: Collection) => c.id), + ); + pruneStaleCache(liveIds); - // Enrich collections with cached names and live status - const enrichedCollections = await Promise.all( - collections.map((collection: Collection) => - enrichCollectionWithCache(collection, jobStatusMap), - ), + // Preserve optimistic entries not yet replaced by a real collection + setCollections((prev) => { + const fetchedJobIds = new Set( + enrichedCollections.map((c: Collection) => c.job_id).filter(Boolean), ); - - // Remove cache entries whose collection no longer exists on the backend - const liveIds = new Set( - enrichedCollections.map((c: Collection) => c.id), + const activeOptimistic = prev.filter( + (c) => + c.id.startsWith("optimistic-") && + (!c.job_id || !fetchedJobIds.has(c.job_id)), ); - pruneStaleCache(liveIds); - - // Preserve optimistic entries not yet replaced by a real collection - setCollections((prev) => { - const fetchedJobIds = new Set( - enrichedCollections - .map((c: Collection) => c.job_id) - .filter(Boolean), - ); - const activeOptimistic = prev.filter( - (c) => - c.id.startsWith("optimistic-") && - (!c.job_id || !fetchedJobIds.has(c.job_id)), - ); - // Sort by inserted_at in descending order (latest first) - const combined = [...activeOptimistic, ...enrichedCollections]; - return combined.sort( - (a, b) => - new Date(b.inserted_at).getTime() - - new Date(a.inserted_at).getTime(), + // Sort by inserted_at in descending order (latest first) + const combined = [...activeOptimistic, ...enrichedCollections]; + return combined.sort( + (a, b) => + new Date(b.inserted_at).getTime() - + new Date(a.inserted_at).getTime(), + ); + }); + + // If selectedCollection is optimistic and the real one just arrived, fetch full details + // Extract the logic outside the updater to avoid side effects + let replacementId: string | null = null; + setSelectedCollection((prev) => { + if (prev?.id.startsWith("optimistic-") && prev.job_id) { + const replacement = enrichedCollections.find( + (c: Collection) => c.job_id === prev.job_id, ); - }); - - // If selectedCollection is optimistic and the real one just arrived, fetch full details - // Extract the logic outside the updater to avoid side effects - let replacementId: string | null = null; - setSelectedCollection((prev) => { - if (prev?.id.startsWith("optimistic-") && prev.job_id) { - const replacement = enrichedCollections.find( - (c: Collection) => c.job_id === prev.job_id, - ); - if (replacement) { - replacementId = replacement.id; - // Don't set the replacement yet - let fetchCollectionDetails do it with full data - } + if (replacement) { + replacementId = replacement.id; + // Don't set the replacement yet - let fetchCollectionDetails do it with full data } - return prev; - }); - - // Fetch full details (including documents) for the replacement - if (replacementId) { - fetchCollectionDetails(replacementId); } - } else { - const error = await response.json().catch(() => ({})); - console.error("Failed to fetch collections:", response.status, error); + return prev; + }); + + // Fetch full details (including documents) for the replacement + if (replacementId) { + fetchCollectionDetails(replacementId); } } catch (error) { console.error("Error fetching collections:", error); @@ -355,31 +362,27 @@ export default function KnowledgeBasePage() { // Fetch available documents const fetchDocuments = async () => { - if (!apiKey) return; + if (!isAuthenticated) return; try { - const response = await fetch("/api/document", { - headers: { "X-API-KEY": apiKey.key }, - }); - - if (response.ok) { - const result = await response.json(); + const result = await apiFetch( + "/api/document", + apiKey?.key ?? "", + ); - // Handle both direct array and wrapped response - const documentList = Array.isArray(result) ? result : result.data || []; + // Handle both direct array and wrapped response + const documentList = Array.isArray(result) + ? result + : (result as DocumentResponse).data || []; - // Sort by inserted_at in descending order (latest first) - const sortedDocuments = documentList.sort( - (a: Document, b: Document) => - new Date(b.inserted_at || 0).getTime() - - new Date(a.inserted_at || 0).getTime(), - ); + // Sort by inserted_at in descending order (latest first) + const sortedDocuments = documentList.sort( + (a: Document, b: Document) => + new Date(b.inserted_at || 0).getTime() - + new Date(a.inserted_at || 0).getTime(), + ); - setAvailableDocuments(sortedDocuments); - } else { - const error = await response.json().catch(() => ({})); - console.error("Failed to fetch documents:", response.status, error); - } + setAvailableDocuments(sortedDocuments); } catch (error) { console.error("Error fetching documents:", error); } @@ -387,7 +390,7 @@ export default function KnowledgeBasePage() { // Fetch collection details with documents const fetchCollectionDetails = async (collectionId: string) => { - if (!apiKey) return; + if (!isAuthenticated) return; // Don't fetch optimistic collections from the server if (collectionId.startsWith("optimistic-")) { @@ -402,53 +405,44 @@ export default function KnowledgeBasePage() { setIsLoading(true); try { - const response = await fetch(`/api/collections/${collectionId}`, { - headers: { "X-API-KEY": apiKey.key }, - }); - - if (response.ok) { - const result = await response.json(); + const result = await apiFetch( + `/api/collections/${collectionId}`, + apiKey?.key ?? "", + ); - // Handle different response formats - const collectionData = result.data || result; + // Handle different response formats + const collectionData = (result.data as Collection) || result; - // Get cached data to find the job_id - const cached = getCollectionDataByCollectionId(collectionId); + // Get cached data to find the job_id + const cached = getCollectionDataByCollectionId(collectionId); - // If we have a job_id, fetch its status - let status = undefined; - if (cached.job_id) { - try { - const jobResponse = await fetch( - `/api/collections/jobs/${cached.job_id}`, - { - headers: { "X-API-KEY": apiKey.key }, - }, - ); - if (jobResponse.ok) { - const jobResult = await jobResponse.json(); - const jobData = jobResult.data || jobResult; - status = jobData.status || undefined; - } - } catch (error) { - console.error( - "Error fetching job status for collection details:", - error, - ); - } + // If we have a job_id, fetch its status + let status = undefined; + if (cached.job_id) { + try { + const jobResult = await apiFetch< + { data?: JobStatusData } & JobStatusData + >(`/api/collections/jobs/${cached.job_id}`, apiKey?.key ?? ""); + const jobData = jobResult.data || jobResult; + status = jobData.status || undefined; + } catch (error) { + console.error( + "Error fetching job status for collection details:", + error, + ); } + } - // Enrich the collection with cached name/description and live status - const enrichedCollection = { - ...collectionData, - name: cached.name || collectionData.name || "Untitled Collection", - description: cached.description || collectionData.description || "", - status: status, - job_id: cached.job_id, - }; + // Enrich the collection with cached name/description and live status + const enrichedCollection = { + ...collectionData, + name: cached.name || collectionData.name || "Untitled Collection", + description: cached.description || collectionData.description || "", + status: status, + job_id: cached.job_id, + }; - setSelectedCollection(enrichedCollection); - } + setSelectedCollection(enrichedCollection); } catch (error) { console.error("Error fetching collection details:", error); } finally { @@ -463,7 +457,7 @@ export default function KnowledgeBasePage() { pollingRef.current = setInterval(async () => { const currentApiKey = apiKeyRef.current; - if (!currentApiKey) return; + if (!currentApiKey && !isAuthenticated) return; const jobs = activeJobsRef.current; if (jobs.size === 0) { @@ -476,12 +470,9 @@ export default function KnowledgeBasePage() { for (const [collectionId, jobId] of Array.from(jobs)) { try { - const response = await fetch(`/api/collections/jobs/${jobId}`, { - headers: { "X-API-KEY": currentApiKey.key }, - }); - if (!response.ok) continue; - - const result = await response.json(); + const result = await apiFetch< + { data?: JobStatusData } & JobStatusData + >(`/api/collections/jobs/${jobId}`, currentApiKey?.key ?? ""); const jobData = result.data || result; const status = jobData.status || null; const realCollectionId = @@ -554,8 +545,8 @@ export default function KnowledgeBasePage() { // Create knowledge base const handleCreateClick = async () => { - if (!apiKey) { - alert("No API key found"); + if (!isAuthenticated) { + alert("Please log in to continue"); return; } @@ -599,60 +590,51 @@ export default function KnowledgeBasePage() { setSelectedCollection(optimisticCollection); try { - const response = await fetch("/api/collections", { - method: "POST", - headers: { - "X-API-KEY": apiKey.key, - "Content-Type": "application/json", + const result = await apiFetch( + "/api/collections", + apiKey?.key ?? "", + { + method: "POST", + body: JSON.stringify({ + name: nameAtCreation, + description: descriptionAtCreation, + documents: docsAtCreation, + provider: "openai", + }), }, - body: JSON.stringify({ - name: nameAtCreation, - description: descriptionAtCreation, - documents: docsAtCreation, - provider: "openai", - }), - }); - - if (response.ok) { - const result = await response.json(); - const jobId = result.data?.job_id; + ); - if (jobId) { - saveCollectionData(jobId, nameAtCreation, descriptionAtCreation); + const jobId = result.data?.job_id; - // Attach job_id to the optimistic entry so polling picks it up - setCollections((prev) => - prev.map((c) => - c.id === optimisticId ? { ...c, job_id: jobId } : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === optimisticId ? { ...prev, job_id: jobId } : prev, - ); + if (jobId) { + saveCollectionData(jobId, nameAtCreation, descriptionAtCreation); - // Register for polling immediately — don't wait for the next collections render - activeJobsRef.current.set(optimisticId, jobId); - startPolling(); - } else { - console.error( - "No job ID found in response - cannot save name to cache", - ); - } + // Attach job_id to the optimistic entry so polling picks it up + setCollections((prev) => + prev.map((c) => + c.id === optimisticId ? { ...c, job_id: jobId } : c, + ), + ); + setSelectedCollection((prev) => + prev?.id === optimisticId ? { ...prev, job_id: jobId } : prev, + ); - // Refresh the real list from the backend (replaces the optimistic entry once the backend knows about it) - await fetchCollections(); + // Register for polling immediately — don't wait for the next collections render + activeJobsRef.current.set(optimisticId, jobId); + startPolling(); } else { - const error = await response.json().catch(() => ({})); - alert( - `Failed to create knowledge base: ${error.error || "Unknown error"}`, + console.error( + "No job ID found in response - cannot save name to cache", ); - // Remove the optimistic entry on failure - setCollections((prev) => prev.filter((c) => c.id !== optimisticId)); - setSelectedCollection(null); } + + // Refresh the real list from the backend (replaces the optimistic entry once the backend knows about it) + await fetchCollections(); } catch (error) { console.error("Error creating knowledge base:", error); - alert("Failed to create knowledge base"); + alert( + `Failed to create knowledge base: ${error instanceof Error ? error.message : "Unknown error"}`, + ); setCollections((prev) => prev.filter((c) => c.id !== optimisticId)); setSelectedCollection(null); } finally { @@ -662,14 +644,14 @@ export default function KnowledgeBasePage() { // Delete collection - show confirmation modal const handleDeleteCollection = (collectionId: string) => { - if (!apiKey) return; + if (!isAuthenticated) return; setCollectionToDelete(collectionId); setShowConfirmDelete(true); }; // Confirm and execute delete const handleConfirmDelete = async () => { - if (!collectionToDelete || !apiKey) return; + if (!collectionToDelete || !isAuthenticated) return; setShowConfirmDelete(false); const collectionId = collectionToDelete; @@ -689,76 +671,38 @@ export default function KnowledgeBasePage() { ); try { - const response = await fetch(`/api/collections/${collectionId}`, { - method: "DELETE", - headers: { "X-API-KEY": apiKey.key }, - }); + const result = await apiFetch( + `/api/collections/${collectionId}`, + apiKey?.key ?? "", + { method: "DELETE" }, + ); - if (response.ok) { - const result = await response.json(); - const jobId = result.data?.job_id; - - if (jobId) { - // Poll the delete job status - const pollDeleteStatus = async () => { - const currentApiKey = apiKeyRef.current; - if (!currentApiKey) return; - - try { - const jobResponse = await fetch( - `/api/collections/jobs/${jobId}`, - { - headers: { "X-API-KEY": currentApiKey.key }, - }, - ); + const jobId = result.data?.job_id; - if (jobResponse.ok) { - const jobResult = await jobResponse.json(); - const jobData = jobResult.data || jobResult; - const status = jobData.status; - const statusLower = status?.toLowerCase(); - - if (statusLower === "successful") { - // Job completed successfully - remove from UI and clean up cache - deleteCollectionFromCache(collectionId); - setCollections((prev) => - prev.filter((c) => c.id !== collectionId), - ); - setSelectedCollection(null); - } else if (statusLower === "failed") { - // Job failed - restore original collection - alert("Failed to delete collection"); - if (originalCollection) { - setCollections((prev) => - prev.map((c) => - c.id === collectionId ? originalCollection : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } - } else { - // Still processing - keep status as "deleting" and poll again - setTimeout(pollDeleteStatus, 2000); // Poll every 2 seconds - } - } else { - // Failed to get job status - alert("Failed to check delete status"); - if (originalCollection) { - setCollections((prev) => - prev.map((c) => - c.id === collectionId ? originalCollection : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } - } - } catch (error) { - console.error("Error polling delete status:", error); - alert("Failed to check delete status"); + if (jobId) { + // Poll the delete job status + const pollDeleteStatus = async () => { + const currentApiKey = apiKeyRef.current; + if (!currentApiKey) return; + + try { + const jobResult = await apiFetch< + { data?: JobStatusData } & JobStatusData + >(`/api/collections/jobs/${jobId}`, currentApiKey?.key ?? ""); + const jobData = jobResult.data || jobResult; + const status = jobData.status; + const statusLower = status?.toLowerCase(); + + if (statusLower === "successful") { + // Job completed successfully - remove from UI and clean up cache + deleteCollectionFromCache(collectionId); + setCollections((prev) => + prev.filter((c) => c.id !== collectionId), + ); + setSelectedCollection(null); + } else if (statusLower === "failed") { + // Job failed - restore original collection + alert("Failed to delete collection"); if (originalCollection) { setCollections((prev) => prev.map((c) => @@ -769,28 +713,33 @@ export default function KnowledgeBasePage() { prev?.id === collectionId ? originalCollection : prev, ); } + } else { + // Still processing - keep status as "deleting" and poll again + setTimeout(pollDeleteStatus, 2000); // Poll every 2 seconds } - }; + } catch (error) { + console.error("Error polling delete status:", error); + alert("Failed to check delete status"); + if (originalCollection) { + setCollections((prev) => + prev.map((c) => + c.id === collectionId ? originalCollection : c, + ), + ); + setSelectedCollection((prev) => + prev?.id === collectionId ? originalCollection : prev, + ); + } + } + }; - // Start polling - pollDeleteStatus(); - } else { - // No job_id returned, assume immediate success - deleteCollectionFromCache(collectionId); - setCollections((prev) => prev.filter((c) => c.id !== collectionId)); - setSelectedCollection(null); - } + // Start polling + pollDeleteStatus(); } else { - alert("Failed to delete collection"); - // Restore the original collection on failure - if (originalCollection) { - setCollections((prev) => - prev.map((c) => (c.id === collectionId ? originalCollection : c)), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } + // No job_id returned, assume immediate success + deleteCollectionFromCache(collectionId); + setCollections((prev) => prev.filter((c) => c.id !== collectionId)); + setSelectedCollection(null); } } catch (error) { console.error("Error deleting collection:", error); @@ -807,6 +756,23 @@ export default function KnowledgeBasePage() { } }; + // Fetch document details and set preview + const fetchAndPreviewDoc = async (doc: Document) => { + setPreviewDoc(doc); + if (isAuthenticated) { + try { + const data = await apiFetch( + `/api/document/${doc.id}`, + apiKey?.key ?? "", + ); + const documentDetails = (data.data || data) as Document; + setPreviewDoc(documentDetails); + } catch (err) { + console.error("Failed to fetch document details:", err); + } + } + }; + // Toggle document selection const toggleDocumentSelection = (documentId: string) => { const newSelection = new Set(selectedDocuments); @@ -819,7 +785,7 @@ export default function KnowledgeBasePage() { }; useEffect(() => { - if (apiKey) { + if (isAuthenticated) { fetchCollections(); fetchDocuments(); } @@ -828,7 +794,7 @@ export default function KnowledgeBasePage() { // Keep apiKeyRef in sync so polling always has the current key useEffect(() => { apiKeyRef.current = apiKey; - }, [apiKey]); + }, [apiKey, isAuthenticated]); // Keep fetchCollectionsRef in sync so polling always has the current function useEffect(() => { @@ -855,8 +821,8 @@ export default function KnowledgeBasePage() { } }); - if (newJobAdded && apiKey) startPolling(); - }, [collections, apiKey]); + if (newJobAdded && isAuthenticated) startPolling(); + }, [collections, isAuthenticated]); // Reset showAllDocs when selectedCollection changes useEffect(() => { @@ -874,10 +840,7 @@ export default function KnowledgeBasePage() { }, []); return ( -
+
{/* Main Content */} @@ -890,21 +853,19 @@ export default function KnowledgeBasePage() { {/* Content Area - Split View */}
{/* Left Panel - Collections List */} -
+
{/* Create Button */}
- +
@@ -917,124 +878,68 @@ export default function KnowledgeBasePage() { No knowledge bases yet. Create your first one!
) : ( -
- {collections.map((collection) => ( -
{ - setShowCreateForm(false); - setShowDocumentPicker(false); - fetchCollectionDetails(collection.id); - }} - className={`border rounded-lg p-3 cursor-pointer transition-colors ${ - selectedCollection?.id === collection.id ? "" : "" - }`} - style={{ - backgroundColor: - selectedCollection?.id === collection.id - ? "hsl(202, 100%, 95%)" - : colors.bg.primary, - borderColor: - selectedCollection?.id === collection.id - ? "hsl(202, 100%, 50%)" - : colors.border, - }} - > -
-
-
- - + {collections.map((collection) => { + const isSelected = selectedCollection?.id === collection.id; + return ( +
+
+
+ - -

- {collection.name} -

+

+ {collection.name} +

+
+ {collection.description && ( +

+ {collection.description} +

+ )} +

+ {formatDate(collection.inserted_at)} +

- {collection.description && ( -

{ + e.stopPropagation(); + handleDeleteCollection(collection.id); + }} + className="p-1.5 rounded-md border border-red-200 bg-white text-red-500 hover:bg-red-50 transition-colors shrink-0 cursor-pointer" + title="Delete Knowledge Base" > - {collection.description} -

+ + )} -

- {formatDate(collection.inserted_at)} -

- {!collection.id.startsWith("optimistic-") && ( - - )} -
-
- ))} + + ); + })}
)}
- {/* Right Panel - Create Form or Preview */}
{showCreateForm ? ( - /* Create Form */
-

+

Create Knowledge Base

{/* Name Input */}
- - setCollectionName(e.target.value)} + onChange={setCollectionName} placeholder="Enter collection name" - className="w-full px-4 py-2 rounded-md border text-sm" - style={{ - borderColor: colors.border, - backgroundColor: colors.bg.secondary, - color: colors.text.primary, - }} />
{/* Description Input */}
-