1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

feat(settings): Track 7 — account profile, password change, API tokens UI, instance settings

- db: display_name column migration, InstanceSettings struct, 6 new pub helpers
  (get/update_instance_settings, update_user_display_name, update_user_password,
  get_user_password_hash, get_user_display_name)
- routes: PATCH /api/auth/me (display name + password change, current-password verify);
  GET|PATCH /api/admin/instance-settings (ROLE_ADMIN); auth/me now returns display_name
- frontend/api.js: updateProfile, changePassword, listTokens, createToken,
  deleteToken, getInstanceSettings, updateInstanceSettings helpers
- frontend: SettingsView (Profile / API Tokens / Instance tabs), settings nav in
  Topbar, App routing for view === 'settings'
- 7 new routes tests; 176 tests green
This commit is contained in:
TheGeneralist 2026-06-26 17:19:43 +02:00
commit d558ce2a3f
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 652 additions and 7 deletions

View file

@ -130,6 +130,14 @@ pub struct RoleRecord {
pub is_builtin: bool, pub is_builtin: bool,
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InstanceSettings {
pub public_index_enabled: bool,
pub public_entry_content_enabled: bool,
pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column
pub default_entry_visibility: u32,
}
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
pub struct CollectionRecord { pub struct CollectionRecord {
pub id: i64, pub id: i64,
@ -417,6 +425,7 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE, email TEXT UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
display_name TEXT,
status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), status TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
role TEXT NOT NULL CHECK (role IN ('admin', 'user')), role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@ -424,6 +433,9 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
); );
"#, "#,
)?; )?;
// Add display_name column to users if not present (idempotent migration)
let _ = conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT", []);
Ok(()) Ok(())
} }
@ -645,6 +657,78 @@ pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result<Vec<ApiTokenR
Ok(records) Ok(records)
} }
pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
conn.query_row(
"SELECT public_index_enabled, public_entry_content_enabled,
public_archive_submission_enabled, default_entry_visibility
FROM instance_settings WHERE id = 1",
[],
|row| {
Ok(InstanceSettings {
public_index_enabled: row.get::<_, i64>(0)? != 0,
public_entry_content_enabled: row.get::<_, i64>(1)? != 0,
open_registration_enabled: row.get::<_, i64>(2)? != 0,
default_entry_visibility: row.get::<_, i64>(3)? as u32,
})
},
)
.map_err(Into::into)
}
pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings) -> Result<()> {
conn.execute(
"UPDATE instance_settings
SET public_index_enabled = ?1,
public_entry_content_enabled = ?2,
public_archive_submission_enabled = ?3,
default_entry_visibility = ?4
WHERE id = 1",
params![
settings.public_index_enabled as i64,
settings.public_entry_content_enabled as i64,
settings.open_registration_enabled as i64,
settings.default_entry_visibility as i64,
],
)?;
Ok(())
}
pub fn get_user_password_hash(conn: &Connection, user_id: i64) -> Result<Option<String>> {
conn.query_row(
"SELECT password_hash FROM users WHERE id = ?1",
[user_id],
|r| r.get(0),
)
.optional()
.map_err(Into::into)
}
pub fn update_user_password(conn: &Connection, user_id: i64, new_hash: &str) -> Result<()> {
conn.execute(
"UPDATE users SET password_hash = ?1 WHERE id = ?2",
params![new_hash, user_id],
)?;
Ok(())
}
pub fn update_user_display_name(conn: &Connection, user_id: i64, display_name: Option<&str>) -> Result<()> {
conn.execute(
"UPDATE users SET display_name = ?1 WHERE id = ?2",
params![display_name, user_id],
)?;
Ok(())
}
pub fn get_user_display_name(conn: &Connection, user_id: i64) -> Result<Option<String>> {
conn.query_row(
"SELECT display_name FROM users WHERE id = ?1",
[user_id],
|r| r.get(0),
)
.optional()
.map_err(Into::into)
}
/// Deletes all sessions for a user. Called on ban or role change. /// Deletes all sessions for a user. Called on ban or role change.
pub fn invalidate_user_sessions(conn: &Connection, user_id: i64) -> Result<usize> { pub fn invalidate_user_sessions(conn: &Connection, user_id: i64) -> Result<usize> {
let n = conn.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?; let n = conn.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;

View file

@ -14,7 +14,9 @@
// OWNER — requires ROLE_OWNER: (future) // OWNER — requires ROLE_OWNER: (future)
// AUTH_SELF — own resources, require_auth() only: // AUTH_SELF — own resources, require_auth() only:
// GET/POST/DELETE /api/auth/tokens // GET/POST/DELETE /api/auth/tokens
// POST /api/auth/logout, GET /api/auth/me // POST /api/auth/logout, GET/PATCH /api/auth/me
// SETTINGS — instance settings, require ROLE_ADMIN:
// GET/PATCH /api/admin/instance-settings
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
use std::{path::PathBuf, sync::Arc}; use std::{path::PathBuf, sync::Arc};
@ -120,7 +122,7 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
.route("/api/auth/setup", axum::routing::get(auth_setup_status).post(auth_setup)) .route("/api/auth/setup", axum::routing::get(auth_setup_status).post(auth_setup))
.route("/api/auth/login", axum::routing::post(auth_login)) .route("/api/auth/login", axum::routing::post(auth_login))
.route("/api/auth/logout", axum::routing::post(auth_logout)) .route("/api/auth/logout", axum::routing::post(auth_logout))
.route("/api/auth/me", axum::routing::get(auth_me)) .route("/api/auth/me", axum::routing::get(auth_me).patch(patch_me))
.route("/api/auth/tokens", axum::routing::get(list_tokens).post(create_token)) .route("/api/auth/tokens", axum::routing::get(list_tokens).post(create_token))
.route("/api/auth/tokens/:token_uid", axum::routing::delete(delete_token)) .route("/api/auth/tokens/:token_uid", axum::routing::delete(delete_token))
.route("/api/admin/users", get(admin_list_users).post(admin_create_user)) .route("/api/admin/users", get(admin_list_users).post(admin_create_user))
@ -128,6 +130,8 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
.route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role)) .route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role))
.route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role)) .route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role))
.route("/api/admin/roles", get(admin_list_roles).post(admin_create_role)) .route("/api/admin/roles", get(admin_list_roles).post(admin_create_role))
.route("/api/admin/instance-settings",
get(get_instance_settings_handler).patch(update_instance_settings_handler))
.route("/api/archives/:archive_id/collections", .route("/api/archives/:archive_id/collections",
get(list_collections_handler).post(create_collection_handler)) get(list_collections_handler).post(create_collection_handler))
.route("/api/archives/:archive_id/collections/:coll_uid", .route("/api/archives/:archive_id/collections/:coll_uid",
@ -581,15 +585,75 @@ async fn auth_me(
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let (user_id, role_bits) = auth_user.require_auth()?; let (user_id, role_bits) = auth_user.require_auth()?;
let conn = database::open_auth_db(&state.auth_db_path)?; let conn = database::open_auth_db(&state.auth_db_path)?;
let username: String = conn let (username, display_name): (String, Option<String>) = conn
.query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| r.get(0)) .query_row(
"SELECT username, display_name FROM users WHERE id = ?1",
[user_id],
|r| Ok((r.get(0)?, r.get(1)?))
)
.map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?; .map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"role_bits": role_bits, "role_bits": role_bits,
"username": username, "username": username,
"display_name": display_name,
}))) })))
} }
async fn patch_me(
State(state): State<AppState>,
auth_user: AuthUser,
Json(body): Json<UpdateProfileBody>,
) -> Result<StatusCode, ApiError> {
let (user_id, _) = auth_user.require_auth()?;
let conn = database::open_auth_db(&state.auth_db_path)?;
if let Some(ref new_pw) = body.new_password {
if new_pw.trim().is_empty() {
return Err(ApiError::bad_request("new_password must not be blank"));
}
let current_pw = body.current_password.as_deref().unwrap_or("");
let hash = database::get_user_password_hash(&conn, user_id)?
.ok_or_else(|| ApiError::not_found("user not found"))?;
if !auth::verify_password(current_pw, &hash).map_err(ApiError::from)? {
return Err(ApiError::unauthorized("current password is incorrect"));
}
let new_hash = auth::hash_password(new_pw).map_err(ApiError::from)?;
database::update_user_password(&conn, user_id, &new_hash)?;
}
if let Some(ref dn) = body.display_name {
let v: Option<&str> = if dn.trim().is_empty() { None } else { Some(dn.as_str()) };
database::update_user_display_name(&conn, user_id, v)?;
}
Ok(StatusCode::NO_CONTENT)
}
async fn get_instance_settings_handler(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<Json<database::InstanceSettings>, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let conn = database::open_auth_db(&state.auth_db_path)?;
Ok(Json(database::get_instance_settings(&conn)?))
}
async fn update_instance_settings_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Json(body): Json<UpdateInstanceSettingsBody>,
) -> Result<StatusCode, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let conn = database::open_auth_db(&state.auth_db_path)?;
let mut settings = database::get_instance_settings(&conn)?;
if let Some(v) = body.public_index_enabled { settings.public_index_enabled = v; }
if let Some(v) = body.public_entry_content_enabled { settings.public_entry_content_enabled = v; }
if let Some(v) = body.open_registration_enabled { settings.open_registration_enabled = v; }
if let Some(v) = body.default_entry_visibility { settings.default_entry_visibility = v; }
database::update_instance_settings(&conn, &settings)?;
Ok(StatusCode::NO_CONTENT)
}
async fn create_token( async fn create_token(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
@ -645,6 +709,21 @@ struct AdminAssignRoleBody { role_slug: String }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
struct AdminCreateRoleBody { slug: String, name: String } struct AdminCreateRoleBody { slug: String, name: String }
#[derive(Debug, serde::Deserialize)]
struct UpdateProfileBody {
display_name: Option<String>,
current_password: Option<String>,
new_password: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct UpdateInstanceSettingsBody {
public_index_enabled: Option<bool>,
public_entry_content_enabled: Option<bool>,
open_registration_enabled: Option<bool>,
default_entry_visibility: Option<u32>,
}
async fn admin_list_users( async fn admin_list_users(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
@ -1834,4 +1913,114 @@ mod tests {
).await.unwrap(); ).await.unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
} }
#[tokio::test]
async fn auth_me_returns_display_name_field() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path).oneshot(
Request::builder().uri("/api/auth/me")
.header("cookie", &session_cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("display_name").is_some(), "auth/me must include display_name field");
assert!(json.get("username").is_some());
}
#[tokio::test]
async fn patch_me_updates_display_name() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path).oneshot(
Request::builder().method("PATCH").uri("/api/auth/me")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(Body::from(r#"{"display_name":"Test Owner"}"#))
.unwrap()
).await.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn patch_me_requires_auth() {
let (test_app, _dir) = make_test_app();
let response = test_app.oneshot(
Request::builder().method("PATCH").uri("/api/auth/me")
.header("content-type", "application/json")
.body(Body::from(r#"{"display_name":"anon"}"#))
.unwrap()
).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn patch_me_rejects_wrong_current_password() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
// Set a real password hash on the owner
{
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
let hash = crate::auth::hash_password("real_password").unwrap();
let user_id: i64 = conn.query_row(
"SELECT id FROM users WHERE username = 'testowner'", [], |r| r.get(0)
).unwrap();
archivr_core::database::update_user_password(&conn, user_id, &hash).unwrap();
}
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path).oneshot(
Request::builder().method("PATCH").uri("/api/auth/me")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(Body::from(r#"{"current_password":"wrong","new_password":"newpass"}"#))
.unwrap()
).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn instance_settings_requires_admin() {
let (test_app, _dir) = make_test_app();
let response = test_app.oneshot(
Request::builder().uri("/api/admin/instance-settings")
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn instance_settings_get_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path).oneshot(
Request::builder().uri("/api/admin/instance-settings")
.header("cookie", &session_cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["public_index_enabled"], false);
assert_eq!(json["open_registration_enabled"], false);
}
#[tokio::test]
async fn instance_settings_patch_updates_fields() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path).oneshot(
Request::builder().method("PATCH").uri("/api/admin/instance-settings")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(Body::from(r#"{"open_registration_enabled":true}"#))
.unwrap()
).await.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
} }

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-BfwsUa2j.js"></script> <script type="module" crossorigin src="/assets/index-DRRKzKIq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css"> <link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
</head> </head>
<body> <body>

View file

@ -10,6 +10,7 @@ import RunsView from './components/RunsView'
import AdminView from './components/AdminView' import AdminView from './components/AdminView'
import TagsView from './components/TagsView' import TagsView from './components/TagsView'
import CollectionsView from './components/CollectionsView' import CollectionsView from './components/CollectionsView'
import SettingsView from './components/SettingsView'
import ContextRail from './components/ContextRail' import ContextRail from './components/ContextRail'
export const AuthContext = createContext(null); export const AuthContext = createContext(null);
@ -217,6 +218,9 @@ export default function App() {
{view === 'collections' && ( {view === 'collections' && (
<CollectionsView archiveId={archiveId} /> <CollectionsView archiveId={archiveId} />
)} )}
{view === 'settings' && (
<SettingsView />
)}
</div> </div>
<ContextRail <ContextRail
archiveId={archiveId} archiveId={archiveId}

View file

@ -112,6 +112,62 @@ export async function fetchMe() {
return r.json(); return r.json();
} }
// ── Profile & settings helpers ───────────────────────────────────────────────
export async function updateProfile(displayName) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: displayName }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function changePassword(currentPassword, newPassword) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
if (!res.ok) {
let msg = await res.text();
try { msg = JSON.parse(msg).error ?? msg; } catch {}
throw new Error(msg);
}
}
export async function listTokens() {
return getJson('/api/auth/tokens');
}
export async function createToken(name) {
const res = await fetch('/api/auth/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteToken(tokenUid) {
const res = await fetch(`/api/auth/tokens/${tokenUid}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
}
export async function getInstanceSettings() {
return getJson('/api/admin/instance-settings');
}
export async function updateInstanceSettings(patch) {
const res = await fetch('/api/admin/instance-settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(await res.text());
}
// ── Admin helpers ───────────────────────────────────────────────────────────── // ── Admin helpers ─────────────────────────────────────────────────────────────
export async function listAdminUsers() { export async function listAdminUsers() {

View file

@ -0,0 +1,271 @@
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
const ROLE_OWNER = 8
export default function SettingsView() {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const [tab, setTab] = useState('profile')
return (
<section className="admin-view">
<h1>Settings</h1>
<div className="admin-tabs" style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
{['profile', 'tokens', ...(isAdmin ? ['instance'] : [])].map(t => (
<button key={t}
className={`nav-link${tab === t ? ' is-active' : ''}`}
style={{ borderBottom: tab === t ? '2px solid var(--accent)' : undefined, color: 'var(--ink)' }}
onClick={() => setTab(t)}>
{t === 'profile' ? 'Profile' : t === 'tokens' ? 'API Tokens' : 'Instance'}
</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: 480 }}>
<div className="admin-section">
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Display Name</h2>
<form className="admin-form" onSubmit={handleSaveProfile}>
<input className="admin-input" placeholder={currentUser?.username ?? ''}
value={displayName} onChange={e => setDisplayName(e.target.value)} />
{saveMsg && (
<div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>
)}
<button className="capture-submit" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save'}
</button>
</form>
</div>
<div className="admin-section" style={{ marginTop: 28 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Change Password</h2>
<form className="admin-form" onSubmit={handleChangePassword}>
<input className="admin-input" type="password" placeholder="Current password"
value={curPw} onChange={e => setCurPw(e.target.value)} required />
<input className="admin-input" type="password" placeholder="New password"
value={newPw} onChange={e => setNewPw(e.target.value)} required />
<input className="admin-input" type="password" placeholder="Confirm new password"
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
{pwMsg && (
<div className={pwMsg.ok ? 'muted' : 'capture-error'}>{pwMsg.text}</div>
)}
<button className="capture-submit" 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) // { 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 (
<div style={{ maxWidth: 640 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>API Tokens</h2>
{newToken && (
<div style={{ background: '#e8f5e9', border: '1px solid #a5d6a7', padding: '12px 14px', marginBottom: 16, fontSize: 13 }}>
<strong>Token created.</strong> Copy it now \u2014 it will not be shown again.<br />
<code style={{ wordBreak: 'break-all', display: 'block', marginTop: 6, padding: '6px 8px', background: '#f5f5f5', border: '1px solid #ddd' }}>
{newToken.raw_token}
</code>
<button style={{ marginTop: 8, fontSize: 12, border: '1px solid #aaa', background: 'none', cursor: 'pointer' }}
onClick={() => setNewToken(null)}>Dismiss</button>
</div>
)}
<form className="admin-form" onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input className="admin-input" placeholder="Token name" value={newName}
onChange={e => setNewName(e.target.value)} style={{ flex: 1 }} required />
<button className="capture-submit" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create'}
</button>
</form>
{error && <div className="capture-error">{error}</div>}
{loading ? <div className="muted">Loading\u2026</div> : (
<div className="admin-list">
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
{tokens.map(tok => (
<div key={tok.token_uid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid var(--line)', background: 'var(--paper-3)', padding: '10px 12px' }}>
<div>
<strong style={{ fontSize: 14 }}>{tok.name}</strong>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
Created {tok.created_at.slice(0, 10)}
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
</div>
</div>
<button onClick={() => handleDelete(tok.token_uid)}
style={{ border: '1px solid var(--line)', background: 'none', cursor: 'pointer',
color: 'var(--accent)', fontSize: 12, padding: '4px 10px' }}>
Revoke
</button>
</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="capture-error">{error}</div>
if (!settings) return null
return (
<div style={{ maxWidth: 480 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Instance Settings</h2>
<form onSubmit={handleSave}>
<div className="admin-section">
{[
['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} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, cursor: 'pointer' }}>
<input type="checkbox" checked={!!settings[key]}
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
{label}
</label>
))}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Default entry visibility</label>
<select className="admin-input" value={settings.default_entry_visibility}
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
<option value={0}>Private (0)</option>
<option value={2}>Unlisted (2)</option>
<option value={3}>Public (3)</option>
</select>
</div>
</div>
{saveMsg && <div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>}
<button className="capture-submit" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save Settings'}
</button>
</form>
</div>
)
}

View file

@ -21,7 +21,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)} {archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
</select> </select>
<nav className="nav" aria-label="Primary"> <nav className="nav" aria-label="Primary">
{['archive', 'runs', 'admin', 'tags', 'collections'].map(name => ( {['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`} <button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
onClick={() => onViewChange(name)}> onClick={() => onViewChange(name)}>
{name.charAt(0).toUpperCase() + name.slice(1)} {name.charAt(0).toUpperCase() + name.slice(1)}
@ -31,7 +31,8 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button> <button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
{currentUser && ( {currentUser && (
<div className="user-menu"> <div className="user-menu">
<span className="username">{currentUser.username}</span> <span className="username">{currentUser.display_name || currentUser.username}</span>
<button className="nav-link" onClick={() => onViewChange('settings')} style={{ color: '#d7cdbf', fontSize: 13 }}>Settings</button>
<button onClick={handleLogout} disabled={loggingOut} className="logout-btn"> <button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
{loggingOut ? 'Logging out\u2026' : 'Log out'} {loggingOut ? 'Logging out\u2026' : 'Log out'}
</button> </button>