diff --git a/apps/web/src/components/settings/DriveSettings.tsx b/apps/web/src/components/settings/DriveSettings.tsx new file mode 100644 index 000000000..c039dcd98 --- /dev/null +++ b/apps/web/src/components/settings/DriveSettings.tsx @@ -0,0 +1,359 @@ +/** + * Settings → Integrations → Google Drive (Phase 10). + * + * Connect a Google account, then link Mnema folders to Drive folders with a + * chosen direction + accepted file types. Talks to /api/drive/*. + */ +import { type JSX, useEffect, useRef, useState } from 'react'; + +interface DriveStatus { + connected: boolean; + configured: boolean; + scope: string | null; + defaultTypes: string[]; +} +interface DriveLink { + id: string; + folderId: string; + driveFolderId: string; + driveFolderName: string | null; + direction: 'pull' | 'push' | 'both'; + acceptedTypes: string[]; + conflictPolicy: 'manual' | 'lww'; + status: 'active' | 'paused' | 'error'; + lastSyncedAt: string | null; + errorMessage: string | null; +} +interface MnemaFolder { id: string; name: string } +interface DriveFolder { id: string; name: string } + +async function api(path: string, opts?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) }, + ...opts, + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error ?? `HTTP ${res.status}`); + } + return res.json() as Promise; +} + +const card: React.CSSProperties = { + background: 'var(--surface)', border: '1px solid var(--line)', + borderRadius: 10, padding: 16, +}; +const primaryBtn: React.CSSProperties = { + padding: '8px 16px', borderRadius: 8, border: 'none', + background: 'var(--accent, #6366f1)', color: 'var(--on-ink)', + fontSize: 14, fontWeight: 600, cursor: 'pointer', +}; +const ghostBtn: React.CSSProperties = { + padding: '5px 12px', borderRadius: 7, border: '1px solid var(--line-strong)', + background: 'transparent', color: 'var(--ink-soft)', fontSize: 12.5, cursor: 'pointer', +}; +const label: React.CSSProperties = { + display: 'block', fontSize: 11, color: 'var(--ink-muted)', marginBottom: 5, + fontWeight: 500, letterSpacing: '0.05em', textTransform: 'uppercase', +}; +const input: React.CSSProperties = { + width: '100%', padding: '8px 10px', borderRadius: 8, boxSizing: 'border-box', + background: 'var(--surface-2)', border: '1px solid var(--line)', color: 'var(--ink)', fontSize: 13, +}; + +export function DriveSettings(): JSX.Element { + const [status, setStatus] = useState(null); + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + const [banner, setBanner] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [confirmUnlink, setConfirmUnlink] = useState(null); + + async function refresh() { + const s = await api('/api/drive/status'); + setStatus(s); + if (s.connected) { + const l = await api<{ links: DriveLink[] }>('/api/drive/links'); + setLinks(l.links); + } + } + + useEffect(() => { + // Surface the OAuth redirect result (?drive=connected|error). + const p = new URLSearchParams(window.location.search).get('drive'); + if (p === 'connected') setBanner('Google Drive connected.'); + else if (p === 'error') setBanner('Could not connect Google Drive — please try again.'); + void refresh().catch((e: Error) => setBanner(e.message)).finally(() => setLoading(false)); + }, []); + + async function syncNow(id: string) { + setBanner('Sync queued…'); + try { await api(`/api/drive/links/${id}/sync`, { method: 'POST' }); setBanner('Sync queued.'); } + catch (e) { setBanner((e as Error).message); } + } + async function unlink(id: string) { + await api(`/api/drive/links/${id}`, { method: 'DELETE' }); + setLinks((prev) => prev.filter((l) => l.id !== id)); + setConfirmUnlink(null); + } + async function togglePause(l: DriveLink) { + const next = l.status === 'paused' ? 'active' : 'paused'; + const { link } = await api<{ link: DriveLink }>(`/api/drive/links/${l.id}`, { + method: 'PATCH', body: JSON.stringify({ status: next }), + }); + setLinks((prev) => prev.map((x) => (x.id === l.id ? link : x))); + } + + if (loading) return
Loading…
; + + return ( +
+
+

Google Drive

+

+ Link Mnema folders to Google Drive folders and keep their files in sync both ways. +

+
+ + {banner && ( +
+ {banner} +
+ )} + + {!status?.configured ? ( +
+

+ Google Drive isn’t configured on this server yet. An operator needs to create a Google + Cloud OAuth client and set GOOGLE_DRIVE_CLIENT_ID, + GOOGLE_DRIVE_CLIENT_SECRET and GOOGLE_DRIVE_REDIRECT_URI in + the environment. See docs/connect/drive.md. +

+
+ ) : !status.connected ? ( +
+

+ Connect your Google account to start linking folders. +

+ + Connect Google Drive + +
+ ) : ( + <> +
+ + Connected · scope {status.scope?.split('/').pop()} + + +
+ + {links.length === 0 ? ( +
+ No linked folders yet. Link one to start syncing. +
+ ) : ( +
+ {links.map((l) => ( +
+
+
+ {l.driveFolderName ?? l.driveFolderId} +
+
+ {l.direction === 'both' ? 'Two-way' : l.direction === 'pull' ? 'Drive → Mnema' : 'Mnema → Drive'} + {' · '}{(l.acceptedTypes.length ? l.acceptedTypes : status.defaultTypes).join(', ')} + {l.status === 'paused' && ' · paused'} + {l.status === 'error' && ` · error: ${l.errorMessage ?? 'sync failed'}`} + {l.lastSyncedAt && ` · synced ${new Date(l.lastSyncedAt).toLocaleString()}`} +
+
+ + + {confirmUnlink === l.id ? ( + + Unlink? + + + + ) : ( + + )} +
+ ))} +
+ )} + + )} + + {showAdd && status && ( + setShowAdd(false)} + onCreated={(link) => { setLinks((prev) => [link, ...prev]); setShowAdd(false); setBanner('Folder linked — initial sync queued.'); }} + /> + )} +
+ ); +} + +function AddLinkModal({ defaultTypes, onClose, onCreated }: { + defaultTypes: string[]; + onClose: () => void; + onCreated: (link: DriveLink) => void; +}): JSX.Element { + const [mnemaFolders, setMnemaFolders] = useState([]); + const [driveFolders, setDriveFolders] = useState([]); + const [folderId, setFolderId] = useState(''); + const [driveFolderId, setDriveFolderId] = useState(''); + const [createInDrive, setCreateInDrive] = useState(false); + const [direction, setDirection] = useState<'pull' | 'push' | 'both'>('both'); + const [types, setTypes] = useState(defaultTypes); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + void api<{ folders: MnemaFolder[] }>('/api/folders').then((d) => setMnemaFolders(d.folders ?? [])).catch(() => {}); + void api<{ folders: DriveFolder[] }>('/api/drive/folders').then((d) => setDriveFolders(d.folders ?? [])).catch(() => {}); + }, []); + + // Dialog a11y: focus the dialog on open, trap Tab within it, close on Escape, + // and restore focus to the trigger on unmount. (onClose via ref so this runs once.) + const modalRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + useEffect(() => { + const node = modalRef.current; + const prevFocus = document.activeElement as HTMLElement | null; + const focusables = () => Array.from( + node?.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? [], + ); + focusables()[0]?.focus(); + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { e.preventDefault(); onCloseRef.current(); return; } + if (e.key !== 'Tab') return; + const items = focusables(); + if (items.length === 0) return; + const first = items[0]!; + const last = items[items.length - 1]!; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + } + document.addEventListener('keydown', onKeyDown); + return () => { document.removeEventListener('keydown', onKeyDown); prevFocus?.focus(); }; + }, []); + + function toggleType(t: string) { + setTypes((prev) => (prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t])); + } + + async function submit() { + if (!folderId) { setError('Choose a Mnema folder.'); return; } + if (!createInDrive && !driveFolderId) { setError('Choose a Drive folder, or create one.'); return; } + setSaving(true); setError(null); + try { + const driveName = driveFolders.find((f) => f.id === driveFolderId)?.name; + const { link } = await api<{ link: DriveLink }>('/api/drive/links', { + method: 'POST', + body: JSON.stringify({ + folderId, + ...(createInDrive ? { createInDrive: true } : { driveFolderId, driveFolderName: driveName }), + direction, + acceptedTypes: types, + }), + }); + onCreated(link); + } catch (e) { + setError((e as Error).message); + setSaving(false); + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+

Link a folder

+ +
+ Mnema folder + +
+ +
+ Google Drive folder + + {!createInDrive && ( + + )} +
+ +
+ Direction + +
+ +
+ File types to sync +
+ {defaultTypes.map((t) => { + const on = types.includes(t); + return ( + + ); + })} +
+
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/layouts/SettingsLayout.astro b/apps/web/src/layouts/SettingsLayout.astro index 195a26736..abc3ad923 100644 --- a/apps/web/src/layouts/SettingsLayout.astro +++ b/apps/web/src/layouts/SettingsLayout.astro @@ -44,6 +44,7 @@ const NAV_SECTIONS = [ items: [ { href: '/app/settings/workspace', label: 'Workspace' }, { href: '/app/settings/api-keys', label: 'API Keys' }, + { href: '/app/settings/integrations', label: 'Integrations' }, { href: '/app/settings/community', label: 'Community' }, { href: '/app/settings/dev', label: 'Dev / AgentLens' }, ], diff --git a/apps/web/src/pages/app/connections/drive.astro b/apps/web/src/pages/app/connections/drive.astro index 4665c914a..0063f52fb 100644 --- a/apps/web/src/pages/app/connections/drive.astro +++ b/apps/web/src/pages/app/connections/drive.astro @@ -126,7 +126,7 @@ const workspaceName = domainPart - Google Drive · planned · Phase 10 + Google Drive · folder sync @@ -146,7 +146,7 @@ const workspaceName = domainPart

Sync content from Google Drive as Mnema docs. Files appear in your workspace as if they were native.

- +
@@ -156,24 +156,12 @@ const workspaceName = domainPart
-

Google Drive sync — coming soon

-

Once available, you'll connect your Drive account here and choose which folders Mnema should mirror as content. Until then, drag content into the workspace as markdown docs.

- Drive sync · planned for Phase 10 -
- - -
-
-
Notify me when it's ready
-
We'll email you once Drive sync ships in Phase 10. No other messages.
-
-
- - -
+

Two-way folder sync

+

Link a Mnema folder to a Google Drive folder and keep their files in sync both ways — add a file in either place and it shows up in the other, for the file types you allow. Connect your account and manage links in Settings.

+ + Open Drive settings + +
@@ -214,20 +202,4 @@ const workspaceName = domainPart - - diff --git a/apps/web/src/pages/app/settings/integrations.astro b/apps/web/src/pages/app/settings/integrations.astro new file mode 100644 index 000000000..166ef1aaa --- /dev/null +++ b/apps/web/src/pages/app/settings/integrations.astro @@ -0,0 +1,17 @@ +--- +import SettingsLayout from '../../../layouts/SettingsLayout.astro'; +import { DriveSettings } from '../../../components/settings/DriveSettings'; + +const auth = Astro.locals.auth; +if (!auth) { + return Astro.redirect('/auth/login'); +} +--- + + +

Settings

+

Integrations

+

Connect external services to your workspace.

+ + +