Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
npm run dev # Start dev server at http://localhost:3000
npm run lint # Run ESLint
npm run build # Production build
npm run format # Format TS/tsx with Prettier
```

## Architecture Overview

### Tech Stack

- **Framework**: Next.js 16.0.7 (App Router)
- **React 19.2.0** + **TypeScript** (strict mode)
- **Routing**: Next.js App Router (react-router-dom is listed as a dependency but currently unused)
- **Styling**: Tailwind CSS 4.x + custom color system and styles defined in `/app/globals.css` for cases not supported by Tailwind
- **Data Fetching**: Native Fetch API + SWR 2.3.6 (used selectively where required)
- **Date/Time**: date-fns 4.1.0, date-fns-tz 3.2.0
- **ESLint**: Used for maintaining code quality, enforcing consistent coding standards, and catching potential issues during development and build time

### Directory Structure

| Path | Purpose |
| ------------------------------ | --------------------------------------------------------------------------------------------- |
| `app/(auth)/` | Authentication-related routes (e.g., invite, verify flows) |
| `app/(main)/` | Main application routes (dashboard-level features like datasets, evaluations, settings, etc.) |
| `app/api/` | Backend API route handlers (Next.js route handlers acting as BFF layer) |
| `app/components/` | App-scoped components used within routes/Pages |
| `app/components/icons/` | Hand-authored React icon components |
| `app/hooks/` | Custom React hooks specific to app features |
| `app/lib/` | Core shared logic and utilities across the application |
| `app/lib/context/` | React context providers (global state handling) |
| `app/lib/store/` | State management logic (custom/global store) |
| `app/lib/types/` | TypeScript type definitions (shared across modules) |
| `app/lib/utils/` | Domain-specific utility modules (e.g., evaluation, guardrails) |
| `app/lib/data/` | Static data and validators (e.g., guardrails validators) |
| `app/lib/apiClient.ts` | Centralized API client for forwarding requests to the backend |
| `app/lib/authCookie.ts` | Authentication cookie utilities (get/set/remove tokens) |
| `app/lib/configFetchers.ts` | API fetchers related to configuration modules |
| `app/lib/constants.ts` | Global constants used across the app |
| `app/lib/guardrailsClient.ts` | Client-side API helpers for guardrails features |
| `app/lib/models.ts` | Data models/interfaces for structured data handling |
| `app/lib/navConfig.ts` | Navigation configuration (sidebar/menu structure) |
| `app/lib/promptEditorUtils.ts` | Utility functions for prompt editor logic |
| `app/lib/utils.ts` | General utility/helper functions |
| `public/favicon.ico` | Application favicon |

## Import Aliases

[tsconfig.json](./tsconfig.json) sets paths: `{ "@/*": ["./*"] }`, so imports are resolved from the project root using the `@/` prefix. Use:

```
import { apiClient } from '@/app/lib/apiClient';
import { Providers } from '@/app/components/providers';
import { APP_NAME } from '@/app/lib/constants';
```

SVGs follow Next.js defaults (imported as static assets via next/image or referenced from /public).

## Routing & Role-Based Access

Routing uses the **Next.js App Router** exclusively. Routes are organized via route groups:

- `app/(auth)/` - unauthenticated flows (`/invite`, `/verify`)
- `app/(main)/` — authenticated app surface (`/evaluations`, `/datasets`, `/configurations`, `/guardrails`, `/knowledge-base`, `/settings`, etc.)

Role gating lives in middleware.ts and reads a kaapi_role cookie with two values:

- `user` - standard authenticated user
- `superuser` - admin; required for `/settings/*`

The cookie is issued server-side by [authCookie.ts](app/lib/authCookie.ts) after login/verify based on user.is_superuser. Middleware classifies each request into one of:

- `PUBLIC_ROUTES` — open to everyone (`/evaluations`, `/invite`, `/verify`, `/coming-soon/*`)
- `GUEST_ONLY_ROUTES` — unauthenticated only (`/keystore`); authenticated users are redirected to `/evaluations`
- `/settings/*` — superuser only
- Everything else — any authenticated user

There is no dynamic/custom role system; only the two static roles above.

## Toast Notifications

Toasts are managed via a React Context provider ([Toast.tsx](app/components/Toast.tsx)), mounted once in [Providers.tsx](app/components/providers/Providers.tsx). Consume them from any client component:

```
import { useToast } from '@/app/components/Toast';
// or the re-export: import { useToast } from '@/app/hooks/useToast';

function MyComponent() {
const toast = useToast();

toast.success('Saved successfully'); // success toast
toast.error('Something went wrong'); // error toast
toast.warning('Heads up'); // warning toast
toast.info('FYI'); // info toast

// Optional: override the default 5000ms auto-dismiss
toast.success('Saved', 3000);

// Low-level API (type + duration)
toast.addToast('Custom message', 'success', 4000);
}
```

## Authentication [AuthContext.tsx](app/lib/context/AuthContext.tsx)

There is no `AuthService` class. Auth state is owned by a React Context provider (`AuthProvider`) mounted in [Providers.tsx](app/components/providers/Providers.tsx), and consumed via the `useAuth()` hook:

```
import { useAuth } from '@/app/lib/context/AuthContext';

function MyComponent() {
const {
isAuthenticated, isHydrated,
session, currentUser, googleProfile,
apiKeys, activeKey, addKey, removeKey, setKeys,
loginWithToken, logout,
} = useAuth();
}
```

## App Context [AppContext.tsx](app/lib/context/AppContext.tsx)

Sidebar state is managed via `AppProvider`, consumed with `useApp()`:

```
import { useApp } from '@/app/lib/context/AppContext';

const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar } = useApp();
```

## API Client & Error Handling

The BFF layer uses [apiClient.ts](app/lib/apiClient.ts) which forwards requests from Next.js route handlers to the backend at `BACKEND_URL` (defaults to `http://localhost:8000`). Key patterns:

- **Server-side (route handlers)**: Use `apiClient(request, endpoint, options)` — it relays `X-API-KEY` and `Cookie` headers automatically and returns `{ status, data, headers }`.
- **Client-side**: Use `clientFetch(endpoint, options)` — handles token refresh on 401, dispatches `AUTH_EXPIRED_EVENT` when refresh fails, and throws with a message extracted from `error`, `message`, or `detail` fields in the response body.
- **Error extraction**: `extractErrorMessage(body, fallback)` reads `body.error || body.message || body.detail` — follow this pattern when adding new API routes.
- **Auth expiry**: On 401 with failed refresh, a `CustomEvent(AUTH_EXPIRED_EVENT)` is dispatched on `window`, which `AuthContext` listens to for automatic logout.
220 changes: 19 additions & 201 deletions app/(auth)/invite/page.tsx
Original file line number Diff line number Diff line change
@@ -1,211 +1,29 @@
"use client";

import { useEffect, useState, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/app/lib/context/AuthContext";
import { InviteVerifyResponse } from "@/app/lib/types/auth";
import {
CheckCircleIcon,
WarningIcon,
SpinnerIcon,
} from "@/app/components/icons";
import { Button } from "@/app/components";

type Status = "verifying" | "success" | "error";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { SpinnerIcon } from "@/app/components/icons";
import TokenVerifyPage from "@/app/components/auth/TokenVerifyPage";

function InviteContent() {
const searchParams = useSearchParams();
const router = useRouter();
const { loginWithToken } = useAuth();
const [status, setStatus] = useState<Status>("verifying");
const [error, setError] = useState("");
const [progress, setProgress] = useState(0);

useEffect(() => {
const token = searchParams.get("token");

if (!token) {
setStatus("error");
setError("Invalid invitation link. No token found.");
return;
}

let cancelled = false;

(async () => {
try {
const res = await fetch(
`/api/auth/invite?token=${encodeURIComponent(token)}`,
{ credentials: "include" },
);

const data: InviteVerifyResponse = await res.json();

if (cancelled) return;

if (!res.ok || !data.success || !data.data) {
setStatus("error");
setError(data.error || "Invitation link is invalid or has expired.");
return;
}

loginWithToken(data.data.access_token, data.data.user);
setStatus("success");
} catch {
if (!cancelled) {
setStatus("error");
setError("Failed to verify invitation. Please try again.");
}
}
})();

return () => {
cancelled = true;
};
}, [searchParams, loginWithToken]);

useEffect(() => {
if (status !== "success") return;

const duration = 2000;
const interval = 30;
let elapsed = 0;

const timer = setInterval(() => {
elapsed += interval;
const pct = Math.min((elapsed / duration) * 100, 100);
setProgress(pct);

if (elapsed >= duration) {
clearInterval(timer);
router.push("/evaluations");
}
}, interval);

return () => clearInterval(timer);
}, [status, router]);

return (
<div className="min-h-screen bg-bg-secondary flex flex-col items-center justify-center p-4 relative overflow-hidden">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-1/2 -right-1/4 w-[600px] h-[600px] rounded-full bg-linear-to-br from-blue-50 to-purple-50 opacity-60 blur-3xl" />
<div className="absolute -bottom-1/2 -left-1/4 w-[500px] h-[500px] rounded-full bg-linear-to-tr from-green-50 to-blue-50 opacity-40 blur-3xl" />
</div>

<div className="w-full max-w-sm relative z-10">
<div className="text-center mb-8">
<h2 className="text-lg font-semibold text-text-primary tracking-tight">
Kaapi Konsole
</h2>
<p className="text-xs text-text-secondary mt-0.5">by Tech4Dev</p>
</div>

<div
className={`bg-white rounded-2xl border shadow-sm overflow-hidden transition-all duration-500 ${
status === "error"
? "border-red-200"
: status === "success"
? "border-green-200"
: "border-border"
}`}
>
<div
className="h-1 transition-all duration-700"
style={{
background:
status === "error"
? "linear-gradient(90deg, #fca5a5, #ef4444)"
: status === "success"
? "linear-gradient(90deg, #86efac, #22c55e)"
: "linear-gradient(90deg, #dbeafe, #c7d2fe, #ddd6fe)",
}}
/>

<div className="px-8 py-10">
<div className="flex justify-center mb-5">
<div
className={`w-16 h-16 rounded-full flex items-center justify-center transition-all duration-500 ${
status === "verifying"
? "bg-neutral-50 border border-border"
: status === "success"
? "bg-green-50 border border-green-200"
: "bg-red-50 border border-red-200"
}`}
>
{status === "verifying" && (
<SpinnerIcon className="w-7 h-7 text-text-secondary animate-spin" />
)}
{status === "success" && (
<CheckCircleIcon className="w-8 h-8 text-green-600" />
)}
{status === "error" && (
<WarningIcon className="w-8 h-8 text-red-500" />
)}
</div>
</div>

{/* Title */}
<h1 className="text-center text-xl font-semibold text-text-primary mb-2">
{status === "verifying" && "Verifying invitation"}
{status === "success" && "Welcome aboard!"}
{status === "error" && "Something went wrong"}
</h1>

<p className="text-sm text-text-secondary leading-relaxed">
{status === "verifying" &&
"Please wait while we verify your invitation and set up your account."}
{status === "success" &&
"Your account has been activated. Redirecting you to the dashboard..."}
{status === "error" && error}
</p>

{status === "success" && (
<div className="mt-6 flex justify-center">
<div className="h-1 w-32 rounded-full bg-neutral-100 overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-[width] duration-75 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}

{status === "verifying" && (
<div className="mt-6 flex justify-center gap-1.5">
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-text-secondary animate-pulse"
style={{ animationDelay: `${i * 200}ms` }}
/>
))}
</div>
)}

{status === "error" && (
<div className="mt-8 space-y-3">
<Button fullWidth onClick={() => router.push("/evaluations")}>
Go to Dashboard
</Button>
<button
onClick={() => window.location.reload()}
className="w-full text-center text-xs text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
>
Try again
</button>
</div>
)}
</div>
</div>

{status === "error" && (
<p className="text-center text-xs text-text-secondary mt-5 leading-relaxed">
If this keeps happening, please contact your organization
administrator for a new invitation link.
</p>
)}
</div>
</div>
<TokenVerifyPage
token={searchParams.get("token")}
apiUrl="/api/auth/invite"
title={{
verifying: "Verifying invitation",
success: "Welcome aboard!",
error: "Something went wrong",
}}
description={{
verifying: "Hang tight — we're setting up your account.",
success: "Your account has been activated. Redirecting to dashboard...",
}}
errorFallback="Invitation link is invalid or has expired."
helpText="If this keeps happening, please contact your organization administrator for a new invitation link."
/>
);
}

Expand Down
Loading
Loading