mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +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:
commit
d558ce2a3f
8 changed files with 652 additions and 7 deletions
|
|
@ -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<Vec<ApiTokenR
|
|||
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.
|
||||
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])?;
|
||||
|
|
|
|||
|
|
@ -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<Json<serde_json::Value>, 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<String>) = 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<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(
|
||||
State(state): State<AppState>,
|
||||
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<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(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -1834,4 +1913,114 @@ mod tests {
|
|||
).await.unwrap();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
crates/archivr-server/static/assets/index-DRRKzKIq.js
Normal file
40
crates/archivr-server/static/assets/index-DRRKzKIq.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
<CollectionsView archiveId={archiveId} />
|
||||
)}
|
||||
{view === 'settings' && (
|
||||
<SettingsView />
|
||||
)}
|
||||
</div>
|
||||
<ContextRail
|
||||
archiveId={archiveId}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
271
frontend/src/components/SettingsView.jsx
Normal file
271
frontend/src/components/SettingsView.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>)}
|
||||
</select>
|
||||
<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' : ''}`}
|
||||
onClick={() => onViewChange(name)}>
|
||||
{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>
|
||||
{currentUser && (
|
||||
<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">
|
||||
{loggingOut ? 'Logging out\u2026' : 'Log out'}
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue