diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f8f01ab..0061113 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,7 @@ import RunsView from './components/RunsView' import AdminView from './components/AdminView' import TagsView from './components/TagsView' import CollectionsView from './components/CollectionsView' +import SettingsView from './components/SettingsView' import ContextRail from './components/ContextRail' export const AuthContext = createContext(null); @@ -217,6 +218,9 @@ export default function App() { {view === 'collections' && ( )} + {view === 'settings' && ( + + )} + Settings + + {['profile', 'tokens', ...(isAdmin ? ['instance'] : [])].map(t => ( + setTab(t)}> + {t === 'profile' ? 'Profile' : t === 'tokens' ? 'API Tokens' : 'Instance'} + + ))} + + + {tab === 'profile' && } + {tab === 'tokens' && } + {tab === 'instance' && isAdmin && } + + ) +} + +function ProfileTab({ currentUser, setCurrentUser }) { + const [displayName, setDisplayName] = useState(currentUser?.display_name ?? '') + const [saving, setSaving] = useState(false) + const [saveMsg, setSaveMsg] = useState(null) + + const [curPw, setCurPw] = useState('') + const [newPw, setNewPw] = useState('') + const [confirmPw, setConfirmPw] = useState('') + const [pwSaving, setPwSaving] = useState(false) + const [pwMsg, setPwMsg] = useState(null) + + async function handleSaveProfile(e) { + e.preventDefault() + setSaving(true) + setSaveMsg(null) + try { + await updateProfile(displayName) + setCurrentUser(u => ({ ...u, display_name: displayName || null })) + setSaveMsg({ ok: true, text: 'Saved.' }) + } catch (err) { + setSaveMsg({ ok: false, text: err.message }) + } finally { + setSaving(false) + } + } + + async function handleChangePassword(e) { + e.preventDefault() + if (newPw !== confirmPw) { setPwMsg({ ok: false, text: 'Passwords do not match.' }); return } + setPwSaving(true) + setPwMsg(null) + try { + await changePassword(curPw, newPw) + setCurPw(''); setNewPw(''); setConfirmPw('') + setPwMsg({ ok: true, text: 'Password changed.' }) + } catch (err) { + setPwMsg({ ok: false, text: err.message }) + } finally { + setPwSaving(false) + } + } + + return ( + + + Display Name + + setDisplayName(e.target.value)} /> + {saveMsg && ( + {saveMsg.text} + )} + + {saving ? 'Saving\u2026' : 'Save'} + + + + + + Change Password + + setCurPw(e.target.value)} required /> + setNewPw(e.target.value)} required /> + setConfirmPw(e.target.value)} required /> + {pwMsg && ( + {pwMsg.text} + )} + + {pwSaving ? 'Changing\u2026' : 'Change Password'} + + + + + ) +} + +function TokensTab() { + const [tokens, setTokens] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [newName, setNewName] = useState('') + const [creating, setCreating] = useState(false) + const [newToken, setNewToken] = useState(null) // { raw_token, name } + + const refresh = useCallback(async () => { + setLoading(true); setError(null) + try { setTokens(await listTokens()) } + catch (e) { setError(e.message) } + finally { setLoading(false) } + }, []) + + useEffect(() => { refresh() }, [refresh]) + + async function handleCreate(e) { + e.preventDefault() + if (!newName.trim()) return + setCreating(true) + try { + const tok = await createToken(newName.trim()) + setNewToken(tok) + setNewName('') + refresh() + } catch (err) { + setError(err.message) + } finally { + setCreating(false) + } + } + + async function handleDelete(tokenUid) { + try { + await deleteToken(tokenUid) + setTokens(ts => ts.filter(t => t.token_uid !== tokenUid)) + } catch (err) { + setError(err.message) + } + } + + return ( + + API Tokens + {newToken && ( + + Token created. Copy it now \u2014 it will not be shown again. + + {newToken.raw_token} + + setNewToken(null)}>Dismiss + + )} + + setNewName(e.target.value)} style={{ flex: 1 }} required /> + + {creating ? 'Creating\u2026' : 'Create'} + + + {error && {error}} + {loading ? Loading\u2026 : ( + + {tokens.length === 0 && No tokens yet.} + {tokens.map(tok => ( + + + {tok.name} + + Created {tok.created_at.slice(0, 10)} + {tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`} + + + handleDelete(tok.token_uid)} + style={{ border: '1px solid var(--line)', background: 'none', cursor: 'pointer', + color: 'var(--accent)', fontSize: 12, padding: '4px 10px' }}> + Revoke + + + ))} + + )} + + ) +} + +function InstanceTab() { + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [saveMsg, setSaveMsg] = useState(null) + + useEffect(() => { + (async () => { + try { setSettings(await getInstanceSettings()) } + catch (e) { setError(e.message) } + finally { setLoading(false) } + })() + }, []) + + async function handleSave(e) { + e.preventDefault() + setSaving(true); setSaveMsg(null) + try { + await updateInstanceSettings(settings) + setSaveMsg({ ok: true, text: 'Saved.' }) + } catch (err) { + setSaveMsg({ ok: false, text: err.message }) + } finally { + setSaving(false) + } + } + + if (loading) return Loading\u2026 + if (error) return {error} + if (!settings) return null + + return ( + + Instance Settings + + + {[ + ['public_index_enabled', 'Public index (unauthenticated browsing)'], + ['public_entry_content_enabled', 'Public entry content'], + ['open_registration_enabled', 'Open registration'], + ].map(([key, label]) => ( + + setSettings(s => ({ ...s, [key]: e.target.checked }))} /> + {label} + + ))} + + Default entry visibility + setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}> + Private (0) + Unlisted (2) + Public (3) + + + + {saveMsg && {saveMsg.text}} + + {saving ? 'Saving\u2026' : 'Save Settings'} + + + + ) +} diff --git a/frontend/src/components/Topbar.jsx b/frontend/src/components/Topbar.jsx index bf6ced4..e188cec 100644 --- a/frontend/src/components/Topbar.jsx +++ b/frontend/src/components/Topbar.jsx @@ -21,7 +21,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV {archives.map(a => {a.label})} - {['archive', 'runs', 'admin', 'tags', 'collections'].map(name => ( + {['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => ( onViewChange(name)}> {name.charAt(0).toUpperCase() + name.slice(1)} @@ -31,7 +31,8 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV + Capture {currentUser && ( - {currentUser.username} + {currentUser.display_name || currentUser.username} + onViewChange('settings')} style={{ color: '#d7cdbf', fontSize: 13 }}>Settings {loggingOut ? 'Logging out\u2026' : 'Log out'}
+ {newToken.raw_token} +