From 907f45bd2ffc57f71aaee537c469d6ec40ac9812 Mon Sep 17 00:00:00 2001 From: cosmin chauciuc Date: Fri, 5 Jun 2026 13:42:31 +0300 Subject: [PATCH] Phase 1 frontend: login flow, auth context, workspace switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of Phase 1, built against the new auth API. Session is an HTTP-only cookie, so the client never handles tokens — it sends credentials and derives auth state from /auth/me. - client.ts: withCredentials, X-Workspace-Id request header, global 401 -> drop-session handler (auth endpoints excluded to avoid loops) - AuthContext + useAuth: loads /auth/me, exposes user/workspaces/role, active-workspace selection (persisted), refresh/logout, query-cache reset on workspace switch + logout - LoginPage: provider-aware (password + register toggle, magic-link), dev token surfaced for local magic-link; MagicLinkVerifyPage for /login/verify - ProtectedRoute guard; App.tsx public /login + /login/verify, app routes gated behind auth - AppLayout header: workspace switcher, role badge, account menu with sign-out - authApi client + auth types Builds (tsc + vite) and lints clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/App.tsx | 28 ++- frontend/src/api/authApi.ts | 16 ++ frontend/src/api/client.ts | 37 ++++ .../src/components/auth/ProtectedRoute.tsx | 23 ++ frontend/src/components/layout/AppLayout.tsx | 80 ++++++- frontend/src/context/AuthContext.tsx | 76 +++++++ frontend/src/context/auth.ts | 25 +++ frontend/src/main.tsx | 5 +- frontend/src/pages/LoginPage.tsx | 207 ++++++++++++++++++ frontend/src/pages/MagicLinkVerifyPage.tsx | 54 +++++ frontend/src/types/auth.ts | 35 +++ 11 files changed, 568 insertions(+), 18 deletions(-) create mode 100644 frontend/src/api/authApi.ts create mode 100644 frontend/src/components/auth/ProtectedRoute.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/context/auth.ts create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/MagicLinkVerifyPage.tsx create mode 100644 frontend/src/types/auth.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3993778..24df0c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,8 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { AppLayout } from './components/layout/AppLayout'; +import { ProtectedRoute } from './components/auth/ProtectedRoute'; +import { LoginPage } from './pages/LoginPage'; +import { MagicLinkVerifyPage } from './pages/MagicLinkVerifyPage'; import { QueryPage } from './pages/QueryPage'; import { ConnectionsPage } from './pages/ConnectionsPage'; import { GlossaryPage } from './pages/GlossaryPage'; @@ -11,15 +14,22 @@ import { HistoryPage } from './pages/HistoryPage'; export default function App() { return ( - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Public */} + } /> + } /> + + {/* Authenticated */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ); diff --git a/frontend/src/api/authApi.ts b/frontend/src/api/authApi.ts new file mode 100644 index 0000000..26da0ba --- /dev/null +++ b/frontend/src/api/authApi.ts @@ -0,0 +1,16 @@ +import { api } from './client'; +import type { AuthProviderInfo, MagicLinkResponse, Me, User } from '../types/auth'; + +export const authApi = { + providers: () => api.get('/auth/providers').then((r) => r.data), + me: () => api.get('/auth/me').then((r) => r.data), + login: (email: string, password: string) => + api.post('/auth/login', { email, password }).then((r) => r.data), + register: (email: string, password: string, name?: string) => + api.post('/auth/register', { email, password, name }).then((r) => r.data), + requestMagicLink: (email: string) => + api.post('/auth/magic-link', { email }).then((r) => r.data), + verifyMagicLink: (token: string) => + api.post('/auth/magic-link/verify', { token }).then((r) => r.data), + logout: () => api.post('/auth/logout').then((r) => r.data), +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2893c64..1842c31 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,8 +5,42 @@ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000'; export const api = axios.create({ baseURL: `${API_BASE}/api/v1`, headers: { 'Content-Type': 'application/json' }, + // Session is an HTTP-only cookie set by the backend, so credentials must + // travel with every request (and CORS must allow credentials). + withCredentials: true, }); +// --- Active workspace ------------------------------------------------------- +// Connections + the semantic layer are scoped per workspace. The AuthContext +// keeps this in sync; the request interceptor forwards it as a header. +let activeWorkspaceId: string | null = null; + +export function setActiveWorkspaceId(id: string | null) { + activeWorkspaceId = id; +} + +api.interceptors.request.use((config) => { + if (activeWorkspaceId) { + config.headers.set('X-Workspace-Id', activeWorkspaceId); + } + return config; +}); + +// --- Unauthorized handling -------------------------------------------------- +// On a 401 the session is gone/expired; the AuthContext registers a handler +// here to drop the user and bounce to the login screen. +let onUnauthorized: (() => void) | null = null; + +export function setUnauthorizedHandler(fn: (() => void) | null) { + onUnauthorized = fn; +} + +// 401s from the auth endpoints themselves (e.g. a bad login) are expected and +// must not trigger the global logout/redirect. +function isAuthEndpoint(url?: string): boolean { + return !!url && url.includes('/auth/'); +} + // Surface real backend error messages instead of generic "Request failed with status code 500" api.interceptors.response.use( (response) => response, @@ -14,6 +48,9 @@ api.interceptors.response.use( if (error.response?.data?.error) { error.message = error.response.data.error; } + if (error.response?.status === 401 && !isAuthEndpoint(error.config?.url) && onUnauthorized) { + onUnauthorized(); + } return Promise.reject(error); }, ); diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..751e1d4 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import { Center, Loader } from '@mantine/core'; +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useAuth } from '../../context/auth'; + +/** Gate the app routes behind an authenticated session. */ +export function ProtectedRoute() { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 2d5fd56..b64b047 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,4 +1,14 @@ -import { AppShell, NavLink, Group, Title, Text } from '@mantine/core'; +import { + ActionIcon, + AppShell, + Badge, + Group, + Menu, + NavLink, + Select, + Text, + Title, +} from '@mantine/core'; import { IconMessageQuestion, IconDatabase, @@ -7,9 +17,13 @@ import { IconVocabulary, IconFileText, IconHistory, + IconLogout, + IconUserCircle, } from '@tabler/icons-react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { EmbeddingStatusBanner } from '../common/EmbeddingStatusBanner'; +import { useAuth } from '../../context/auth'; +import type { Role } from '../../types/auth'; const NAV_ITEMS = [ { label: 'Query', path: '/query', icon: IconMessageQuestion }, @@ -21,9 +35,21 @@ const NAV_ITEMS = [ { label: 'History', path: '/history', icon: IconHistory }, ]; +const ROLE_COLOR: Record = { + admin: 'grape', + editor: 'blue', + viewer: 'gray', +}; + export function AppLayout() { const location = useLocation(); const navigate = useNavigate(); + const { user, workspaces, activeWorkspace, role, setActiveWorkspace, logout } = useAuth(); + + async function handleLogout() { + await logout(); + navigate('/login', { replace: true }); + } return ( - - - QueryWise - - - Ask questions in plain English - + + + + QueryWise + + + Ask questions in plain English + + + + + {workspaces.length > 0 && ( +