From 69af2d206072b4c2a234a98660b7c567b6cf866f Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:17:34 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(settings):=20db=20=E2=80=94=20display?= =?UTF-8?q?=5Fname=20column,=20InstanceSettings=20struct,=20profile=20and?= =?UTF-8?q?=20instance-settings=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/archivr-core/src/database.rs | 84 +++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index cce1d75..07126a3 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -130,6 +130,14 @@ pub struct RoleRecord { 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)] pub struct CollectionRecord { pub id: i64, @@ -417,6 +425,7 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> { username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, password_hash TEXT NOT NULL, + display_name TEXT, status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), role TEXT NOT NULL CHECK (role IN ('admin', 'user')), 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(()) } @@ -645,6 +657,78 @@ pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result Result { + 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> { + 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> { + 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. pub fn invalidate_user_sessions(conn: &Connection, user_id: i64) -> Result { let n = conn.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?; From dabe014e7ee4f4f23698adccc8f73faade0186dc Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:17:37 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(settings):=20routes=20=E2=80=94=20PATC?= =?UTF-8?q?H=20/api/auth/me,=20GET|PATCH=20/api/admin/instance-settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/archivr-server/src/routes.rs | 87 +++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index f5044fa..166a2c0 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -14,7 +14,9 @@ // OWNER — requires ROLE_OWNER: (future) // AUTH_SELF — own resources, require_auth() only: // 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}; @@ -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/login", axum::routing::post(auth_login)) .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/:token_uid", axum::routing::delete(delete_token)) .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/:role_slug", axum::routing::delete(admin_remove_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", get(list_collections_handler).post(create_collection_handler)) .route("/api/archives/:archive_id/collections/:coll_uid", @@ -581,15 +585,75 @@ async fn auth_me( ) -> Result, ApiError> { let (user_id, role_bits) = auth_user.require_auth()?; let conn = database::open_auth_db(&state.auth_db_path)?; - let username: String = conn - .query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| r.get(0)) + let (username, display_name): (String, Option) = conn + .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}")))?; Ok(Json(serde_json::json!({ "role_bits": role_bits, "username": username, + "display_name": display_name, }))) } +async fn patch_me( + State(state): State, + auth_user: AuthUser, + Json(body): Json, +) -> Result { + 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, + auth_user: AuthUser, +) -> Result, 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, + auth_user: AuthUser, + Json(body): Json, +) -> Result { + 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( State(state): State, auth_user: AuthUser, @@ -645,6 +709,21 @@ struct AdminAssignRoleBody { role_slug: String } #[derive(Debug, serde::Deserialize)] struct AdminCreateRoleBody { slug: String, name: String } +#[derive(Debug, serde::Deserialize)] +struct UpdateProfileBody { + display_name: Option, + current_password: Option, + new_password: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct UpdateInstanceSettingsBody { + public_index_enabled: Option, + public_entry_content_enabled: Option, + open_registration_enabled: Option, + default_entry_visibility: Option, +} + async fn admin_list_users( State(state): State, auth_user: AuthUser, From 677e5c439b35a2c7b9306d3e45ca5c3b6b3812a9 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:17:40 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(settings):=20frontend=20=E2=80=94=20ap?= =?UTF-8?q?i.js=20helpers=20for=20profile,=20tokens,=20and=20instance=20se?= =?UTF-8?q?ttings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api.js | 56 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/frontend/src/api.js b/frontend/src/api.js index aa5695a..334fbf6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -112,6 +112,62 @@ export async function fetchMe() { 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 ───────────────────────────────────────────────────────────── export async function listAdminUsers() { From 78e763c3777ccb352d0344bc96528b863ffb384c Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:17:45 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(settings):=20frontend=20=E2=80=94=20Se?= =?UTF-8?q?ttingsView=20component,=20settings=20nav,=20App=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 4 + frontend/src/components/SettingsView.jsx | 271 +++++++++++++++++++++++ frontend/src/components/Topbar.jsx | 5 +- 3 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/SettingsView.jsx 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 => ( + + ))} +
+ + {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}
+ )} + +
+
+ +
+

Change Password

+
+ setCurPw(e.target.value)} required /> + setNewPw(e.target.value)} required /> + setConfirmPw(e.target.value)} required /> + {pwMsg && ( +
{pwMsg.text}
+ )} + +
+
+
+ ) +} + +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} + + +
+ )} +
+ setNewName(e.target.value)} style={{ flex: 1 }} required /> + +
+ {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)}`} +
+
+ +
+ ))} +
+ )} +
+ ) +} + +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]) => ( + + ))} +
+ + +
+
+ {saveMsg &&
{saveMsg.text}
} + +
+
+ ) +} 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 => )}