Skip to content
Closed
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
28 changes: 19 additions & 9 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,15 +14,22 @@ import { HistoryPage } from './pages/HistoryPage';
export default function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/query" replace />} />
<Route path="/query" element={<QueryPage />} />
<Route path="/connections" element={<ConnectionsPage />} />
<Route path="/glossary" element={<GlossaryPage />} />
<Route path="/metrics" element={<MetricsPage />} />
<Route path="/dictionary" element={<DictionaryPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/history" element={<HistoryPage />} />
{/* Public */}
<Route path="/login" element={<LoginPage />} />
<Route path="/login/verify" element={<MagicLinkVerifyPage />} />

{/* Authenticated */}
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/query" replace />} />
<Route path="/query" element={<QueryPage />} />
<Route path="/connections" element={<ConnectionsPage />} />
<Route path="/glossary" element={<GlossaryPage />} />
<Route path="/metrics" element={<MetricsPage />} />
<Route path="/dictionary" element={<DictionaryPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/history" element={<HistoryPage />} />
</Route>
</Route>
</Routes>
);
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/api/authApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { api } from './client';
import type { AuthProviderInfo, MagicLinkResponse, Me, User } from '../types/auth';

export const authApi = {
providers: () => api.get<AuthProviderInfo>('/auth/providers').then((r) => r.data),
me: () => api.get<Me>('/auth/me').then((r) => r.data),
login: (email: string, password: string) =>
api.post<User>('/auth/login', { email, password }).then((r) => r.data),
register: (email: string, password: string, name?: string) =>
api.post<User>('/auth/register', { email, password, name }).then((r) => r.data),
requestMagicLink: (email: string) =>
api.post<MagicLinkResponse>('/auth/magic-link', { email }).then((r) => r.data),
verifyMagicLink: (token: string) =>
api.post<User>('/auth/magic-link/verify', { token }).then((r) => r.data),
logout: () => api.post('/auth/logout').then((r) => r.data),
};
37 changes: 37 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,52 @@ 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,
(error) => {
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);
},
);
23 changes: 23 additions & 0 deletions frontend/src/components/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Center mih="100vh">
<Loader />
</Center>
);
}

if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
}

return <Outlet />;
}
80 changes: 72 additions & 8 deletions frontend/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 },
Expand All @@ -21,9 +35,21 @@ const NAV_ITEMS = [
{ label: 'History', path: '/history', icon: IconHistory },
];

const ROLE_COLOR: Record<Role, string> = {
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 (
<AppShell
Expand All @@ -32,13 +58,51 @@ export function AppLayout() {
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md">
<Title order={3} fw={700}>
QueryWise
</Title>
<Text size="sm" c="dimmed">
Ask questions in plain English
</Text>
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Title order={3} fw={700}>
QueryWise
</Title>
<Text size="sm" c="dimmed" visibleFrom="sm">
Ask questions in plain English
</Text>
</Group>

<Group gap="sm" wrap="nowrap">
{workspaces.length > 0 && (
<Select
size="xs"
w={200}
aria-label="Active workspace"
data={workspaces.map((w) => ({ value: w.team_id, label: w.team_name }))}
value={activeWorkspace?.team_id ?? null}
onChange={(v) => v && setActiveWorkspace(v)}
allowDeselect={false}
checkIconPosition="right"
/>
)}
{role && (
<Badge color={ROLE_COLOR[role]} variant="light" visibleFrom="sm">
{role}
</Badge>
)}
<Menu position="bottom-end" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" size="lg" aria-label="Account menu">
<IconUserCircle size={24} stroke={1.5} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{user?.email ?? 'Account'}</Menu.Label>
<Menu.Item
leftSection={<IconLogout size={16} />}
onClick={handleLogout}
>
Sign out
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>

Expand Down
76 changes: 76 additions & 0 deletions frontend/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { authApi } from '../api/authApi';
import { setActiveWorkspaceId, setUnauthorizedHandler } from '../api/client';
import type { Me } from '../types/auth';
import { AuthContext, type AuthContextValue } from './auth';

const ACTIVE_WS_KEY = 'activeWorkspaceId';

export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();

const meQuery = useQuery<Me>({
queryKey: ['me'],
queryFn: authApi.me,
retry: false,
staleTime: Infinity,
});

const me = meQuery.data ?? null;
const workspaces = useMemo(() => me?.workspaces ?? [], [me]);

// Resolve the active workspace: stored choice if still a member, else first.
const [selectedId, setSelectedId] = useState<string | null>(
() => (typeof window !== 'undefined' ? localStorage.getItem(ACTIVE_WS_KEY) : null),
);
const activeWorkspace =
workspaces.find((w) => w.team_id === selectedId) ?? workspaces[0] ?? null;

// Keep the axios header + localStorage in sync with the resolved workspace.
useEffect(() => {
setActiveWorkspaceId(activeWorkspace?.team_id ?? null);
if (activeWorkspace) {
localStorage.setItem(ACTIVE_WS_KEY, activeWorkspace.team_id);
}
}, [activeWorkspace]);

// On any 401 from a non-auth endpoint, drop the session so guards redirect.
useEffect(() => {
setUnauthorizedHandler(() => {
queryClient.setQueryData(['me'], null);
});
return () => setUnauthorizedHandler(null);
}, [queryClient]);

const value: AuthContextValue = {
user: me?.user ?? null,
workspaces,
activeWorkspace,
role: activeWorkspace?.role ?? null,
isLoading: meQuery.isLoading,
isAuthenticated: !!me?.user,
refresh: async () => {
await queryClient.invalidateQueries({ queryKey: ['me'] });
},
logout: async () => {
try {
await authApi.logout();
} finally {
setActiveWorkspaceId(null);
queryClient.setQueryData(['me'], null);
// Workspace-scoped data must not bleed across sessions.
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== 'me' });
}
},
setActiveWorkspace: (teamId: string) => {
localStorage.setItem(ACTIVE_WS_KEY, teamId);
setActiveWorkspaceId(teamId);
setSelectedId(teamId);
// Re-fetch everything for the newly selected workspace.
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== 'me' });
},
};

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
25 changes: 25 additions & 0 deletions frontend/src/context/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react';
import type { Role, User, WorkspaceMembership } from '../types/auth';

export interface AuthContextValue {
user: User | null;
workspaces: WorkspaceMembership[];
activeWorkspace: WorkspaceMembership | null;
role: Role | null;
isLoading: boolean;
isAuthenticated: boolean;
/** Re-fetch /auth/me (call after a successful login). */
refresh: () => Promise<void>;
logout: () => Promise<void>;
setActiveWorkspace: (teamId: string) => void;
}

export const AuthContext = createContext<AuthContextValue | null>(null);

export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
5 changes: 4 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { BrowserRouter } from 'react-router-dom';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import App from './App';
import { AuthProvider } from './context/AuthContext';

const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -20,7 +21,9 @@ createRoot(document.getElementById('root')!).render(
<Notifications position="top-right" />
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
Expand Down
Loading
Loading