mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
- 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
280 lines
9.8 KiB
JavaScript
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>
|
|
)
|
|
}
|