diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f663efe..f6f119b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,8 @@
-import { useState, useEffect, useCallback, useRef } from 'react'
-import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags } from './api'
+import { useState, useEffect, useCallback, useRef, createContext } from 'react'
+import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe } from './api'
+import LoginPage from './components/LoginPage.jsx'
+import SetupPage from './components/SetupPage.jsx'
+
import Topbar from './components/Topbar'
import CaptureDialog from './components/CaptureDialog'
import EntriesView from './components/EntriesView'
@@ -8,7 +11,29 @@ import AdminView from './components/AdminView'
import TagsView from './components/TagsView'
import ContextRail from './components/ContextRail'
+export const AuthContext = createContext(null);
+
export default function App() {
+ const [authState, setAuthState] = useState('loading');
+ const [currentUser, setCurrentUser] = useState(null);
+
+ useEffect(() => {
+ (async () => {
+ const needsSetup = await checkSetup();
+ if (needsSetup) { setAuthState('setup'); return; }
+ const user = await fetchMe();
+ if (!user) { setAuthState('login'); return; }
+ setCurrentUser(user);
+ setAuthState('authenticated');
+ })();
+ }, []);
+
+ useEffect(() => {
+ const handler = () => { setCurrentUser(null); setAuthState('login'); };
+ window.addEventListener('auth:expired', handler);
+ return () => window.removeEventListener('auth:expired', handler);
+ }, []);
+
const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null)
const [entries, setEntries] = useState([])
@@ -127,77 +152,83 @@ export default function App() {
])
}, [archiveId, searchQuery, tagFilter, loadEntries])
+ if (authState === 'loading') return
Loading\u2026
;
+ if (authState === 'setup') return setAuthState('login')} />;
+ if (authState === 'login') return { setCurrentUser(user); setAuthState('authenticated'); }} />;
+
return (
- <>
-
-
-
- {view === 'archive' && (
-
-
setSearchQuery(e.target.value)}
- />
-
- {resultCount}
- {tagFilter && (
-
- )}
-
-
- )}
- {view === 'archive' && (
-
- )}
- {view === 'runs' &&
}
- {view === 'admin' &&
}
- {view === 'tags' && (
-
- )}
-
-
+ <>
+
-
-
- >
+
+
+ {view === 'archive' && (
+
+
setSearchQuery(e.target.value)}
+ />
+
+ {resultCount}
+ {tagFilter && (
+
+ )}
+
+
+ )}
+ {view === 'archive' && (
+
+ )}
+ {view === 'runs' &&
}
+ {view === 'admin' &&
}
+ {view === 'tags' && (
+
+ )}
+
+
+
+
+ >
+
)
}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 0ce034b..1af25c8 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -68,3 +68,54 @@ export async function submitCapture(archiveId, locator) {
throw new Error(msg || `HTTP ${res.status}`);
}
}
+
+// ── Auth helpers ─────────────────────────────────────────────────────────────
+
+export async function checkSetup() {
+ const r = await fetch('/api/auth/setup');
+ const data = await r.json();
+ return data.setup_required === true;
+}
+
+export async function doSetup(username, password) {
+ const r = await fetch('/api/auth/setup', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!r.ok) throw new Error((await r.json()).error || 'Setup failed');
+ return r.json();
+}
+
+export async function login(username, password) {
+ const r = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!r.ok) throw new Error((await r.json()).error || 'Login failed');
+ return r.json();
+}
+
+export async function logout() {
+ await fetch('/api/auth/logout', { method: 'POST' });
+}
+
+export async function fetchMe() {
+ const r = await fetch('/api/auth/me');
+ if (r.status === 401) return null;
+ return r.json();
+}
+
+// ── 401 interceptor ───────────────────────────────────────────────────────────
+const _origFetch = window.fetch;
+window.fetch = async (...args) => {
+ const r = await _origFetch(...args);
+ if (r.status === 401) {
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
+ if (!url.includes('/api/auth/')) {
+ window.dispatchEvent(new CustomEvent('auth:expired'));
+ }
+ }
+ return r;
+};
diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx
new file mode 100644
index 0000000..105ed5a
--- /dev/null
+++ b/frontend/src/components/LoginPage.jsx
@@ -0,0 +1,54 @@
+import { useState } from 'react';
+import { login } from '../api.js';
+
+export default function LoginPage({ onLogin }) {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+ try {
+ const user = await login(username, password);
+ onLogin(user);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/SetupPage.jsx b/frontend/src/components/SetupPage.jsx
new file mode 100644
index 0000000..aca4cb3
--- /dev/null
+++ b/frontend/src/components/SetupPage.jsx
@@ -0,0 +1,73 @@
+import { useState } from 'react';
+import { doSetup } from '../api.js';
+
+export default function SetupPage({ onComplete }) {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ if (password !== confirm) {
+ setError('Passwords do not match');
+ return;
+ }
+ if (password.length < 8) {
+ setError('Password must be at least 8 characters');
+ return;
+ }
+ setError(null);
+ setLoading(true);
+ try {
+ await doSetup(username, password);
+ onComplete();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
Welcome to Archivr
+
Create your owner account to get started.
+
+
+ );
+}