1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00
archivr/frontend/src/components/SettingsView.jsx
TheGeneralist 4311e85f95
ui: comprehensive UI polish pass
- LoginPage/SetupPage: centered card layout with display font, styled fields and submit button
- Topbar: fix duplicate Settings nav item; add styled user-menu with username + logout button
- RunsView: format ISO timestamps to readable dates, add colored status badges (completed/failed/running)
- AdminView: view-tabs system, styled admin-table, admin-input, status-badge (active/disabled), btn-primary
- SettingsView: replace all inline styles with form-section/form-field/field-input/btn-primary/btn-danger
- CollectionsView: restructure create form with proper field labels and btn-primary
- TagsView: add Tags section heading with separator
- ContextRail: show visibility as human-readable label not raw number; fix assign-error class
- styles.css: add auth-loading, view-tabs, form utilities, btn variants, status badges,
  run-status pills, token-banner/row, checkbox-row, coll-create-form, tag-tree-header CSS
2026-06-28 22:14:08 +02:00

280 lines
9.8 KiB
JavaScript

import { useState, useEffect, useContext, useCallback } from 'react'
import { AuthContext } from '../App.jsx'
import {
updateProfile, changePassword,
listTokens, createToken, deleteToken,
getInstanceSettings, updateInstanceSettings,
} from '../api.js'
const ROLE_ADMIN = 4
export default function SettingsView() {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const [tab, setTab] = useState('profile')
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance' }
return (
<section className="admin-view">
<h1>Settings</h1>
<div className="view-tabs">
{tabs.map(t => (
<button key={t}
className={`view-tab${tab === t ? ' is-active' : ''}`}
onClick={() => setTab(t)}>
{tabLabels[t]}
</button>
))}
</div>
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />}
</section>
)
}
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 (
<div style={{ maxWidth: 440 }}>
<div className="form-section">
<h2>Display Name</h2>
<form onSubmit={handleSaveProfile}>
<div className="form-field">
<label className="form-label" htmlFor="display-name">Name shown in the UI</label>
<input className="field-input" id="display-name"
placeholder={currentUser?.username ?? ''}
value={displayName} onChange={e => setDisplayName(e.target.value)} />
</div>
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save'}
</button>
</form>
</div>
<div className="form-section">
<h2>Change Password</h2>
<form onSubmit={handleChangePassword}>
<div className="form-field">
<label className="form-label" htmlFor="cur-pw">Current password</label>
<input className="field-input" id="cur-pw" type="password"
value={curPw} onChange={e => setCurPw(e.target.value)} required />
</div>
<div className="form-field">
<label className="form-label" htmlFor="new-pw">New password</label>
<input className="field-input" id="new-pw" type="password"
value={newPw} onChange={e => setNewPw(e.target.value)} required />
</div>
<div className="form-field">
<label className="form-label" htmlFor="confirm-pw">Confirm new password</label>
<input className="field-input" id="confirm-pw" type="password"
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
</div>
{pwMsg && <div className={`form-msg form-msg--${pwMsg.ok ? 'ok' : 'err'}`}>{pwMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={pwSaving}>
{pwSaving ? 'Changing\u2026' : 'Change Password'}
</button>
</form>
</div>
</div>
)
}
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)
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 (
<div style={{ maxWidth: 600 }}>
<div className="form-section">
<h2>API Tokens</h2>
{newToken && (
<div className="token-banner">
<strong>Token created.</strong> Copy it now it won't be shown again.
<code>{newToken.raw_token}</code>
<button className="token-dismiss" onClick={() => setNewToken(null)}>Dismiss</button>
</div>
)}
<form className="token-create-row" onSubmit={handleCreate}>
<input className="field-input field-input--flex" placeholder="Token name"
value={newName} onChange={e => setNewName(e.target.value)} required />
<button className="btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create token'}
</button>
</form>
{error && <div className="form-msg form-msg--err">{error}</div>}
{loading ? (
<div className="muted">Loading\u2026</div>
) : (
<div>
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
{tokens.map(tok => (
<div key={tok.token_uid} className="token-row">
<div className="token-row-info">
<strong>{tok.name}</strong>
<div className="muted">
Created {tok.created_at.slice(0, 10)}
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
</div>
</div>
<button className="btn-danger" style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => handleDelete(tok.token_uid)}>
Revoke
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}
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 <div className="muted">Loading\u2026</div>
if (error) return <div className="form-msg form-msg--err">{error}</div>
if (!settings) return null
return (
<div style={{ maxWidth: 440 }}>
<div className="form-section">
<h2>Instance Settings</h2>
<form onSubmit={handleSave}>
{[
['public_index_enabled', 'Public index (unauthenticated browsing)'],
['public_entry_content_enabled', 'Public entry content'],
['open_registration_enabled', 'Open registration'],
].map(([key, label]) => (
<label key={key} className="checkbox-row">
<input type="checkbox" checked={!!settings[key]}
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
{label}
</label>
))}
<div className="form-field" style={{ marginTop: 4 }}>
<label className="form-label">Default entry visibility</label>
<select className="field-input" value={settings.default_entry_visibility}
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
<option value={0}>Private</option>
<option value={2}>Unlisted</option>
<option value={3}>Public</option>
</select>
</div>
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save Settings'}
</button>
</form>
</div>
</div>
)
}