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 01/32] 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 02/32] 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 03/32] 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 04/32] 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 05/32] 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 07/32] 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 713e9ad6b53a32d086c5f362f4bc20623567067b Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:19:57 +0530 Subject: [PATCH 08/32] feat(*): google integration flow --- .env.example | 3 +- app/(main)/configurations/page.tsx | 28 +- .../configurations/prompt-editor/page.tsx | 8 +- app/(main)/datasets/page.tsx | 134 +---- app/(main)/document/page.tsx | 17 +- app/(main)/evaluations/[id]/page.tsx | 55 +- app/(main)/evaluations/page.tsx | 161 ++--- app/(main)/knowledge-base/page.tsx | 565 ++++++++---------- app/(main)/settings/credentials/page.tsx | 34 +- app/(main)/settings/onboarding/page.tsx | 4 +- app/(main)/speech-to-text/page.tsx | 110 ++-- app/(main)/text-to-speech/page.tsx | 113 ++-- app/Providers.tsx | 20 + app/api/auth/google/route.ts | 50 ++ app/api/auth/refresh/route.ts | 19 + app/components/ConfigModal.tsx | 53 +- app/components/ConfigSelector.tsx | 2 - app/components/Modal.tsx | 2 +- app/components/PageHeader.tsx | 102 ++-- app/components/Sidebar.tsx | 289 ++++++++- app/components/SimplifiedConfigEditor.tsx | 100 ++-- app/components/auth/FeatureGateModal.tsx | 31 + app/components/auth/LoginModal.tsx | 129 ++++ app/components/auth/ProtectedPage.tsx | 38 ++ app/components/auth/index.ts | 3 + app/components/document/DocumentListing.tsx | 21 +- app/components/document/DocumentPreview.tsx | 6 +- app/components/evaluations/DatasetsTab.tsx | 44 +- app/components/evaluations/EvaluationsTab.tsx | 62 +- .../speech-to-text/ModelComparisonCard.tsx | 1 - app/hooks/useConfigs.ts | 22 +- app/hooks/usePaginatedList.ts | 16 +- app/layout.tsx | 10 +- app/lib/apiClient.ts | 106 +++- app/lib/context/AuthContext.tsx | 111 +++- app/lib/types/auth.ts | 42 ++ next.config.ts | 9 +- package-lock.json | 11 + package.json | 1 + 39 files changed, 1435 insertions(+), 1097 deletions(-) create mode 100644 app/Providers.tsx create mode 100644 app/api/auth/google/route.ts create mode 100644 app/api/auth/refresh/route.ts 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/lib/types/auth.ts 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(); + const { activeKey: apiKey, isAuthenticated } = useAuth(); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -52,12 +53,11 @@ export default function Datasets() { if (apiKey) { fetchDatasets(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKey]); 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; } @@ -65,23 +65,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) { @@ -119,49 +106,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!"); @@ -176,8 +143,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; } @@ -187,23 +154,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) { @@ -214,7 +172,6 @@ export default function Datasets() { } }; - // Pagination calculations const indexOfLastItem = currentPage * itemsPerPage; const indexOfFirstItem = indexOfLastItem - itemsPerPage; const currentDatasets = datasets.slice(indexOfFirstItem, indexOfLastItem); @@ -223,26 +180,17 @@ export default function Datasets() { const paginate = (pageNumber: number) => setCurrentPage(pageNumber); return ( -
+
- {/* Sidebar */} - {/* Main Content */}
- {/* Content Area */} -
+
- {/* Upload Dataset Modal */} {isModalOpen && ( Loading datasets...

) : !apiKey ? ( -
- - - -

- No API key found -

-

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

- - Go to Keystore - +
+

Login required

+

Please log in to manage datasets

) : error ? (
(null); const [isUploading, setIsUploading] = useState(false); - const { activeKey: apiKey } = useAuth(); + const { activeKey: apiKey, isAuthenticated } = useAuth(); const { items: documents, @@ -62,7 +62,7 @@ export default function DocumentPage() { }; const handleUpload = async () => { - if (!apiKey || !selectedFile) return; + if (!isAuthenticated || !selectedFile) return; setIsUploading(true); @@ -72,7 +72,7 @@ export default function DocumentPage() { const data = await apiFetch<{ data?: { id: string } }>( "/api/document", - apiKey.key, + apiKey?.key ?? "", { method: "POST", body: formData }, ); if (selectedFile && data.data?.id) { @@ -102,8 +102,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; } @@ -112,7 +112,7 @@ export default function DocumentPage() { } try { - await apiFetch(`/api/document/${documentId}`, apiKey.key, { + await apiFetch(`/api/document/${documentId}`, apiKey?.key ?? "", { method: "DELETE", }); @@ -131,13 +131,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); @@ -182,7 +182,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 a9a5843..808bc98 100644 --- a/app/(main)/evaluations/[id]/page.tsx +++ b/app/(main)/evaluations/[id]/page.tsx @@ -48,9 +48,9 @@ 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); @@ -73,18 +73,9 @@ export default function EvaluationReport() { return `"${sanitized}"`; }; - 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); @@ -93,7 +84,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) { @@ -108,14 +99,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( @@ -124,10 +111,9 @@ export default function EvaluationReport() { } finally { setIsLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [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; @@ -142,11 +128,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( @@ -159,8 +141,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[]) => { @@ -303,16 +285,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"); @@ -325,14 +305,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 e139f92..f00235c 100644 --- a/app/(main)/evaluations/page.tsx +++ b/app/(main)/evaluations/page.tsx @@ -8,6 +8,7 @@ "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"; @@ -17,6 +18,7 @@ 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 { 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"; @@ -37,8 +39,9 @@ function SimplifiedEvalContent() { }); const { sidebarCollapsed } = useApp(); - const { apiKeys } = useAuth(); - const [selectedKeyId, setSelectedKeyId] = useState(""); + const { apiKeys, isAuthenticated } = useAuth(); + const apiKey = apiKeys[0]?.key ?? ""; + const [showLoginModal, setShowLoginModal] = useState(false); const [mounted, setMounted] = useState(false); useEffect(() => { @@ -74,39 +77,26 @@ function SimplifiedEvalContent() { ); const [isEvaluating, setIsEvaluating] = useState(false); - // Set initial selected key from context - useEffect(() => { - if (apiKeys.length > 0 && !selectedKeyId) { - setSelectedKeyId(apiKeys[0].id); - } - }, [apiKeys, selectedKeyId]); - // Fetch datasets from backend const loadStoredDatasets = useCallback(async () => { - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) { - console.error("No selected API key found for loading datasets"); - return; - } + if (!isAuthenticated) return; setIsDatasetsLoading(true); try { - const response = await fetch("/api/evaluations/datasets", { - method: "GET", - headers: { "X-API-KEY": selectedKey.key }, - }); - if (!response.ok) return; - const data = await response.json(); + const data = await apiFetch( + "/api/evaluations/datasets", + apiKey, + ); setStoredDatasets(Array.isArray(data) ? data : data.data || []); } catch (e) { console.error("Failed to load datasets:", e); } finally { setIsDatasetsLoading(false); } - }, [apiKeys, selectedKeyId]); + }, [apiKey, isAuthenticated]); useEffect(() => { - if (apiKeys.length > 0 && selectedKeyId) loadStoredDatasets(); - }, [apiKeys, selectedKeyId, loadStoredDatasets]); + if (isAuthenticated) loadStoredDatasets(); + }, [isAuthenticated, loadStoredDatasets]); // File selection handler const handleFileSelect = (event: React.ChangeEvent) => { @@ -173,9 +163,8 @@ function SimplifiedEvalContent() { toast.error("Please enter a dataset name"); return; } - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) { - toast.error("No API key selected. Please select one in the Keystore."); + if (!isAuthenticated) { + toast.error("Please log in to create datasets."); return; } @@ -191,22 +180,14 @@ function SimplifiedEvalContent() { formData.append("duplication_factor", duplicationFactor); } - const response = await fetch("/api/evaluations/datasets", { - method: "POST", - body: formData, - headers: { "X-API-KEY": selectedKey.key }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || - errorData.message || - `Upload failed with status ${response.status}`, - ); - } - - const data = await response.json(); + const data = await apiFetch<{ dataset_id?: number }>( + "/api/evaluations/datasets", + apiKey, + { + method: "POST", + body: formData, + }, + ); await loadStoredDatasets(); if (data.dataset_id) { @@ -220,7 +201,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"}`, ); @@ -231,8 +212,8 @@ function SimplifiedEvalContent() { // Run evaluation const handleRunEvaluation = async () => { - if (!selectedKeyId) { - toast.error("Please select an API key first"); + if (!isAuthenticated) { + toast.error("Please log in to run evaluations."); return; } if (!selectedDatasetId) { @@ -248,12 +229,6 @@ function SimplifiedEvalContent() { return; } - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) { - toast.error("Selected API key not found"); - return; - } - setIsEvaluating(true); try { const payload = { @@ -263,25 +238,14 @@ function SimplifiedEvalContent() { config_version: selectedConfigVersion, }; - const response = await fetch("/api/evaluations", { + const data = await apiFetch<{ + id?: string; + data?: { id?: string }; + eval_id?: string; + }>("/api/evaluations", apiKey, { method: "POST", - headers: { - "X-API-KEY": selectedKey.key, - "Content-Type": "application/json", - }, body: JSON.stringify(payload), }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || - errorData.message || - `Evaluation failed with status ${response.status}`, - ); - } - - const data = await response.json(); const evalId = data.id || data.data?.id || data.eval_id || "unknown"; setIsEvaluating(false); @@ -323,51 +287,18 @@ function SimplifiedEvalContent() { /> {/* Tab Content */} - {!mounted || apiKeys.length === 0 ? ( -
-
- - - -

- 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" ? ( ) : ( ([]); @@ -47,7 +74,7 @@ export default function KnowledgeBasePage() { ); const [showDocPreviewModal, setShowDocPreviewModal] = useState(false); const [previewDoc, setPreviewDoc] = useState(null); - const { activeKey: apiKey } = useAuth(); + const { activeKey: apiKey, isAuthenticated } = useAuth(); const [showAllDocs, setShowAllDocs] = useState(false); // Polling refs — persist across renders, no stale closures @@ -227,7 +254,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) || "{}"); @@ -261,22 +288,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); } @@ -294,80 +317,74 @@ export default function KnowledgeBasePage() { }; // Fetch collections - // eslint-disable-next-line react-hooks/exhaustive-deps + 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); @@ -378,31 +395,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); } @@ -410,7 +423,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-")) { @@ -425,53 +438,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 { @@ -499,12 +503,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 = @@ -577,8 +578,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; } @@ -622,60 +623,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; + const jobId = result.data?.job_id; - if (jobId) { - saveCollectionData(jobId, nameAtCreation, descriptionAtCreation); + if (jobId) { + saveCollectionData(jobId, nameAtCreation, descriptionAtCreation); - // 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, - ); - - // 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 { @@ -685,14 +677,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; @@ -712,76 +704,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) => @@ -792,28 +746,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); @@ -846,7 +805,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 @@ -1385,21 +1343,15 @@ export default function KnowledgeBasePage() { if (apiKey) { try { - const response = await fetch( + const data = await apiFetch< + DocumentDetailResponse & Document + >( `/api/document/${firstDoc.id}`, - { - method: "GET", - headers: { - "X-API-KEY": apiKey.key, - }, - }, + apiKey?.key ?? "", ); - - if (response.ok) { - const data = await response.json(); - const documentDetails = data.data || data; - setPreviewDoc(documentDetails); - } + const documentDetails = (data.data || + data) as Document; + setPreviewDoc(documentDetails); } catch (err) { console.error( "Failed to fetch document details for preview:", @@ -1702,18 +1654,11 @@ export default function KnowledgeBasePage() { if (apiKey) { try { - const response = await fetch(`/api/document/${doc.id}`, { - method: "GET", - headers: { - "X-API-KEY": apiKey.key, - }, - }); - - if (response.ok) { - const data = await response.json(); - const documentDetails = data.data || data; - setPreviewDoc(documentDetails); - } + const data = await apiFetch< + DocumentDetailResponse & Document + >(`/api/document/${doc.id}`, apiKey?.key ?? ""); + const documentDetails = (data.data || data) as Document; + setPreviewDoc(documentDetails); } catch (err) { console.error( "Failed to fetch document details for preview:", diff --git a/app/(main)/settings/credentials/page.tsx b/app/(main)/settings/credentials/page.tsx index 35ce665..0888c78 100644 --- a/app/(main)/settings/credentials/page.tsx +++ b/app/(main)/settings/credentials/page.tsx @@ -22,12 +22,11 @@ import { getExistingForProvider } from "@/app/lib/utils"; import ProviderList from "@/app/components/settings/credentials/ProviderList"; import CredentialForm from "@/app/components/settings/credentials/CredentialForm"; import { apiFetch } from "@/app/lib/apiClient"; -import Link from "next/link"; export default function CredentialsPage() { const toast = useToast(); const { sidebarCollapsed, setSidebarCollapsed } = useApp(); - const { apiKeys } = useAuth(); + const { apiKeys, isAuthenticated } = useAuth(); const [selectedProvider, setSelectedProvider] = useState( PROVIDERS[0], ); @@ -43,9 +42,8 @@ export default function CredentialsPage() { // Load credentials once we have an API key useEffect(() => { - if (apiKeys.length === 0) return; + if (!isAuthenticated) return; loadCredentials(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKeys]); // Re-populate form when provider or credentials change @@ -76,11 +74,11 @@ export default function CredentialsPage() { try { const data = await apiFetch<{ data?: Credential[] } | Credential[]>( "/api/credentials", - apiKeys[0].key, + apiKeys[0]?.key ?? "", ); setCredentials(Array.isArray(data) ? data : data.data || []); - } catch (err) { - console.error("Failed to load credentials:", err); + } catch { + // Silently ignore — credentials may not exist yet or auth may be cookie-only } finally { setIsLoading(false); } @@ -101,7 +99,7 @@ export default function CredentialsPage() { }; const handleSave = async () => { - if (apiKeys.length === 0) { + if (!isAuthenticated) { toast.error("Please add an API key in Keystore first"); return; } @@ -116,13 +114,13 @@ export default function CredentialsPage() { setIsSaving(true); try { if (existingCredential) { - await apiFetch("/api/credentials", apiKeys[0].key, { + await apiFetch("/api/credentials", apiKeys[0]?.key ?? "", { method: "PATCH", body: JSON.stringify(buildCredentialBody(true)), }); toast.success(`${selectedProvider.name} credentials updated`); } else { - await apiFetch("/api/credentials", apiKeys[0].key, { + await apiFetch("/api/credentials", apiKeys[0]?.key ?? "", { method: "POST", body: JSON.stringify(buildCredentialBody(false)), }); @@ -159,12 +157,12 @@ export default function CredentialsPage() { }; const handleDelete = async () => { - if (!existingCredential || apiKeys.length === 0) return; + if (!existingCredential || !isAuthenticated) return; setIsDeleting(true); try { await apiFetch( `/api/credentials/${selectedProvider.credentialKey}`, - apiKeys[0].key, + apiKeys[0]?.key ?? "", { method: "DELETE" }, ); toast.success(`${selectedProvider.name} credentials removed`); @@ -260,7 +258,7 @@ export default function CredentialsPage() { />
- {apiKeys.length === 0 ? ( + {!isAuthenticated ? (
- No API key found. Please add one in{" "} - - Keystore - {" "} - first. + Please log in to manage credentials.
) : ( ("loading"); const [selectedOrg, setSelectedOrg] = useState(null); const [projects, setProjects] = useState([]); @@ -100,7 +100,7 @@ export default function OnboardingPage() { // Redirect if no API key or not a superuser useEffect(() => { if (!isHydrated) return; - if (!activeKey) { + if (!isAuthenticated) { router.replace("/"); return; } diff --git a/app/(main)/speech-to-text/page.tsx b/app/(main)/speech-to-text/page.tsx index 7b76edf..115d4a1 100644 --- a/app/(main)/speech-to-text/page.tsx +++ b/app/(main)/speech-to-text/page.tsx @@ -340,7 +340,7 @@ export default function SpeechToTextPage() { const [activeTab, setActiveTab] = useState("datasets"); const { sidebarCollapsed } = useApp(); const [leftPanelWidth] = useState(450); - const { apiKeys } = useAuth(); + const { apiKeys, isAuthenticated } = useAuth(); const [languages, setLanguages] = useState([]); const [datasetName, setDatasetName] = useState(""); const [datasetDescription, setDatasetDescription] = useState(""); @@ -376,11 +376,11 @@ export default function SpeechToTextPage() { // Load languages const loadLanguages = async () => { - if (apiKeys.length === 0) return; + if (!isAuthenticated) return; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = await apiFetch("/api/languages", apiKeys[0].key); + const data = await apiFetch("/api/languages", apiKeys[0]?.key ?? ""); // eslint-disable-next-line @typescript-eslint/no-explicit-any let rawList: any[] = []; @@ -421,14 +421,14 @@ export default function SpeechToTextPage() { // Load datasets const loadDatasets = async () => { - if (apiKeys.length === 0) return; + if (!isAuthenticated) return; setIsLoadingDatasets(true); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = await apiFetch( "/api/evaluations/stt/datasets", - apiKeys[0].key, + apiKeys[0]?.key ?? "", ); let datasetsList = []; @@ -452,14 +452,14 @@ export default function SpeechToTextPage() { // Load evaluation runs const loadRuns = async () => { - if (apiKeys.length === 0) return; + if (!isAuthenticated) return; setIsLoadingRuns(true); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = await apiFetch( "/api/evaluations/stt/runs", - apiKeys[0].key, + apiKeys[0]?.key ?? "", ); let runsList = []; @@ -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 @@ -498,7 +497,7 @@ export default function SpeechToTextPage() { if (!files || files.length === 0) return; - if (apiKeys.length === 0) { + if (!isAuthenticated) { toast.error("Please add an API key in Keystore first"); return; } @@ -548,7 +547,7 @@ export default function SpeechToTextPage() { file_id?: string; id?: string; data?: { file_id?: string; id?: string }; - }>("/api/evaluations/stt/files", apiKeys[0].key, { + }>("/api/evaluations/stt/files", apiKeys[0]?.key ?? "", { method: "POST", body: formData, }); @@ -619,7 +618,7 @@ export default function SpeechToTextPage() { return; } - if (apiKeys.length === 0) { + if (!isAuthenticated) { toast.error("Please add an API key in Keystore first"); return; } @@ -648,10 +647,14 @@ export default function SpeechToTextPage() { samples: samples, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - await apiFetch("/api/evaluations/stt/datasets", apiKeys[0].key, { - method: "POST", - body: JSON.stringify(payload), - }); + await apiFetch( + "/api/evaluations/stt/datasets", + apiKeys[0]?.key ?? "", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); toast.success(`Dataset "${datasetName}" created successfully!`); @@ -672,7 +675,7 @@ export default function SpeechToTextPage() { }; const handleRunEvaluation = async () => { - if (apiKeys.length === 0) { + if (!isAuthenticated) { toast.error("Please add an API key in Keystore first"); return; } @@ -691,7 +694,7 @@ export default function SpeechToTextPage() { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any - await apiFetch("/api/evaluations/stt/runs", apiKeys[0].key, { + await apiFetch("/api/evaluations/stt/runs", apiKeys[0]?.key ?? "", { method: "POST", body: JSON.stringify({ run_name: evaluationName.trim(), @@ -728,7 +731,7 @@ export default function SpeechToTextPage() { // Load results for a specific run const loadResults = async (runId: number) => { - if (apiKeys.length === 0) return; + if (!isAuthenticated) return; setIsLoadingResults(true); try { @@ -736,7 +739,7 @@ export default function SpeechToTextPage() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const runData = await apiFetch( `/api/evaluations/stt/runs/${runId}?include_results=true&include_signed_url=true`, - apiKeys[0].key, + apiKeys[0]?.key ?? "", ); // Extract results @@ -822,49 +825,25 @@ export default function SpeechToTextPage() { /> {/* Tab Content */} - {apiKeys.length === 0 ? ( + {!isAuthenticated ? (
- - -

- API key required + Authentication required

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

- - Go to Keystore -
) : activeTab === "datasets" ? ( @@ -1024,6 +1003,7 @@ function DatasetsTab({ languages, toast, }: DatasetsTabProps) { + const { isAuthenticated } = useAuth(); const [showLanguageInfo, setShowLanguageInfo] = useState(false); const [languageInfoPos, setLanguageInfoPos] = useState({ top: 0, left: 0 }); const [viewingId, setViewingId] = useState(null); @@ -1044,7 +1024,7 @@ function DatasetsTab({ const [savingSampleId, setSavingSampleId] = useState(null); const handleViewDataset = async (datasetId: number, datasetName: string) => { - if (apiKeys.length === 0) return; + if (!isAuthenticated) return; setViewingId(datasetId); try { const data = await apiFetch<{ @@ -1073,7 +1053,7 @@ function DatasetsTab({ }[]; }>( `/api/evaluations/stt/datasets/${datasetId}?include_samples=true&include_signed_url=true&sample_limit=100&sample_offset=0`, - apiKeys[0].key, + apiKeys[0]?.key ?? "", ); const samples = data?.data?.samples || data?.samples || []; if (samples.length === 0) { @@ -1095,13 +1075,13 @@ function DatasetsTab({ field: "ground_truth" | "language_id", value: string | number, ) => { - if (!viewModalData || apiKeys.length === 0) return; + if (!viewModalData || !isAuthenticated) return; setSavingSampleId(sampleId); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any await apiFetch( `/api/evaluations/stt/samples/${sampleId}`, - apiKeys[0].key, + apiKeys[0]?.key ?? "", { method: "PATCH", body: JSON.stringify({ [field]: value }), @@ -1320,20 +1300,20 @@ function DatasetsTab({ {audioFiles.length === 0 ? (
0 ? triggerAudioUpload : undefined} + onClick={isAuthenticated ? triggerAudioUpload : undefined} className="border-2 border-dashed rounded-lg p-6 text-center transition-colors" style={{ borderColor: colors.border, backgroundColor: colors.bg.primary, - cursor: apiKeys.length > 0 ? "pointer" : "not-allowed", - opacity: apiKeys.length > 0 ? 1 : 0.5, + cursor: isAuthenticated ? "pointer" : "not-allowed", + opacity: isAuthenticated ? 1 : 0.5, }} onMouseEnter={(e) => - apiKeys.length > 0 && + isAuthenticated && (e.currentTarget.style.backgroundColor = colors.bg.secondary) } onMouseLeave={(e) => - apiKeys.length > 0 && + isAuthenticated && (e.currentTarget.style.backgroundColor = colors.bg.primary) } > @@ -1355,7 +1335,7 @@ function DatasetsTab({ className="text-xs font-medium mb-1" style={{ color: colors.text.primary }} > - {apiKeys.length > 0 + {isAuthenticated ? "Click to upload audio samples" : "Add an API key to upload"}

@@ -1541,14 +1521,13 @@ function DatasetsTab({ {/* Upload more - below scrollable area */} - {children ?? ( -
- {title && ( -

- {title} -

- )} - {subtitle && ( -

- {subtitle} -

- )} -
- )} + <> +
+
+ + {children ?? ( +
+ {title && ( +

+ {title} +

+ )} + {subtitle && ( +

+ {subtitle} +

+ )} +
+ )} +
+
+ {actions} + {!isAuthenticated && ( + + )} +
- {actions &&
{actions}
} -
+ + setShowLoginModal(false)} + /> + ); } diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 359c320..dcec97c 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -5,8 +5,10 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; +import Image from "next/image"; import { useAuth } from "@/app/lib/context/AuthContext"; import { ClipboardIcon, @@ -17,6 +19,8 @@ import { KeyIcon, ChevronRightIcon, } from "@/app/components/icons"; +import { LoginModal } from "@/app/components/auth"; +import { Button } from "@/app/components"; interface SubMenuItem { name: string; @@ -30,6 +34,7 @@ interface MenuItem { route?: string; icon: React.ReactNode; submenu?: SubMenuItem[]; + gateDescription?: string; } interface SidebarProps { @@ -37,16 +42,85 @@ interface SidebarProps { activeRoute?: string; } +/** Routes that are always accessible without auth */ +const PUBLIC_ROUTES = new Set(["/evaluations", "/keystore"]); + +// ---- Gate Popover (rendered via portal) ---- + +function GatePopover({ + name, + description, + anchorRect, + onMouseEnter, + onMouseLeave, + onLogin, +}: { + name: string; + description: string; + anchorRect: DOMRect; + onMouseEnter: () => void; + onMouseLeave: () => void; + onLogin: () => void; +}) { + return createPortal( +
+
+ {/* Gradient banner */} +
+ + {/* Content */} +
+

+ {name} +

+

+ {description} +

+ +
+ +
+
+
+
, + document.body, + ); +} + +// ---- Main Sidebar ---- + export default function Sidebar({ collapsed, activeRoute = "/evaluations", }: SidebarProps) { const router = useRouter(); - const { currentUser } = useAuth(); + const { currentUser, googleProfile, isAuthenticated, session, logout } = + useAuth(); + const isGoogleUser = !!session?.accessToken; const [expandedMenus, setExpandedMenus] = useState>({ Evaluations: true, Configurations: false, }); + const [showLoginModal, setShowLoginModal] = useState(false); + const [hoveredGate, setHoveredGate] = useState(null); + const [gateRect, setGateRect] = useState(null); + const gateTimeoutRef = useRef | null>(null); useEffect(() => { const saved = localStorage.getItem("sidebar-expanded-menus"); @@ -65,6 +139,38 @@ export default function Sidebar({ localStorage.setItem("sidebar-expanded-menus", JSON.stringify(newState)); }; + const handleGateEnter = useCallback((name: string, el: HTMLElement) => { + if (gateTimeoutRef.current) clearTimeout(gateTimeoutRef.current); + setHoveredGate(name); + setGateRect(el.getBoundingClientRect()); + }, []); + + const handleGateLeave = useCallback(() => { + gateTimeoutRef.current = setTimeout(() => { + setHoveredGate(null); + setGateRect(null); + }, 200); + }, []); + + const handleGatePopoverEnter = useCallback(() => { + if (gateTimeoutRef.current) clearTimeout(gateTimeoutRef.current); + }, []); + + const isRouteGated = (route?: string): boolean => { + if (isAuthenticated) return false; + if (!route) return false; + return !PUBLIC_ROUTES.has(route); + }; + + const isItemGated = (item: MenuItem): boolean => { + if (isAuthenticated) return false; + if (item.route) return isRouteGated(item.route); + if (item.submenu) { + return item.submenu.every((sub) => isRouteGated(sub.route)); + } + return false; + }; + const navItems: MenuItem[] = [ { name: "Evaluations", @@ -74,16 +180,20 @@ export default function Sidebar({ { name: "Speech-to-Text", route: "/speech-to-text" }, { name: "Text-to-Speech", route: "/text-to-speech" }, ], + gateDescription: + "Log in to compare model response quality across different configs.", }, { name: "Documents", route: "/document", icon: , + gateDescription: "Log in to upload and manage your documents.", }, { name: "Knowledge Base", route: "/knowledge-base", icon: , + gateDescription: "Log in to manage your knowledge bases for RAG.", }, { name: "Configurations", @@ -92,6 +202,7 @@ export default function Sidebar({ { name: "Library", route: "/configurations" }, { name: "Prompt Editor", route: "/configurations/prompt-editor" }, ], + gateDescription: "Log in to manage prompts and model configurations.", }, ...(currentUser?.is_superuser ? [ @@ -107,15 +218,26 @@ export default function Sidebar({ : []), ]; - const bottomItem: MenuItem = { - name: "Keystore", - route: "/keystore", - icon: , + // Find the gate description for the currently hovered item + const getGateDescription = (name: string): string => { + for (const item of navItems) { + if (item.name === name) + return ( + item.gateDescription || `Log in to access ${name.toLowerCase()}.` + ); + if (item.submenu) { + for (const sub of item.submenu) { + if (sub.name === name) + return `Log in to access ${name.toLowerCase()}.`; + } + } + } + return "Log in to access this feature."; }; return ( ); } diff --git a/app/components/SimplifiedConfigEditor.tsx b/app/components/SimplifiedConfigEditor.tsx index aace472..a65fbf6 100644 --- a/app/components/SimplifiedConfigEditor.tsx +++ b/app/components/SimplifiedConfigEditor.tsx @@ -14,6 +14,8 @@ import { useState, useEffect } from "react"; import ConfigDrawer from "./ConfigDrawer"; import { useToast } from "./Toast"; +import { apiFetch } from "@/app/lib/apiClient"; +import { useAuth } from "@/app/lib/context/AuthContext"; import { ConfigPublic, ConfigVersionPublic, @@ -24,6 +26,7 @@ import { ConfigListResponse, ConfigWithVersionResponse, ConfigVersionListResponse, + ConfigVersionResponse, } from "@/app/lib/types/configs"; import { colors } from "../lib/colors"; @@ -80,20 +83,8 @@ export default function SimplifiedConfigEditor({ const [isLoading, setIsLoading] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [commitMessage, setCommitMessage] = useState(""); - - // Get API key from localStorage - const getApiKey = (): string | null => { - try { - const stored = localStorage.getItem("kaapi_api_keys"); - if (stored) { - const keys = JSON.parse(stored); - return keys.length > 0 ? keys[0].key : null; - } - } catch (e) { - console.error("Failed to get API key:", e); - } - return null; - }; + const { activeKey, isAuthenticated } = useAuth(); + const apiKeyStr = activeKey?.key ?? ""; // Flatten config versions for UI const flattenConfigVersion = ( @@ -143,21 +134,15 @@ export default function SimplifiedConfigEditor({ useEffect(() => { const fetchConfigs = async () => { setIsLoading(true); - const apiKey = getApiKey(); - if (!apiKey) { - console.warn( - "No API key found. Please add an API key in the Keystore.", - ); + if (!isAuthenticated) { setIsLoading(false); return; } + const apiKey = apiKeyStr; try { // Fetch all configs - const response = await fetch("/api/configs", { - headers: { "X-API-KEY": apiKey }, - }); - const data: ConfigListResponse = await response.json(); + const data = await apiFetch("/api/configs", apiKey); if (!data.success || !data.data) { console.error("Failed to fetch configs:", data.error); @@ -169,24 +154,19 @@ export default function SimplifiedConfigEditor({ const allVersions: SavedConfig[] = []; for (const config of data.data) { try { - const versionsResponse = await fetch( + const versionsData = await apiFetch( `/api/configs/${config.id}/versions`, - { - headers: { "X-API-KEY": apiKey }, - }, + apiKey, ); - const versionsData: ConfigVersionListResponse = - await versionsResponse.json(); if (versionsData.success && versionsData.data) { // Fetch full version details for each version for (const versionItem of versionsData.data) { try { - const versionResponse = await fetch( + const versionData = await apiFetch( `/api/configs/${config.id}/versions/${versionItem.version}`, - { headers: { "X-API-KEY": apiKey } }, + apiKey, ); - const versionData = await versionResponse.json(); if (versionData.success && versionData.data) { allVersions.push( @@ -260,11 +240,11 @@ export default function SimplifiedConfigEditor({ return; } - const apiKey = getApiKey(); - if (!apiKey) { - toast.error("No API key found. Please add an API key in the Keystore."); + if (!isAuthenticated) { + toast.error("Please log in to save configurations."); return; } + const apiKey = apiKeyStr; try { // Build config blob @@ -312,20 +292,15 @@ export default function SimplifiedConfigEditor({ `Updated to ${modelName} with temperature ${temperature}`, }; - const response = await fetch( + const data = await apiFetch( `/api/configs/${existingConfig.config_id}/versions`, + apiKey, { method: "POST", - headers: { - "X-API-KEY": apiKey, - "Content-Type": "application/json", - }, body: JSON.stringify(versionCreate), }, ); - const data = await response.json(); - if (!data.success) { toast.error( `Failed to create version: ${data.error || "Unknown error"}`, @@ -345,16 +320,14 @@ export default function SimplifiedConfigEditor({ commit_message: commitMessage.trim() || "Initial version", }; - const response = await fetch("/api/configs", { - method: "POST", - headers: { - "X-API-KEY": apiKey, - "Content-Type": "application/json", + const data = await apiFetch( + "/api/configs", + apiKey, + { + method: "POST", + body: JSON.stringify(configCreate), }, - body: JSON.stringify(configCreate), - }); - - const data: ConfigWithVersionResponse = await response.json(); + ); if (!data.success || !data.data) { toast.error( @@ -367,32 +340,27 @@ export default function SimplifiedConfigEditor({ } // Refresh configs list - const response = await fetch("/api/configs", { - headers: { "X-API-KEY": apiKey }, - }); - const data: ConfigListResponse = await response.json(); + const refreshData = await apiFetch( + "/api/configs", + apiKey, + ); - if (data.success && data.data) { + if (refreshData.success && refreshData.data) { const allVersions: SavedConfig[] = []; - for (const config of data.data) { + for (const config of refreshData.data) { try { - const versionsResponse = await fetch( + const versionsData = await apiFetch( `/api/configs/${config.id}/versions`, - { - headers: { "X-API-KEY": apiKey }, - }, + apiKey, ); - const versionsData: ConfigVersionListResponse = - await versionsResponse.json(); if (versionsData.success && versionsData.data) { for (const versionItem of versionsData.data) { try { - const versionResponse = await fetch( + const versionData = await apiFetch( `/api/configs/${config.id}/versions/${versionItem.version}`, - { headers: { "X-API-KEY": apiKey } }, + apiKey, ); - const versionData = await versionResponse.json(); if (versionData.success && versionData.data) { allVersions.push( diff --git a/app/components/auth/FeatureGateModal.tsx b/app/components/auth/FeatureGateModal.tsx new file mode 100644 index 0000000..2e0b821 --- /dev/null +++ b/app/components/auth/FeatureGateModal.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Button } from "@/app/components"; + +interface FeatureGateModalProps { + feature: string; + description: string; + onLogin: () => void; +} + +export default function FeatureGateModal({ + feature, + description, + onLogin, +}: FeatureGateModalProps) { + return ( +
+
+

{feature}

+

+ {description} +

+
+ +
+
+
+ ); +} diff --git a/app/components/auth/LoginModal.tsx b/app/components/auth/LoginModal.tsx new file mode 100644 index 0000000..d285f88 --- /dev/null +++ b/app/components/auth/LoginModal.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { GoogleLogin, CredentialResponse } from "@react-oauth/google"; +import Modal from "@/app/components/Modal"; +import { Button, Field } from "@/app/components"; +import { useAuth, User, GoogleProfile } from "@/app/lib/context/AuthContext"; +import { useToast } from "@/app/components/Toast"; +import { apiFetch } from "@/app/lib/apiClient"; + +interface LoginModalProps { + open: boolean; + onClose: () => void; +} + +export default function LoginModal({ open, onClose }: LoginModalProps) { + const { addKey, loginWithGoogle } = useAuth(); + const toast = useToast(); + const [apiKey, setApiKey] = useState(""); + const [isLoggingIn, setIsLoggingIn] = useState(false); + + const handleGoogleSuccess = async ( + credentialResponse: CredentialResponse, + ) => { + const token = credentialResponse.credential; + if (!token) { + toast.error("No credential received from Google."); + return; + } + + setIsLoggingIn(true); + + try { + const data = await apiFetch<{ + access_token: string; + token_type: string; + user: User; + google_profile: GoogleProfile; + }>("/api/auth/google", "", { + method: "POST", + body: JSON.stringify({ token }), + }); + + loginWithGoogle(data.access_token, data.user, data.google_profile); + + toast.success("Logged in successfully!"); + onClose(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to connect to server.", + ); + } finally { + setIsLoggingIn(false); + } + }; + + const handleApiKeySubmit = () => { + if (!apiKey.trim()) { + toast.error("Please enter an API key."); + return; + } + addKey({ + id: crypto.randomUUID(), + label: "API Key", + key: apiKey.trim(), + provider: "default", + createdAt: new Date().toISOString(), + }); + toast.success("API key added successfully!"); + setApiKey(""); + onClose(); + }; + + return ( + +
+

+ Log in or connect +

+

+ Sign in to access all features including evaluations, configurations, + and more. +

+
+ +
+ {/* Google Login */} +
+ {isLoggingIn ? ( +
+ Signing in... +
+ ) : ( + toast.error("Google login failed.")} + width="400" + shape="pill" + text="continue_with" + size="large" + /> + )} +
+ + {/* Divider */} +
+
+ OR +
+
+ + {/* API Key */} +
+ +
+ + +
+ + ); +} diff --git a/app/components/auth/ProtectedPage.tsx b/app/components/auth/ProtectedPage.tsx new file mode 100644 index 0000000..73b5c1c --- /dev/null +++ b/app/components/auth/ProtectedPage.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useState, ReactNode } from "react"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { FeatureGateModal, LoginModal } from "@/app/components/auth"; + +interface ProtectedPageProps { + feature: string; + description: string; + children: ReactNode; +} + +export default function ProtectedPage({ + feature, + description, + children, +}: ProtectedPageProps) { + const { isAuthenticated } = useAuth(); + const [showLoginModal, setShowLoginModal] = useState(false); + + if (!isAuthenticated) { + return ( + <> + setShowLoginModal(true)} + /> + setShowLoginModal(false)} + /> + + ); + } + + return <>{children}; +} diff --git a/app/components/auth/index.ts b/app/components/auth/index.ts new file mode 100644 index 0000000..366a852 --- /dev/null +++ b/app/components/auth/index.ts @@ -0,0 +1,3 @@ +export { default as LoginModal } from "./LoginModal"; +export { default as FeatureGateModal } from "./FeatureGateModal"; +export { default as ProtectedPage } from "./ProtectedPage"; diff --git a/app/components/document/DocumentListing.tsx b/app/components/document/DocumentListing.tsx index 296f0c6..edec9b1 100644 --- a/app/components/document/DocumentListing.tsx +++ b/app/components/document/DocumentListing.tsx @@ -1,11 +1,10 @@ "use client"; -import { APIKey } from "@/app/lib/types/credentials"; import { formatDate } from "@/app/components/utils"; import { Document } from "@/app/(main)/document/page"; +import { useAuth } from "@/app/lib/context/AuthContext"; import { RefreshIcon, - KeyIcon, DocumentFileIcon, TrashIcon, } from "@/app/components/icons"; @@ -19,7 +18,6 @@ interface DocumentListingProps { isLoading: boolean; isLoadingMore: boolean; error: string | null; - apiKey: APIKey | null; scrollRef: React.RefObject; } @@ -32,9 +30,9 @@ export function DocumentListing({ isLoading, isLoadingMore, error, - apiKey, scrollRef, }: DocumentListingProps) { + const { isAuthenticated } = useAuth(); return (
@@ -61,21 +59,12 @@ export function DocumentListing({

Loading documents...

- ) : !apiKey ? ( + ) : !isAuthenticated ? (
-

- No API key found + Login required

-

- Please add an API key in the Keystore -

- - Go to Keystore - +

Please log in to manage documents

) : error ? (
diff --git a/app/components/document/DocumentPreview.tsx b/app/components/document/DocumentPreview.tsx index fb62d00..96d8ff8 100644 --- a/app/components/document/DocumentPreview.tsx +++ b/app/components/document/DocumentPreview.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import Image from "next/image"; import { formatDate } from "@/app/components/utils"; import { Document } from "@/app/(main)/document/page"; import { @@ -135,10 +136,13 @@ export function DocumentPreview({ document, isLoading }: DocumentPreviewProps) {

) : ( - {document.fname} { setImageLoadError(true); }} diff --git a/app/components/evaluations/DatasetsTab.tsx b/app/components/evaluations/DatasetsTab.tsx index f89fd29..cc39df1 100644 --- a/app/components/evaluations/DatasetsTab.tsx +++ b/app/components/evaluations/DatasetsTab.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useRef } from "react"; import { colors } from "@/app/lib/colors"; -import { APIKey } from "@/app/lib/types/credentials"; import { Dataset } from "@/app/(main)/datasets/page"; import { useToast } from "@/app/components/Toast"; +import { apiFetch } from "@/app/lib/apiClient"; import EvalDatasetDescription from "./EvalDatasetDescription"; import Loader from "@/app/components/Loader"; @@ -24,8 +24,7 @@ export interface DatasetsTabProps { resetForm: () => void; storedDatasets: Dataset[]; isDatasetsLoading: boolean; - apiKeys: APIKey[]; - selectedKeyId: string; + apiKey: string; loadStoredDatasets: () => void; toast: ReturnType; } @@ -46,8 +45,7 @@ export default function DatasetsTab({ resetForm, storedDatasets, isDatasetsLoading, - apiKeys, - selectedKeyId, + apiKey, loadStoredDatasets, toast, }: DatasetsTabProps) { @@ -73,24 +71,17 @@ export default function DatasetsTab({ }, [showDuplicationInfo]); const handleDeleteDataset = async (datasetId: number) => { - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) return; - setDeletingId(datasetId); try { - const response = await fetch(`/api/evaluations/datasets/${datasetId}`, { + await apiFetch(`/api/evaluations/datasets/${datasetId}`, apiKey, { method: "DELETE", - headers: { "X-API-KEY": selectedKey.key }, }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || "Failed to delete dataset"); - } toast.success("Dataset deleted"); loadStoredDatasets(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { - toast.error(err.message || "Failed to delete dataset"); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to delete dataset", + ); } finally { setDeletingId(null); } @@ -106,23 +97,16 @@ export default function DatasetsTab({ } | null>(null); const handleViewDataset = async (datasetId: number, datasetName: string) => { - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) return; - setViewingId(datasetId); try { - const response = await fetch( + const data = await apiFetch<{ + data?: { signed_url?: string }; + signed_url?: string; + csv_content?: string; + }>( `/api/evaluations/datasets/${datasetId}?include_signed_url=true&fetch_content=true`, - { - method: "GET", - headers: { "X-API-KEY": selectedKey.key }, - }, + apiKey, ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || "Failed to get dataset"); - } - const data = await response.json(); const signedUrl = data?.data?.signed_url || data?.signed_url; const csvText = data?.csv_content; if (!csvText) { diff --git a/app/components/evaluations/EvaluationsTab.tsx b/app/components/evaluations/EvaluationsTab.tsx index 76f6401..d6c06c0 100644 --- a/app/components/evaluations/EvaluationsTab.tsx +++ b/app/components/evaluations/EvaluationsTab.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { apiFetch } from "@/app/lib/apiClient"; import { colors } from "@/app/lib/colors"; import { Dataset } from "@/app/(main)/datasets/page"; import { EvalJob, AssistantConfig } from "@/app/components/types"; @@ -8,14 +9,13 @@ import ConfigSelector from "@/app/components/ConfigSelector"; import Loader from "@/app/components/Loader"; import EvalRunCard from "./EvalRunCard"; import EvalDatasetDescription from "./EvalDatasetDescription"; -import { APIKey } from "@/app/lib/types/credentials"; +import { useAuth } from "@/app/lib/context/AuthContext"; type Tab = "datasets" | "evaluations"; export interface EvaluationsTabProps { leftPanelWidth: number; - apiKeys: APIKey[]; - selectedKeyId: string; + apiKey: string; storedDatasets: Dataset[]; selectedDatasetId: string; setSelectedDatasetId: (id: string) => void; @@ -31,8 +31,7 @@ export interface EvaluationsTabProps { export default function EvaluationsTab({ leftPanelWidth, - apiKeys, - selectedKeyId, + apiKey, storedDatasets, selectedDatasetId, setSelectedDatasetId, @@ -64,34 +63,19 @@ export default function EvaluationsTab({ !isEvaluating; // Fetch evaluation jobs + const { isAuthenticated } = useAuth(); + const fetchEvaluations = useCallback(async () => { - if (!selectedKeyId) { - setError("Please select an API key first"); - return; - } - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) { - setError("Selected API key not found"); - return; - } + if (!isAuthenticated) return; setIsLoading(true); setError(null); try { - const response = await fetch("/api/evaluations", { - method: "GET", - headers: { "X-API-KEY": selectedKey.key }, - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || - errorData.message || - `Failed to fetch evaluations: ${response.status}`, - ); - } - const data = await response.json(); + const data = await apiFetch( + "/api/evaluations", + apiKey, + ); setEvalJobs(Array.isArray(data) ? data : data.data || []); } catch (err: unknown) { setError( @@ -100,25 +84,21 @@ export default function EvaluationsTab({ } finally { setIsLoading(false); } - }, [apiKeys, selectedKeyId]); + }, [apiKey, isAuthenticated]); // Fetch assistant config const fetchAssistantConfig = useCallback( async (assistantId: string) => { - if (!selectedKeyId) return; - const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); - if (!selectedKey) return; + if (!isAuthenticated) return; try { - const response = await fetch(`/api/assistant/${assistantId}`, { - method: "GET", - headers: { "X-API-KEY": selectedKey.key }, - }); - if (!response.ok) return; - const result = await response.json(); + const result = await apiFetch<{ + success: boolean; + data?: AssistantConfig; + }>(`/api/assistant/${assistantId}`, apiKey); if (result.success && result.data) { setAssistantConfigs((prev) => - new Map(prev).set(assistantId, result.data), + new Map(prev).set(assistantId, result.data!), ); } } catch (err) { @@ -128,7 +108,7 @@ export default function EvaluationsTab({ ); } }, - [apiKeys, selectedKeyId], + [apiKey, isAuthenticated], ); useEffect(() => { @@ -140,8 +120,8 @@ export default function EvaluationsTab({ }, [evalJobs, assistantConfigs, fetchAssistantConfig]); useEffect(() => { - if (selectedKeyId) fetchEvaluations(); - }, [selectedKeyId, fetchEvaluations]); + if (isAuthenticated) fetchEvaluations(); + }, [isAuthenticated, fetchEvaluations]); return (
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/hooks/useConfigs.ts b/app/hooks/useConfigs.ts index 5613802..2e9e5c0 100644 --- a/app/hooks/useConfigs.ts +++ b/app/hooks/useConfigs.ts @@ -69,15 +69,15 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { const [error, setError] = useState(null); const [isCached, setIsCached] = useState(false); const [totalKnownCount, setTotalKnownCount] = useState(0); - const { activeKey, isHydrated } = useAuth(); - const apiKey = activeKey?.key; + const { activeKey, isHydrated, isAuthenticated } = useAuth(); + const apiKey = activeKey?.key ?? ""; const fetchConfigs = useCallback( async (force: boolean = false) => { if (!isHydrated) return; - if (!apiKey) { - setError("No API key found. Please add an API key in the Keystore."); + if (!isAuthenticated) { + setError("Please log in to continue."); setIsLoading(false); return; } @@ -239,7 +239,7 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { return; } - if (!apiKey) return; + if (!isAuthenticated) return; const loadPromise = (async () => { const versionsData = await apiFetch<{ @@ -288,7 +288,7 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { const existing = pendingSingleVersionLoads.get(key); if (existing) return existing; - if (!apiKey) return null; + if (!isAuthenticated) return null; const configSource = configs.find((c) => c.config_id === config_id); // Fall back to the lightweight allConfigMeta when the config hasn't been detail-fetched yet @@ -364,8 +364,8 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { const loadMoreConfigs = useCallback(async () => { if (!configState.allConfigMeta || configState.allConfigMeta.length === 0) return; - const apiKey = activeKey?.key; - if (!apiKey) return; + const localApiKey = activeKey?.key ?? ""; + if (!isAuthenticated) return; const loadedIds = new Set( (configState.inMemoryCache?.configs ?? configs).map((c) => c.config_id), @@ -384,7 +384,11 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { configState.pendingLoadMore = (async () => { const { newVersions, newVersionCounts, newConfigMeta } = - await fetchNextConfigBatch(apiKey, loadedIds, pageSize ?? PAGE_SIZE); + await fetchNextConfigBatch( + localApiKey, + loadedIds, + pageSize ?? PAGE_SIZE, + ); setConfigs((prev) => { const merged = [...prev, ...newVersions]; diff --git a/app/hooks/usePaginatedList.ts b/app/hooks/usePaginatedList.ts index 21f64d0..dc038f2 100644 --- a/app/hooks/usePaginatedList.ts +++ b/app/hooks/usePaginatedList.ts @@ -49,8 +49,8 @@ export function usePaginatedList(options: { limit = DEFAULT_PAGE_LIMIT, extraParams, } = options; - const { activeKey, isHydrated } = useAuth(); - const apiKey = activeKey?.key; + const { activeKey, isHydrated, isAuthenticated } = useAuth(); + const apiKey = activeKey?.key ?? ""; const [items, setItems] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -63,8 +63,8 @@ export function usePaginatedList(options: { const fetchPage = useCallback( async (skip: number, replace: boolean) => { - if (!apiKey) { - setError("No API key found. Please add an API key in the Keystore."); + if (!isAuthenticated) { + setError("Please log in to continue."); return; } @@ -91,13 +91,13 @@ export function usePaginatedList(options: { setHasMore(data.metadata?.has_more ?? false); skipRef.current = skip + newItems.length; }, - [apiKey, endpoint, query, limit, extraParams], + [apiKey, isAuthenticated, endpoint, query, limit, extraParams], ); useEffect(() => { if (!isHydrated) return; - if (!apiKey) { - setError("No API key found. Please add an API key in the Keystore."); + if (!isAuthenticated) { + setError("Please log in to continue."); setIsLoading(false); return; } @@ -121,7 +121,7 @@ export function usePaginatedList(options: { return () => { cancelled = true; }; - }, [fetchPage, isHydrated, apiKey]); + }, [fetchPage, isHydrated, apiKey, isAuthenticated]); const loadMore = useCallback(() => { if (loadingMoreRef.current || !hasMore || isLoading) return; diff --git a/app/layout.tsx b/app/layout.tsx index 48db982..00a016c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import { ToastProvider } from "@/app/components/Toast"; -import { AuthProvider } from "@/app/lib/context/AuthContext"; -import { AppProvider } from "@/app/lib/context/AppContext"; +import Providers from "./Providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -30,11 +28,7 @@ export default function RootLayout({ - - - {children} - - + {children} ); diff --git a/app/lib/apiClient.ts b/app/lib/apiClient.ts index 790743e..4d62510 100644 --- a/app/lib/apiClient.ts +++ b/app/lib/apiClient.ts @@ -4,8 +4,8 @@ const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000"; /** * Passthrough proxy helper for Next.js route handlers. - * Extracts X-API-KEY from the incoming request and forwards it to the backend. - * Returns raw { status, data } so the route handler can relay the exact HTTP status. + * Extracts X-API-KEY and cookies from the incoming request and forwards them to the backend. + * Returns raw { status, data, headers } so the route handler can relay the exact HTTP status. */ export async function apiClient( request: NextRequest | Request, @@ -13,48 +13,122 @@ export async function apiClient( options: RequestInit = {}, ) { const apiKey = request.headers.get("X-API-KEY") || ""; + const cookie = request.headers.get("Cookie") || ""; const headers = new Headers(options.headers); - // Don't set Content-Type for FormData — the browser sets it with the boundary if (!(options.body instanceof FormData)) { headers.set("Content-Type", "application/json"); } headers.set("X-API-KEY", apiKey); + if (cookie) headers.set("Cookie", cookie); const response = await fetch(`${BACKEND_URL}${endpoint}`, { ...options, headers, + credentials: "include", }); - // 204 No Content has no body const text = response.status === 204 ? "" : await response.text(); const data = text ? JSON.parse(text) : null; - return { status: response.status, data }; + return { status: response.status, data, headers: response.headers }; +} + +/** + * Dispatched when both the access token AND refresh token are expired / + * invalid. AuthContext listens for this and triggers logout. + */ +export const AUTH_EXPIRED_EVENT = "kaapi:auth-expired"; + +/** + * Singleton refresh promise so concurrent 401s don't fire multiple + * refresh requests — they all await the same in-flight call. + */ +let refreshPromise: Promise | null = null; + +async function tryRefreshToken(): Promise { + if (refreshPromise) return refreshPromise; + + refreshPromise = (async () => { + try { + const res = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + }); + return res.ok; + } catch { + return false; + } + })().finally(() => { + refreshPromise = null; + }); + + return refreshPromise; } /** * Client-side fetch helper for Next.js route handlers (/api/*). - * Attaches the X-API-KEY header and throws on non-OK responses. - * Use this in "use client" pages instead of raw fetch calls. + * Attaches the X-API-KEY header and includes credentials for cookie-based auth. + * + * On a 401 response it automatically attempts a token refresh via + * `/api/auth/refresh`. If the refresh succeeds the original request is + * retried once. If the refresh also fails, a `kaapi:auth-expired` event + * is dispatched so AuthContext can trigger logout. */ export async function apiFetch( url: string, apiKey: string, options: RequestInit = {}, ): Promise { - const headers = new Headers(options.headers); - if (!(options.body instanceof FormData)) { - headers.set("Content-Type", "application/json"); - } - headers.set("X-API-KEY", apiKey); + const buildHeaders = () => { + const headers = new Headers(options.headers); + if (!(options.body instanceof FormData)) { + headers.set("Content-Type", "application/json"); + } + headers.set("X-API-KEY", apiKey); + return headers; + }; + const res = await fetch(url, { ...options, - headers, + headers: buildHeaders(), + credentials: "include", }); - const data = await res.json(); - if (!res.ok) + + // Happy path + if (res.ok) return (await res.json()) as T; + + // Not a 401 — throw immediately + if (res.status !== 401) { + const data = await res.json(); throw new Error( data.error || data.message || `Request failed: ${res.status}`, ); - return data as T; + } + + // 401 — attempt a silent token refresh + const refreshed = await tryRefreshToken(); + + if (refreshed) { + const retry = await fetch(url, { + ...options, + headers: buildHeaders(), + credentials: "include", + }); + const retryData = await retry.json(); + if (retry.ok) return retryData as T; + + throw new Error( + retryData.error || retryData.message || `Request failed: ${retry.status}`, + ); + } + + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT)); + } + const data = await res.json().catch(() => ({})); + throw new Error( + (data as Record).error || + (data as Record).message || + "Session expired. Please log in again.", + ); } diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx index c977f09..fbf59a1 100644 --- a/app/lib/context/AuthContext.tsx +++ b/app/lib/context/AuthContext.tsx @@ -8,27 +8,17 @@ import { useEffect, } from "react"; import { APIKey } from "@/app/lib/types/credentials"; -import { apiFetch } from "@/app/lib/apiClient"; +import { + User, + GoogleProfile, + Session, + AuthContextValue, +} from "@/app/lib/types/auth"; +import { apiFetch, AUTH_EXPIRED_EVENT } from "@/app/lib/apiClient"; +export type { User, GoogleProfile, Session } from "@/app/lib/types/auth"; 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; -} +const SESSION_KEY = "kaapi_session"; const AuthContext = createContext(null); @@ -36,37 +26,63 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [apiKeys, setApiKeys] = useState([]); const [isHydrated, setIsHydrated] = useState(false); const [currentUser, setCurrentUser] = useState(null); + const [session, setSession] = useState(null); - // Initialize from localStorage after hydration to avoid SSR mismatch. + // Initialize from localStorage after hydration useEffect(() => { try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) setApiKeys(JSON.parse(stored)); + const storedKeys = localStorage.getItem(STORAGE_KEY); + if (storedKeys) setApiKeys(JSON.parse(storedKeys)); + + const storedSession = localStorage.getItem(SESSION_KEY); + if (storedSession) { + const parsed = JSON.parse(storedSession) as Session; + setSession(parsed); + if (parsed.user) setCurrentUser(parsed.user); + } } catch { /* ignore malformed data */ } setIsHydrated(true); }, []); + // Always fetch the latest user profile from the backend on hydration. useEffect(() => { - const apiKey = apiKeys[0]?.key; - if (!apiKey || !isHydrated) return; + if (!isHydrated) return; + const hasApiKey = !!apiKeys[0]?.key; + const hasSession = !!session; + if (!hasApiKey && !hasSession) return; let cancelled = false; (async () => { try { - const data = await apiFetch("/api/users/me", apiKey); - if (!cancelled) setCurrentUser(data); + const data = await apiFetch( + "/api/users/me", + apiKeys[0]?.key ?? "", + ); + if (!cancelled) { + setCurrentUser(data); + const storedRaw = localStorage.getItem(SESSION_KEY); + if (storedRaw) { + try { + const stored = JSON.parse(storedRaw); + stored.user = data; + localStorage.setItem(SESSION_KEY, JSON.stringify(stored)); + } catch { + /* ignore */ + } + } + } } catch { - // silently ignore — user info is non-critical + // silently ignore } })(); return () => { cancelled = true; }; - }, [apiKeys, isHydrated]); + }, [apiKeys, session, isHydrated]); const persist = useCallback((keys: APIKey[]) => { setApiKeys(keys); @@ -87,16 +103,53 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); const setKeys = useCallback((keys: APIKey[]) => persist(keys), [persist]); + const loginWithGoogle = useCallback( + (accessToken: string, user?: User, googleProfile?: GoogleProfile) => { + const newSession: Session = { + accessToken, + user: user ?? null, + googleProfile: googleProfile ?? null, + }; + setSession(newSession); + localStorage.setItem(SESSION_KEY, JSON.stringify(newSession)); + + if (user) setCurrentUser(user); + }, + [], + ); + + const logout = useCallback(() => { + setSession(null); + setCurrentUser(null); + localStorage.removeItem(SESSION_KEY); + persist([]); + }, [persist]); + + // logout when both access + refresh tokens are expired + useEffect(() => { + const handleExpired = () => logout(); + window.addEventListener(AUTH_EXPIRED_EVENT, handleExpired); + return () => window.removeEventListener(AUTH_EXPIRED_EVENT, handleExpired); + }, [logout]); + + const activeKey = apiKeys[0] ?? null; + const isAuthenticated = !!activeKey || !!session; + return ( {children} diff --git a/app/lib/types/auth.ts b/app/lib/types/auth.ts new file mode 100644 index 0000000..6a2f84b --- /dev/null +++ b/app/lib/types/auth.ts @@ -0,0 +1,42 @@ +import { APIKey } from "./credentials"; + +export interface User { + id: number; + email: string; + full_name: string; + is_active: boolean; + is_superuser: boolean; +} + +export interface GoogleProfile { + email: string; + name: string; + picture: string; + given_name: string; + family_name: string; +} + +export interface Session { + accessToken: string; + user: User | null; + googleProfile: GoogleProfile | null; +} + +export interface AuthContextValue { + apiKeys: APIKey[]; + activeKey: APIKey | null; + isHydrated: boolean; + currentUser: User | null; + googleProfile: GoogleProfile | null; + session: Session | null; + isAuthenticated: boolean; + addKey: (key: APIKey) => void; + removeKey: (id: string) => void; + setKeys: (keys: APIKey[]) => void; + loginWithGoogle: ( + accessToken: string, + user?: User, + googleProfile?: GoogleProfile, + ) => void; + logout: () => void; +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..5a2c26e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "lh3.googleusercontent.com", + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 48bbccf..3aa4d57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "kaapi-frontend", "version": "0.1.0", "dependencies": { + "@react-oauth/google": "^0.13.4", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "next": "^16.0.7", @@ -1250,6 +1251,16 @@ "node": ">=12.4.0" } }, + "node_modules/@react-oauth/google": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz", + "integrity": "sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", diff --git a/package.json b/package.json index 38e2ba0..e65d7f7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "prepare": "husky" }, "dependencies": { + "@react-oauth/google": "^0.13.4", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "next": "^16.0.7", From 06acfdc444f9f7c4f7fe2d124a7e9c2b062c687b Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:22:19 +0530 Subject: [PATCH 09/32] feat(*): added the logout api endpoint --- app/api/auth/logout/route.ts | 19 +++++++++++++++++++ app/lib/context/AuthContext.tsx | 10 +++++++++- app/lib/types/auth.ts | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 app/api/auth/logout/route.ts diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..3b5994f --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function POST(request: NextRequest) { + const { status, data, headers } = await apiClient( + request, + "/api/v1/auth/logout", + { method: "POST" }, + ); + + const res = NextResponse.json(data, { status }); + + const setCookies = headers.getSetCookie?.() ?? []; + for (const cookie of setCookies) { + res.headers.append("Set-Cookie", cookie); + } + + return res; +} diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx index fbf59a1..f78f796 100644 --- a/app/lib/context/AuthContext.tsx +++ b/app/lib/context/AuthContext.tsx @@ -118,7 +118,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { [], ); - const logout = useCallback(() => { + const logout = useCallback(async () => { + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch { + // Clear local state even if the backend call fails + } setSession(null); setCurrentUser(null); localStorage.removeItem(SESSION_KEY); diff --git a/app/lib/types/auth.ts b/app/lib/types/auth.ts index 6a2f84b..0c535ed 100644 --- a/app/lib/types/auth.ts +++ b/app/lib/types/auth.ts @@ -38,5 +38,5 @@ export interface AuthContextValue { user?: User, googleProfile?: GoogleProfile, ) => void; - logout: () => void; + logout: () => Promise; } From b2ef7833842f48069a313eeb55c346fd458fcaf7 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:24:57 +0530 Subject: [PATCH 10/32] fix(*): align the bottom action items tab --- app/(main)/speech-to-text/page.tsx | 2 +- app/(main)/text-to-speech/page.tsx | 2 +- app/components/evaluations/DatasetsTab.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(main)/speech-to-text/page.tsx b/app/(main)/speech-to-text/page.tsx index 115d4a1..a6b114e 100644 --- a/app/(main)/speech-to-text/page.tsx +++ b/app/(main)/speech-to-text/page.tsx @@ -1556,7 +1556,7 @@ function DatasetsTab({ {/* Bottom Action Bar */}
Date: Wed, 1 Apr 2026 21:46:03 +0530 Subject: [PATCH 11/32] fix(*): UI updates and users flow --- app/(main)/settings/credentials/page.tsx | 55 +--- app/(main)/settings/onboarding/page.tsx | 33 +- app/api/user-projects/[userId]/route.ts | 25 ++ app/api/user-projects/route.ts | 40 +++ app/components/GatePopover.tsx | 54 +++ app/components/Modal.tsx | 2 +- app/components/Sidebar.tsx | 247 ++++---------- app/components/auth/LoginModal.tsx | 63 ++-- app/components/icons/index.tsx | 1 + app/components/icons/sidebar/LogoutIcon.tsx | 21 ++ app/{ => components/providers}/Providers.tsx | 0 app/components/providers/index.ts | 1 + app/components/settings/SettingsSidebar.tsx | 139 ++++++++ .../settings/credentials/CredentialForm.tsx | 2 +- .../settings/onboarding/ProjectList.tsx | 32 +- .../settings/onboarding/UserList.tsx | 308 ++++++++++++++++++ app/components/settings/onboarding/index.ts | 1 + app/components/user-menu/Branding.tsx | 12 + app/components/user-menu/UserMenuPopover.tsx | 83 +++++ app/components/user-menu/index.ts | 2 + app/layout.tsx | 2 +- app/lib/navConfig.ts | 46 +++ app/lib/types/nav.ts | 53 +++ app/lib/types/onboarding.ts | 24 ++ 24 files changed, 948 insertions(+), 298 deletions(-) create mode 100644 app/api/user-projects/[userId]/route.ts create mode 100644 app/api/user-projects/route.ts create mode 100644 app/components/GatePopover.tsx create mode 100644 app/components/icons/sidebar/LogoutIcon.tsx rename app/{ => components/providers}/Providers.tsx (100%) create mode 100644 app/components/providers/index.ts create mode 100644 app/components/settings/SettingsSidebar.tsx create mode 100644 app/components/settings/onboarding/UserList.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/nav.ts diff --git a/app/(main)/settings/credentials/page.tsx b/app/(main)/settings/credentials/page.tsx index 0888c78..c80364e 100644 --- a/app/(main)/settings/credentials/page.tsx +++ b/app/(main)/settings/credentials/page.tsx @@ -8,11 +8,10 @@ "use client"; import { useState, useEffect } from "react"; -import Sidebar from "@/app/components/Sidebar"; +import SettingsSidebar from "@/app/components/settings/SettingsSidebar"; import { colors } from "@/app/lib/colors"; import { useToast } from "@/app/components/Toast"; import { useAuth } from "@/app/lib/context/AuthContext"; -import { useApp } from "@/app/lib/context/AppContext"; import { PROVIDERS, Credential, @@ -25,7 +24,6 @@ import { apiFetch } from "@/app/lib/apiClient"; export default function CredentialsPage() { const toast = useToast(); - const { sidebarCollapsed, setSidebarCollapsed } = useApp(); const { apiKeys, isAuthenticated } = useAuth(); const [selectedProvider, setSelectedProvider] = useState( PROVIDERS[0], @@ -198,10 +196,7 @@ export default function CredentialsPage() { style={{ backgroundColor: colors.bg.secondary }} >
- +
-
- -
-

- Settings -

-

- Manage provider credentials -

-
+ Credentials + +

+ Manage provider credentials +

diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index ea01344..3b7f22d 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -2,9 +2,8 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; -import Sidebar from "@/app/components/Sidebar"; +import SettingsSidebar from "@/app/components/settings/SettingsSidebar"; import PageHeader from "@/app/components/PageHeader"; -import { useApp } from "@/app/lib/context/AppContext"; import { useAuth } from "@/app/lib/context/AuthContext"; import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import { @@ -13,6 +12,7 @@ import { OrganizationList, ProjectList, StepIndicator, + UserList, } from "@/app/components/settings/onboarding"; import { Organization, @@ -25,7 +25,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 ( @@ -60,10 +60,10 @@ function OrganizationListSkeleton() { export default function OnboardingPage() { const router = useRouter(); - const { sidebarCollapsed } = useApp(); const { activeKey, currentUser, isHydrated, isAuthenticated } = 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( @@ -139,22 +139,30 @@ export default function OnboardingPage() { 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/api/user-projects/[userId]/route.ts b/app/api/user-projects/[userId]/route.ts new file mode 100644 index 0000000..b793306 --- /dev/null +++ b/app/api/user-projects/[userId]/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> }, +) { + try { + const { userId } = await params; + const { searchParams } = new URL(request.url); + const queryString = searchParams.toString(); + + const { status, data } = await apiClient( + request, + `/api/v1/user-projects/${userId}${queryString ? `?${queryString}` : ""}`, + { method: "DELETE" }, + ); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/user-projects/route.ts b/app/api/user-projects/route.ts new file mode 100644 index 0000000..cfff7e0 --- /dev/null +++ b/app/api/user-projects/route.ts @@ -0,0 +1,40 @@ +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/user-projects/${queryString ? `?${queryString}` : ""}`, + ); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { status, data } = await apiClient( + request, + "/api/v1/user-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/GatePopover.tsx b/app/components/GatePopover.tsx new file mode 100644 index 0000000..0ed1a0b --- /dev/null +++ b/app/components/GatePopover.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { createPortal } from "react-dom"; +import { Button } from "@/app/components"; +import { GatePopoverProps } from "@/app/lib/types/nav"; + +export default function GatePopover({ + name, + description, + anchorRect, + onMouseEnter, + onMouseLeave, + onLogin, +}: GatePopoverProps) { + return createPortal( +
+
+ {/* Gradient banner */} +
+ + {/* Content */} +
+

+ {name} +

+

+ {description} +

+ +
+ +
+
+
+
, + document.body, + ); +} diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index bb767e1..c2591dc 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -58,7 +58,7 @@ export default function Modal({ {showClose && ( diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index dcec97c..6d623ab 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -6,7 +6,6 @@ "use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; -import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { useAuth } from "@/app/lib/context/AuthContext"; @@ -15,109 +14,30 @@ import { DocumentFileIcon, BookOpenIcon, GearIcon, - SlidersIcon, - KeyIcon, ChevronRightIcon, } from "@/app/components/icons"; import { LoginModal } from "@/app/components/auth"; -import { Button } from "@/app/components"; - -interface SubMenuItem { - name: string; - route?: string; - comingSoon?: boolean; - submenu?: SubMenuItem[]; -} - -interface MenuItem { - name: string; - route?: string; - icon: React.ReactNode; - submenu?: SubMenuItem[]; - gateDescription?: string; -} - -interface SidebarProps { - collapsed: boolean; - activeRoute?: string; -} +import { Branding, UserMenuPopover } from "@/app/components/user-menu"; +import GatePopover from "@/app/components/GatePopover"; +import { NAV_ITEMS } from "@/app/lib/navConfig"; +import { MenuItem, SidebarProps } from "@/app/lib/types/nav"; /** Routes that are always accessible without auth */ -const PUBLIC_ROUTES = new Set(["/evaluations", "/keystore"]); - -// ---- Gate Popover (rendered via portal) ---- - -function GatePopover({ - name, - description, - anchorRect, - onMouseEnter, - onMouseLeave, - onLogin, -}: { - name: string; - description: string; - anchorRect: DOMRect; - onMouseEnter: () => void; - onMouseLeave: () => void; - onLogin: () => void; -}) { - return createPortal( -
-
- {/* Gradient banner */} -
- - {/* Content */} -
-

- {name} -

-

- {description} -

- -
- -
-
-
-
, - document.body, - ); -} - -// ---- Main Sidebar ---- +const PUBLIC_ROUTES = new Set(["/evaluations"]); export default function Sidebar({ collapsed, activeRoute = "/evaluations", }: SidebarProps) { const router = useRouter(); - const { currentUser, googleProfile, isAuthenticated, session, logout } = - useAuth(); - const isGoogleUser = !!session?.accessToken; + const { currentUser, googleProfile, isAuthenticated, logout } = useAuth(); const [expandedMenus, setExpandedMenus] = useState>({ Evaluations: true, Configurations: false, }); const [showLoginModal, setShowLoginModal] = useState(false); + const [showUserMenu, setShowUserMenu] = useState(false); + const userMenuRef = useRef(null); const [hoveredGate, setHoveredGate] = useState(null); const [gateRect, setGateRect] = useState(null); const gateTimeoutRef = useRef | null>(null); @@ -133,6 +53,21 @@ export default function Sidebar({ } }, []); + // Close user menu on click outside + useEffect(() => { + if (!showUserMenu) return; + const handleClickOutside = (e: MouseEvent) => { + if ( + userMenuRef.current && + !userMenuRef.current.contains(e.target as Node) + ) { + setShowUserMenu(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showUserMenu]); + const toggleMenu = (menuName: string) => { const newState = { ...expandedMenus, [menuName]: !expandedMenus[menuName] }; setExpandedMenus(newState); @@ -171,54 +106,23 @@ export default function Sidebar({ return false; }; - const navItems: MenuItem[] = [ - { - name: "Evaluations", - icon: , - submenu: [ - { name: "Text", route: "/evaluations" }, - { name: "Speech-to-Text", route: "/speech-to-text" }, - { name: "Text-to-Speech", route: "/text-to-speech" }, - ], - gateDescription: - "Log in to compare model response quality across different configs.", - }, - { - name: "Documents", - route: "/document", - icon: , - gateDescription: "Log in to upload and manage your documents.", - }, - { - name: "Knowledge Base", - route: "/knowledge-base", - icon: , - gateDescription: "Log in to manage your knowledge bases for RAG.", - }, - { - name: "Configurations", - icon: , - submenu: [ - { name: "Library", route: "/configurations" }, - { name: "Prompt Editor", route: "/configurations/prompt-editor" }, - ], - gateDescription: "Log in to manage prompts and model configurations.", - }, - ...(currentUser?.is_superuser - ? [ - { - name: "Settings", - icon: , - submenu: [ - { name: "Credentials", route: "/settings/credentials" }, - { name: "Onboarding", route: "/settings/onboarding" }, - ], - }, - ] - : []), - ]; + const iconMap: Record = { + clipboard: , + document: , + book: , + gear: , + }; + + const navItems: MenuItem[] = NAV_ITEMS.filter( + (item) => !item.superuserOnly || currentUser?.is_superuser, + ).map((item) => ({ + name: item.name, + route: item.route, + icon: iconMap[item.icon], + submenu: item.submenu, + gateDescription: item.gateDescription, + })); - // Find the gate description for the currently hovered item const getGateDescription = (name: string): string => { for (const item of navItems) { if (item.name === name) @@ -239,12 +143,7 @@ export default function Sidebar({