Skip to content

Commit 5406739

Browse files
committed
feat: Add Notion OAuth workspace connection with per-user access control
- Notion public OAuth flow (connect/disconnect via Settings page) - NotionWorkspace and NotionConnection DB models with encrypted token storage - Background ingestion: fetch, deduplicate, chunk, and embed with workspace_id tagging - RAG queries scoped to user's connected workspaces via ChromaDB where filter - Sidebar shows real connected workspaces with sync status - Settings page: connect, disconnect, manual sync with status polling - Deduplicate Notion documents (database rows fetched as both pages and rows) - ChromaManager: add delete_by_workspace for cleanup on disconnect/re-sync - Update .env.example with Notion OAuth and encryption config
1 parent 8a55da5 commit 5406739

19 files changed

Lines changed: 790 additions & 60 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@ JWT_ALGORITHM=HS256
66
JWT_EXPIRE_MINUTES=1440
77
FRONTEND_URL=http://localhost:5173
88
DATABASE_URL=sqlite:///./workmate.db
9+
GEMINI_KEY=your-gemini-api-key
10+
NOTION_TOKEN=your-notion-internal-integration-token
11+
NOTION_OAUTH_CLIENT_ID=your-notion-oauth-client-id
12+
NOTION_OAUTH_CLIENT_SECRET=your-notion-oauth-client-secret
13+
NOTION_REDIRECT_URI=http://localhost:8000/api/notion/callback
14+
NOTION_ENCRYPTION_KEY=your-fernet-encryption-key

frontend/src/app/components/Sidebar.tsx

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,10 @@ import { Card } from './ui/card';
1818
import { Button } from './ui/button';
1919
import { useAuth } from '../contexts/AuthContext';
2020
import { useIsAdmin } from '../../hooks/useIsAdmin';
21-
import { listConversations, deleteConversation, renameConversation } from '../../services/api';
21+
import { listConversations, deleteConversation, renameConversation, getWorkspaces } from '../../services/api';
2222
import { toast } from 'sonner';
2323
import type { ConversationSummary, NotionWorkspace } from '../../types/chat';
2424

25-
const mockWorkspaces: NotionWorkspace[] = [
26-
{ id: '1', name: 'Product Requirements', pageCount: 24, connected: true },
27-
{ id: '2', name: 'Engineering Docs', pageCount: 156, connected: true },
28-
{ id: '3', name: 'Team Wiki', pageCount: 89, connected: true },
29-
];
30-
3125
interface SidebarProps {
3226
activeConversationId: number | null;
3327
onSelectConversation: (id: number | null, title?: string) => void;
@@ -63,11 +57,15 @@ export function Sidebar({
6357
const [searchQuery, setSearchQuery] = useState('');
6458
const [editingId, setEditingId] = useState<number | null>(null);
6559
const [editingTitle, setEditingTitle] = useState('');
60+
const [workspaces, setWorkspaces] = useState<NotionWorkspace[]>([]);
6661

6762
useEffect(() => {
6863
listConversations()
6964
.then(setConversations)
7065
.catch((err) => console.error('Failed to load conversations:', err));
66+
getWorkspaces()
67+
.then(setWorkspaces)
68+
.catch(() => {}); // Silently fail if no workspaces
7169
}, [refreshKey]);
7270

7371
const handleDelete = async (e: React.MouseEvent, id: number) => {
@@ -270,25 +268,40 @@ export function Sidebar({
270268
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Connected Workspaces</h2>
271269
</div>
272270
<div className="space-y-1.5">
273-
{mockWorkspaces.map((workspace) => (
274-
<Card
275-
key={workspace.id}
276-
className="p-2.5 hover:bg-white dark:hover:bg-slate-800 transition-colors cursor-pointer border-slate-200 dark:border-slate-700"
271+
{workspaces.length === 0 ? (
272+
<Link
273+
to="/settings"
274+
className="block text-xs text-slate-400 hover:text-purple-500 text-center py-3 transition-colors"
277275
>
278-
<div className="flex items-start justify-between">
279-
<div className="flex-1">
280-
<div className="flex items-center gap-2">
281-
<div className="w-2 h-2 rounded-full bg-green-500" />
282-
<h3 className="font-medium text-sm text-slate-900 dark:text-slate-100">{workspace.name}</h3>
276+
Connect a Notion workspace
277+
</Link>
278+
) : (
279+
workspaces.map((workspace) => (
280+
<Card
281+
key={workspace.id}
282+
className="p-2.5 hover:bg-white dark:hover:bg-slate-800 transition-colors cursor-pointer border-slate-200 dark:border-slate-700"
283+
>
284+
<div className="flex items-start justify-between">
285+
<div className="flex-1">
286+
<div className="flex items-center gap-2">
287+
<div className={`w-2 h-2 rounded-full ${
288+
workspace.sync_status === 'syncing' ? 'bg-yellow-500 animate-pulse' :
289+
workspace.sync_status === 'error' ? 'bg-red-500' : 'bg-green-500'
290+
}`} />
291+
<h3 className="font-medium text-sm text-slate-900 dark:text-slate-100">
292+
{workspace.workspace_name}
293+
</h3>
294+
</div>
295+
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
296+
{workspace.sync_status === 'syncing' ? 'Syncing...' :
297+
workspace.last_synced_at ? `Synced ${formatTime(workspace.last_synced_at)}` : 'Pending sync'}
298+
</p>
283299
</div>
284-
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
285-
{workspace.pageCount} pages indexed
286-
</p>
300+
<ChevronRight className="w-4 h-4 text-slate-400" />
287301
</div>
288-
<ChevronRight className="w-4 h-4 text-slate-400" />
289-
</div>
290-
</Card>
291-
))}
302+
</Card>
303+
))
304+
)}
292305
</div>
293306
</div>
294307

frontend/src/app/pages/SettingsPage.tsx

Lines changed: 194 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useState } from 'react';
2-
import { useNavigate } from 'react-router-dom';
3-
import { Sun, Moon, Monitor, Link2 } from 'lucide-react';
1+
import { useState, useEffect } from 'react';
2+
import { useNavigate, useSearchParams } from 'react-router-dom';
3+
import { Sun, Moon, Monitor, Link2, RefreshCw, Unplug, Plus, Loader2 } from 'lucide-react';
44
import { toast } from 'sonner';
55
import { Button } from '../components/ui/button';
66
import { Card } from '../components/ui/card';
@@ -20,14 +20,117 @@ import {
2020
import { useAuth } from '../contexts/AuthContext';
2121
import { useTheme } from 'next-themes';
2222
import { updateProfile, deleteAccount } from '../../services/auth';
23+
import { getNotionAuthUrl, getWorkspaces, disconnectWorkspace, syncWorkspace } from '../../services/api';
24+
import type { NotionWorkspace } from '../../types/chat';
2325

2426
export function SettingsPage() {
2527
const { user, logout, updateUser } = useAuth();
2628
const { theme, setTheme } = useTheme();
2729
const navigate = useNavigate();
2830

31+
const [searchParams, setSearchParams] = useSearchParams();
2932
const [name, setName] = useState(user?.name ?? '');
3033
const [saving, setSaving] = useState(false);
34+
const [workspaces, setWorkspaces] = useState<NotionWorkspace[]>([]);
35+
const [loadingWorkspaces, setLoadingWorkspaces] = useState(true);
36+
const [connecting, setConnecting] = useState(false);
37+
const [syncingId, setSyncingId] = useState<number | null>(null);
38+
39+
useEffect(() => {
40+
loadWorkspaces();
41+
// Handle redirect from Notion OAuth callback
42+
if (searchParams.get('notion') === 'connected') {
43+
toast.success('Notion workspace connected! Ingestion is running in the background.');
44+
searchParams.delete('notion');
45+
setSearchParams(searchParams, { replace: true });
46+
}
47+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
48+
49+
// Poll for status updates while any workspace is syncing
50+
useEffect(() => {
51+
const isSyncing = workspaces.some((w) => w.sync_status === 'syncing');
52+
if (!isSyncing) return;
53+
54+
const interval = setInterval(() => {
55+
getWorkspaces().then((ws) => {
56+
setWorkspaces(ws);
57+
// Stop polling if nothing is syncing anymore
58+
if (!ws.some((w) => w.sync_status === 'syncing')) {
59+
const failed = ws.find((w) => w.sync_status === 'error');
60+
if (failed) {
61+
toast.error(`Sync failed for ${failed.workspace_name}`);
62+
} else {
63+
toast.success('Sync complete!');
64+
}
65+
}
66+
}).catch(() => {});
67+
}, 10000);
68+
69+
return () => clearInterval(interval);
70+
}, [workspaces]);
71+
72+
const loadWorkspaces = async () => {
73+
try {
74+
const ws = await getWorkspaces();
75+
setWorkspaces(ws);
76+
} catch {
77+
// Silently fail — user may not have any workspaces
78+
} finally {
79+
setLoadingWorkspaces(false);
80+
}
81+
};
82+
83+
const handleConnectNotion = async () => {
84+
setConnecting(true);
85+
try {
86+
const url = await getNotionAuthUrl();
87+
window.location.href = url;
88+
} catch {
89+
toast.error('Failed to initiate Notion connection');
90+
setConnecting(false);
91+
}
92+
};
93+
94+
const handleDisconnect = async (wsId: number) => {
95+
try {
96+
await disconnectWorkspace(wsId);
97+
setWorkspaces((prev) => prev.filter((w) => w.id !== wsId));
98+
toast.success('Workspace disconnected');
99+
} catch {
100+
toast.error('Failed to disconnect workspace');
101+
}
102+
};
103+
104+
const handleSync = async (wsId: number) => {
105+
setSyncingId(wsId);
106+
try {
107+
const result = await syncWorkspace(wsId);
108+
if (result.status === 'already_syncing') {
109+
toast.info('Sync is already in progress');
110+
} else {
111+
toast.success('Sync started — this may take a few minutes');
112+
setWorkspaces((prev) =>
113+
prev.map((w) => (w.id === wsId ? { ...w, sync_status: 'syncing' } : w))
114+
);
115+
}
116+
} catch {
117+
toast.error('Failed to start sync');
118+
} finally {
119+
setSyncingId(null);
120+
}
121+
};
122+
123+
const formatTimeAgo = (dateStr: string | null) => {
124+
if (!dateStr) return 'Never';
125+
const diff = Date.now() - new Date(dateStr).getTime();
126+
const mins = Math.floor(diff / 60000);
127+
if (mins < 1) return 'Just now';
128+
if (mins < 60) return `${mins}m ago`;
129+
const hours = Math.floor(mins / 60);
130+
if (hours < 24) return `${hours}h ago`;
131+
const days = Math.floor(hours / 24);
132+
return `${days}d ago`;
133+
};
31134

32135
const handleSaveName = async () => {
33136
const trimmed = name.trim();
@@ -147,17 +250,95 @@ export function SettingsPage() {
147250
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-3">
148251
Connected Accounts
149252
</h2>
150-
<div className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 p-3">
151-
<div className="flex items-center gap-3">
152-
<Link2 className="h-5 w-5 text-slate-500 dark:text-slate-400" />
153-
<div>
154-
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Notion</p>
155-
<p className="text-xs text-slate-500 dark:text-slate-400">Coming soon</p>
253+
<div className="space-y-3">
254+
{loadingWorkspaces ? (
255+
<div className="flex items-center justify-center py-4">
256+
<Loader2 className="h-5 w-5 animate-spin text-slate-400" />
156257
</div>
157-
</div>
158-
<Button variant="outline" disabled className="dark:border-slate-600 dark:text-slate-400">
159-
Connect
160-
</Button>
258+
) : (
259+
<>
260+
{workspaces.map((ws) => (
261+
<div
262+
key={ws.id}
263+
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 p-3"
264+
>
265+
<div className="flex items-center gap-3">
266+
<Link2 className="h-5 w-5 text-purple-500" />
267+
<div>
268+
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
269+
{ws.workspace_name}
270+
</p>
271+
<p className="text-xs text-slate-500 dark:text-slate-400">
272+
Connected {formatTimeAgo(ws.connected_at)}
273+
{ws.last_synced_at && ` · Last synced ${formatTimeAgo(ws.last_synced_at)}`}
274+
{ws.sync_status === 'syncing' && (
275+
<span className="ml-1 text-purple-500">· Syncing...</span>
276+
)}
277+
{ws.sync_status === 'error' && (
278+
<span className="ml-1 text-red-500">· Sync failed</span>
279+
)}
280+
</p>
281+
</div>
282+
</div>
283+
<div className="flex gap-2">
284+
<Button
285+
variant="outline"
286+
size="sm"
287+
onClick={() => handleSync(ws.id)}
288+
disabled={syncingId === ws.id || ws.sync_status === 'syncing'}
289+
className="dark:border-slate-600 dark:text-slate-300"
290+
>
291+
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${syncingId === ws.id || ws.sync_status === 'syncing' ? 'animate-spin' : ''}`} />
292+
Sync
293+
</Button>
294+
<AlertDialog>
295+
<AlertDialogTrigger asChild>
296+
<Button
297+
variant="outline"
298+
size="sm"
299+
className="text-red-500 hover:text-red-600 dark:border-slate-600"
300+
>
301+
<Unplug className="h-3.5 w-3.5 mr-1" />
302+
Disconnect
303+
</Button>
304+
</AlertDialogTrigger>
305+
<AlertDialogContent>
306+
<AlertDialogHeader>
307+
<AlertDialogTitle>Disconnect {ws.workspace_name}?</AlertDialogTitle>
308+
<AlertDialogDescription>
309+
This will remove your connection to this Notion workspace.
310+
If no other users are connected, the workspace data will also be removed.
311+
</AlertDialogDescription>
312+
</AlertDialogHeader>
313+
<AlertDialogFooter>
314+
<AlertDialogCancel>Cancel</AlertDialogCancel>
315+
<AlertDialogAction
316+
onClick={() => handleDisconnect(ws.id)}
317+
className="bg-red-600 hover:bg-red-700 text-white"
318+
>
319+
Disconnect
320+
</AlertDialogAction>
321+
</AlertDialogFooter>
322+
</AlertDialogContent>
323+
</AlertDialog>
324+
</div>
325+
</div>
326+
))}
327+
<Button
328+
variant="outline"
329+
onClick={handleConnectNotion}
330+
disabled={connecting}
331+
className="w-full dark:border-slate-600 dark:text-slate-300"
332+
>
333+
{connecting ? (
334+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
335+
) : (
336+
<Plus className="h-4 w-4 mr-2" />
337+
)}
338+
{workspaces.length > 0 ? 'Connect another Notion workspace' : 'Connect Notion workspace'}
339+
</Button>
340+
</>
341+
)}
161342
</div>
162343
</Card>
163344

frontend/src/services/api.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -213,14 +213,38 @@ export async function uploadFiles(files: File[]): Promise<UploadResponse[]> {
213213
return res.json();
214214
}
215215

216-
/**
217-
* Fetch connected Notion workspaces.
218-
* Currently returns mock data.
219-
*/
216+
// --- Notion workspace endpoints ---
217+
218+
export async function getNotionAuthUrl(): Promise<string> {
219+
const res = await fetch(`${BASE_URL}/notion/connect`, {
220+
headers: authHeaders(),
221+
});
222+
if (!res.ok) throw new Error('Failed to get Notion auth URL');
223+
const data = await res.json();
224+
return data.authorization_url;
225+
}
226+
220227
export async function getWorkspaces(): Promise<NotionWorkspace[]> {
221-
return [
222-
{ id: '1', name: 'Product Requirements', pageCount: 24, connected: true },
223-
{ id: '2', name: 'Engineering Docs', pageCount: 156, connected: true },
224-
{ id: '3', name: 'Team Wiki', pageCount: 89, connected: true },
225-
];
228+
const res = await fetch(`${BASE_URL}/notion/workspaces`, {
229+
headers: authHeaders(),
230+
});
231+
if (!res.ok) throw new Error('Failed to fetch workspaces');
232+
return res.json();
233+
}
234+
235+
export async function disconnectWorkspace(workspaceId: number): Promise<void> {
236+
const res = await fetch(`${BASE_URL}/notion/workspaces/${workspaceId}`, {
237+
method: 'DELETE',
238+
headers: authHeaders(),
239+
});
240+
if (!res.ok) throw new Error('Failed to disconnect workspace');
241+
}
242+
243+
export async function syncWorkspace(workspaceId: number): Promise<{ status: string }> {
244+
const res = await fetch(`${BASE_URL}/notion/workspaces/${workspaceId}/sync`, {
245+
method: 'POST',
246+
headers: authHeaders(),
247+
});
248+
if (!res.ok) throw new Error('Failed to sync workspace');
249+
return res.json();
226250
}

0 commit comments

Comments
 (0)