1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(auth): frontend login/setup pages + auth state in App.jsx

This commit is contained in:
TheGeneralist 2026-06-26 11:28:58 +02:00
parent 7584e309de
commit 0a5c899c5e
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 280 additions and 71 deletions

View file

@ -1,5 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef, createContext } from 'react'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags } from './api' 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 Topbar from './components/Topbar'
import CaptureDialog from './components/CaptureDialog' import CaptureDialog from './components/CaptureDialog'
import EntriesView from './components/EntriesView' import EntriesView from './components/EntriesView'
@ -8,7 +11,29 @@ import AdminView from './components/AdminView'
import TagsView from './components/TagsView' import TagsView from './components/TagsView'
import ContextRail from './components/ContextRail' import ContextRail from './components/ContextRail'
export const AuthContext = createContext(null);
export default function App() { 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 [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null) const [archiveId, setArchiveId] = useState(null)
const [entries, setEntries] = useState([]) const [entries, setEntries] = useState([])
@ -127,77 +152,83 @@ export default function App() {
]) ])
}, [archiveId, searchQuery, tagFilter, loadEntries]) }, [archiveId, searchQuery, tagFilter, loadEntries])
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
return ( return (
<> <AuthContext.Provider value={{ currentUser, setCurrentUser }}>
<Topbar <>
archives={archives} <Topbar
archiveId={archiveId} archives={archives}
onArchiveChange={handleArchiveChange}
view={view}
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
/>
<main className="app-shell">
<div className="workspace">
{view === 'archive' && (
<div className="search-row">
<input
className="search-input"
type="search"
aria-label="Search archive"
aria-busy={searchBusy}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<div className="result-count">
{resultCount}
{tagFilter && (
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
× {tagFilter}
</button>
)}
</div>
</div>
)}
{view === 'archive' && (
<EntriesView
entries={entries}
selectedEntryUid={selectedEntryUid}
onSelectEntry={selectEntry}
archiveId={archiveId}
tagFilter={tagFilter}
onClearTagFilter={handleClearTagFilter}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
resultCount={resultCount}
searchBusy={searchBusy}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}
{view === 'admin' && <AdminView archives={archives} />}
{view === 'tags' && (
<TagsView
tagNodes={tagNodes}
tagFilter={tagFilter}
onTagFilterSet={handleTagFilterSet}
onViewChange={handleViewChange}
/>
)}
</div>
<ContextRail
archiveId={archiveId} archiveId={archiveId}
selectedEntry={selectedEntry} onArchiveChange={handleArchiveChange}
onTagFilterSet={handleTagFilterSet} view={view}
tagNodes={tagNodes} onViewChange={handleViewChange}
onTagsRefresh={handleTagsRefresh} onCaptureClick={handleCaptureClick}
/> />
</main> <main className="app-shell">
<CaptureDialog <div className="workspace">
open={captureDialogOpen} {view === 'archive' && (
archiveId={archiveId} <div className="search-row">
onClose={handleCaptureClose} <input
onCaptured={handleCaptured} className="search-input"
/> type="search"
</> aria-label="Search archive"
aria-busy={searchBusy}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<div className="result-count">
{resultCount}
{tagFilter && (
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
× {tagFilter}
</button>
)}
</div>
</div>
)}
{view === 'archive' && (
<EntriesView
entries={entries}
selectedEntryUid={selectedEntryUid}
onSelectEntry={selectEntry}
archiveId={archiveId}
tagFilter={tagFilter}
onClearTagFilter={handleClearTagFilter}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
resultCount={resultCount}
searchBusy={searchBusy}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}
{view === 'admin' && <AdminView archives={archives} />}
{view === 'tags' && (
<TagsView
tagNodes={tagNodes}
tagFilter={tagFilter}
onTagFilterSet={handleTagFilterSet}
onViewChange={handleViewChange}
/>
)}
</div>
<ContextRail
archiveId={archiveId}
selectedEntry={selectedEntry}
onTagFilterSet={handleTagFilterSet}
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
/>
</main>
<CaptureDialog
open={captureDialogOpen}
archiveId={archiveId}
onClose={handleCaptureClose}
onCaptured={handleCaptured}
/>
</>
</AuthContext.Provider>
) )
} }

View file

@ -68,3 +68,54 @@ export async function submitCapture(archiveId, locator) {
throw new Error(msg || `HTTP ${res.status}`); 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;
};

View file

@ -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 (
<div className="login-page">
<h1>Archivr</h1>
<form onSubmit={handleSubmit}>
<label>
Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in\u2026' : 'Log in'}
</button>
</form>
</div>
);
}

View file

@ -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 (
<div className="setup-page">
<h1>Welcome to Archivr</h1>
<p>Create your owner account to get started.</p>
<form onSubmit={handleSubmit}>
<label>
Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Creating account\u2026' : 'Create account'}
</button>
</form>
</div>
);
}