From 8013096df8643f0d02cce6eb29283106bc1ba4de Mon Sep 17 00:00:00 2001 From: savioruz Date: Tue, 25 Aug 2026 22:42:39 +0700 Subject: [PATCH 1/2] feat(web): add account management page + password recovery (#50) The self-hosted single-admin account had no way to change its password or email after setup, and no documented recovery path if the password was forgotten entirely. Add an Account page (/account) to the dashboard: - Change email (updates users.email; login uses the new address). - Change password (current password required to confirm; validates the new password against the shared policy and writes a fresh salt+hash). - Wrong current password is rejected with a clear error. Backend: DbClient::find_user_by_id / update_password / update_email, auth service change_password / change_email, DTOs, WebServices facades, and ApiError derives Debug. Recovery: memayu reset-password '' CLI command resets the admin password without the login gate (memayu-identity::reset_password, works for libsql and postgres). Documented in the README. Tests: 5 auth service tests (change works, old rejected, wrong current rejected, weak/mismatch rejected, email change used by login), 2 identity reset_password tests, and a dashboard HTTP integration test covering the full /account flow. --- README.md | 25 +- bin/memayu/src/cli.rs | 15 + bin/memayu/src/main.rs | 7 + crates/memayu-api/src/error.rs | 1 + crates/memayu-api/src/modules/auth/dto.rs | 12 + crates/memayu-api/src/modules/auth/repo.rs | 96 +++++++ crates/memayu-api/src/modules/auth/service.rs | 220 +++++++++++++++ crates/memayu-api/src/web_services.rs | 17 ++ crates/memayu-identity/src/lib.rs | 93 +++++++ crates/memayu-web/src/components.rs | 17 +- crates/memayu-web/src/lib.rs | 10 + crates/memayu-web/src/pages/account.rs | 202 ++++++++++++++ crates/memayu-web/src/pages/mod.rs | 1 + crates/memayu-web/tests/dashboard.rs | 261 ++++++++++++++++++ 14 files changed, 973 insertions(+), 4 deletions(-) create mode 100644 crates/memayu-web/src/pages/account.rs diff --git a/README.md b/README.md index 823b194..d866df0 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,29 @@ to the previous field or step, `↑`/`↓` pick a select option, and `Esc`/`Ctrl cancels. Other subcommands: `memayu config show|check`, `memayu add`, `memayu search`, -`memayu list`, `memayu get`, `memayu delete`, `memayu doctor`, plus -`memayu serve` (web, `web` feature) and `memayu mcp` (`mcp` feature). +`memayu list`, `memayu get`, `memayu delete`, `memayu reset-password`, +`memayu doctor`, plus `memayu serve` (web, `web` feature) and `memayu mcp` +(`mcp` feature). + +#### Account management + +The web dashboard exposes an **Account** page (`/accounts`) where the logged-in +admin can change the email address and change the password (the current +password is required to confirm the change). A changed password takes effect +on the next login; the old password no longer works. + +If the admin forgets their password entirely (and cannot log in to open +`/accounts`), reset it from the terminal: + +```bash +memayu reset-password 'NewHorse-Staple-99!' +``` + +This bypasses the login gate and sets a fresh password for the single admin +account, validating it against the same policy the UI enforces. It runs against +the same `config.toml` / `MEMAYU_*` env settings the server uses, so it works +for both libsql and Postgres backends. After resetting, log in with the new +password and rotate it from `/accounts` if you like. ### HTTP API diff --git a/bin/memayu/src/cli.rs b/bin/memayu/src/cli.rs index 305dd8b..4159ee6 100644 --- a/bin/memayu/src/cli.rs +++ b/bin/memayu/src/cli.rs @@ -412,3 +412,18 @@ fn print_memory(m: &Memory, json: bool) -> Result<(), String> { } Ok(()) } + +/// `memayu reset-password ` — recover a forgotten admin password. +/// +/// Bypasses the login gate entirely and resets the admin account's password to +/// the given value (validated against the shared policy). This is the +/// documented recovery path for self-hosted instances. +pub async fn cmd_reset_password(config: &Config, password: &str) -> Result<(), String> { + guard_local(config)?; + memayu_identity::reset_password(&config.storage, password) + .await + .map_err(|e| e.to_string())?; + println!("Admin password updated."); + println!("Use it to log in to the dashboard, then rotate it from /account if desired."); + Ok(()) +} diff --git a/bin/memayu/src/main.rs b/bin/memayu/src/main.rs index 086b80b..bbb90f0 100644 --- a/bin/memayu/src/main.rs +++ b/bin/memayu/src/main.rs @@ -49,6 +49,12 @@ async fn main() -> Result<(), Box> { let config = Config::load()?; run_cli(cli::cmd_delete(&config, args).await); } + // Account recovery: reset the admin password without the login gate. + "reset-password" => { + let config = Config::load()?; + let password = args.next().unwrap_or_default(); + run_cli(cli::cmd_reset_password(&config, &password).await); + } // Diagnostics: never requires a fully valid config, so it can report // exactly what is wrong. "doctor" => { @@ -230,6 +236,7 @@ fn usage() -> String { subcommands.push("list"); subcommands.push("get"); subcommands.push("delete"); + subcommands.push("reset-password"); subcommands.push("doctor"); #[cfg(feature = "tui")] subcommands.push("tui"); diff --git a/crates/memayu-api/src/error.rs b/crates/memayu-api/src/error.rs index c263d23..e64a6fb 100644 --- a/crates/memayu-api/src/error.rs +++ b/crates/memayu-api/src/error.rs @@ -20,6 +20,7 @@ pub struct ApiErrorBody { /// Transport-agnostic API error — no axum types in the struct. /// Transport layer is responsible for converting `status` (u16) into /// the appropriate HTTP response. +#[derive(Debug)] pub struct ApiError { pub status: u16, pub error: String, diff --git a/crates/memayu-api/src/modules/auth/dto.rs b/crates/memayu-api/src/modules/auth/dto.rs index f1bebdf..3a3be06 100644 --- a/crates/memayu-api/src/modules/auth/dto.rs +++ b/crates/memayu-api/src/modules/auth/dto.rs @@ -13,6 +13,18 @@ pub struct LoginRequest { pub password: String, } +#[derive(Debug, Deserialize)] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, + pub confirm: String, +} + +#[derive(Debug, Deserialize)] +pub struct ChangeEmailRequest { + pub email: String, +} + #[derive(Debug, Serialize)] pub struct AuthResponse { pub status: String, diff --git a/crates/memayu-api/src/modules/auth/repo.rs b/crates/memayu-api/src/modules/auth/repo.rs index 97deae3..028324f 100644 --- a/crates/memayu-api/src/modules/auth/repo.rs +++ b/crates/memayu-api/src/modules/auth/repo.rs @@ -129,6 +129,102 @@ impl DbClient { } } + pub async fn find_user_by_id(&self, user_id: &str) -> Result, String> { + match self { + DbClient::Libsql(conn) => { + let mut rows = conn + .query( + "SELECT id, email, password, salt FROM users WHERE id = ?1", + vec![user_id], + ) + .await + .map_err(|e| format!("find user by id: {e}"))?; + if let Some(row) = rows.next().await.map_err(|e| format!("read user: {e}"))? { + Ok(Some(User { + id: row.get(0).map_err(|e| format!("id: {e}"))?, + email: row.get(1).map_err(|e| format!("email: {e}"))?, + password: row.get(2).map_err(|e| format!("password: {e}"))?, + salt: row.get(3).map_err(|e| format!("salt: {e}"))?, + })) + } else { + Ok(None) + } + } + DbClient::Postgres(pool) => { + let row = sqlx::query_as::<_, (String, String, String, String)>( + "SELECT id, email, password, salt FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(|e| format!("find user by id: {e}"))?; + Ok(row.map(|(id, email, password, salt)| User { + id, + email, + password, + salt, + })) + } + } + } + + /// Update a user's password hash + salt. Returns `false` when the row does + /// not exist (so the caller can decide whether that is an error). + pub async fn update_password( + &self, + user_id: &str, + password_hash: &str, + salt: &str, + ) -> Result { + match self { + DbClient::Libsql(conn) => { + let res = conn + .execute( + "UPDATE users SET password = ?1, salt = ?2 WHERE id = ?3", + (password_hash, salt, user_id), + ) + .await + .map_err(|e| format!("update password: {e}"))?; + Ok(res > 0) + } + DbClient::Postgres(pool) => { + let res = sqlx::query("UPDATE users SET password = $1, salt = $2 WHERE id = $3") + .bind(password_hash) + .bind(salt) + .bind(user_id) + .execute(pool) + .await + .map_err(|e| format!("update password: {e}"))?; + Ok(res.rows_affected() > 0) + } + } + } + + /// Update a user's email. Returns `false` when the row does not exist. + pub async fn update_email(&self, user_id: &str, email: &str) -> Result { + match self { + DbClient::Libsql(conn) => { + let res = conn + .execute( + "UPDATE users SET email = ?1 WHERE id = ?2", + (email, user_id), + ) + .await + .map_err(|e| format!("update email: {e}"))?; + Ok(res > 0) + } + DbClient::Postgres(pool) => { + let res = sqlx::query("UPDATE users SET email = $1 WHERE id = $2") + .bind(email) + .bind(user_id) + .execute(pool) + .await + .map_err(|e| format!("update email: {e}"))?; + Ok(res.rows_affected() > 0) + } + } + } + // ── Sessions ── pub async fn create_session( diff --git a/crates/memayu-api/src/modules/auth/service.rs b/crates/memayu-api/src/modules/auth/service.rs index e2a9c81..71d4107 100644 --- a/crates/memayu-api/src/modules/auth/service.rs +++ b/crates/memayu-api/src/modules/auth/service.rs @@ -185,6 +185,88 @@ pub async fn logout(db: &DbClient, token: Option<&str>) -> Result Result<(), ApiError> { + let user = db + .find_user_by_id(user_id) + .await + .map_err(|e| ApiError { + status: 500, + error: "internal_error".into(), + message: e, + })? + .ok_or_else(|| ApiError { + status: 401, + error: "unauthorized".into(), + message: "account not found".into(), + })?; + + if hash_password(&user.salt, current_password) != user.password { + return Err(ApiError { + status: 400, + error: "bad_request".into(), + message: "current password is incorrect".into(), + }); + } + if let Some(msg) = validate_password(new_password) { + return Err(ApiError::bad_request(msg)); + } + if new_password != confirm { + return Err(ApiError::bad_request("passwords do not match")); + } + + let salt = new_salt(); + let hash = hash_password(&salt, new_password); + let ok = db + .update_password(user_id, &hash, &salt) + .await + .map_err(|e| ApiError { + status: 500, + error: "internal_error".into(), + message: e, + })?; + if !ok { + return Err(ApiError { + status: 401, + error: "unauthorized".into(), + message: "account not found".into(), + }); + } + Ok(()) +} + +/// Change the email for `user_id`. +pub async fn change_email(db: &DbClient, user_id: &str, email: &str) -> Result { + let email = email.trim(); + if email.is_empty() { + return Err(ApiError::bad_request("email is required")); + } + // The `users.email` column is UNIQUE; a duplicate returns a DB error below. + let ok = db + .update_email(user_id, email) + .await + .map_err(|e| ApiError { + status: 500, + error: "internal_error".into(), + message: e, + })?; + if !ok { + return Err(ApiError { + status: 401, + error: "unauthorized".into(), + message: "account not found".into(), + }); + } + Ok(email.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -297,4 +379,142 @@ mod tests { // Should parse back without error (chrono format test) assert!(chrono::DateTime::parse_from_rfc3339(&ts).is_ok()); } + + // ── change_password / change_email (issue #50) ── + + async fn mem_db_with_admin() -> (DbClient, String) { + let storage = memayu_config::StorageConfig { + backend: memayu_config::StorageBackend::Libsql, + libsql_path: ":memory:".to_string(), + database_url: None, + }; + let db = DbClient::open(&storage).await.unwrap(); + db.init().await.unwrap(); + let salt = new_salt(); + let hash = hash_password(&salt, "OldPass1"); + db.create_user("admin@example.com", &hash, &salt) + .await + .unwrap(); + let admin_id = db.find_user("admin@example.com").await.unwrap().unwrap().id; + (db, admin_id) + } + + #[tokio::test] + async fn change_password_updates_and_old_password_rejected() { + let (db, admin_id) = mem_db_with_admin().await; + match change_password(&db, &admin_id, "OldPass1", "NewPass2", "NewPass2").await { + Ok(()) => {} + Err(e) => panic!("change_password failed: {}", e.message), + } + + // Old password no longer works for login. + let login_old = match login( + &db, + &LoginRequest { + email: "admin@example.com".into(), + password: "OldPass1".into(), + }, + ) + .await + { + Ok(_) => panic!("old password must be rejected"), + Err(e) => e, + }; + assert_eq!(login_old.status, 401); + + // New password works. + let login_new = login( + &db, + &LoginRequest { + email: "admin@example.com".into(), + password: "NewPass2".into(), + }, + ) + .await + .expect("new password must work"); + assert_eq!(login_new.0.status, "ok"); + } + + #[tokio::test] + async fn change_password_rejects_wrong_current_password() { + let (db, admin_id) = mem_db_with_admin().await; + let err = change_password(&db, &admin_id, "Wrong1", "NewPass2", "NewPass2") + .await + .unwrap_err(); + assert_eq!(err.status, 400); + assert_eq!(err.message, "current password is incorrect"); + + // Old password still works (nothing changed). + let login_old = login( + &db, + &LoginRequest { + email: "admin@example.com".into(), + password: "OldPass1".into(), + }, + ) + .await + .expect("old password must still work"); + assert_eq!(login_old.0.status, "ok"); + } + + #[tokio::test] + async fn change_password_rejects_weak_and_mismatched_new_password() { + let (db, admin_id) = mem_db_with_admin().await; + let weak = change_password(&db, &admin_id, "OldPass1", "weak", "weak") + .await + .unwrap_err(); + assert_eq!(weak.status, 400); + assert_eq!(weak.message, "Password must be at least 8 characters."); + + let mismatch = change_password(&db, &admin_id, "OldPass1", "NewPass2", "OtherPass3") + .await + .unwrap_err(); + assert_eq!(mismatch.status, 400); + assert_eq!(mismatch.message, "passwords do not match"); + } + + #[tokio::test] + async fn change_email_updates_and_is_used_by_login() { + let (db, admin_id) = mem_db_with_admin().await; + let new_email = match change_email(&db, &admin_id, "new@example.com").await { + Ok(e) => e, + Err(e) => panic!("change_email failed: {}", e.message), + }; + assert_eq!(new_email, "new@example.com"); + + // The new email is what login resolves. + let login_new = login( + &db, + &LoginRequest { + email: "new@example.com".into(), + password: "OldPass1".into(), + }, + ) + .await + .expect("new email must work"); + assert_eq!(login_new.0.status, "ok"); + + // The old email no longer resolves. + let login_old = match login( + &db, + &LoginRequest { + email: "admin@example.com".into(), + password: "OldPass1".into(), + }, + ) + .await + { + Ok(_) => panic!("old email must be rejected"), + Err(e) => e, + }; + assert_eq!(login_old.status, 401); + } + + #[tokio::test] + async fn change_email_rejects_empty() { + let (db, admin_id) = mem_db_with_admin().await; + let err = change_email(&db, &admin_id, " ").await.unwrap_err(); + assert_eq!(err.status, 400); + assert_eq!(err.message, "email is required"); + } } diff --git a/crates/memayu-api/src/web_services.rs b/crates/memayu-api/src/web_services.rs index 28ec3c9..62db3cd 100644 --- a/crates/memayu-api/src/web_services.rs +++ b/crates/memayu-api/src/web_services.rs @@ -56,6 +56,23 @@ impl WebServices { pub async fn auth_resolve_session(&self, token: &str) -> Result { auth_service::resolve_session(&self.db, token).await } + + /// Change the logged-in user's password after verifying the current one. + pub async fn auth_change_password( + &self, + user_id: &str, + current_password: &str, + new_password: &str, + confirm: &str, + ) -> Result<(), ApiError> { + auth_service::change_password(&self.db, user_id, current_password, new_password, confirm) + .await + } + + /// Change the logged-in user's email. + pub async fn auth_change_email(&self, user_id: &str, email: &str) -> Result { + auth_service::change_email(&self.db, user_id, email).await + } } // ── API Keys ── diff --git a/crates/memayu-identity/src/lib.rs b/crates/memayu-identity/src/lib.rs index ef5a2e4..5cc9695 100644 --- a/crates/memayu-identity/src/lib.rs +++ b/crates/memayu-identity/src/lib.rs @@ -179,6 +179,40 @@ pub async fn bootstrap(config: &StorageConfig) -> Result Ok(admin_id) } +/// Reset the admin password for a self-hosted instance, bypassing the login +/// gate entirely (the recovery path for a forgotten password). Validates the +/// new password against the shared policy and writes a fresh salt + hash. +pub async fn reset_password(config: &StorageConfig, password: &str) -> Result<(), IdentityError> { + if let Some(msg) = validate_password(password) { + return Err(IdentityError::Validation(msg)); + } + let admin_id = resolve_self_hosted_account_id(config).await?; + let salt = new_salt(); + let hash = hash_password(&salt, password); + match config.backend { + StorageBackend::Libsql => { + let conn = open_libsql(config).await?; + conn.execute( + "UPDATE users SET password = ?1, salt = ?2 WHERE id = ?3", + (hash.as_str(), salt.as_str(), admin_id.as_str()), + ) + .await + .map_err(|e| IdentityError::Db(format!("reset password: {e}")))?; + } + StorageBackend::Postgres => { + let pool = open_postgres(config).await?; + sqlx::query("UPDATE users SET password = $1, salt = $2 WHERE id = $3") + .bind(hash.as_str()) + .bind(salt.as_str()) + .bind(admin_id.as_str()) + .execute(&pool) + .await + .map_err(|e| IdentityError::Db(format!("reset password: {e}")))?; + } + } + Ok(()) +} + // ── password rules / hashing ── // // These mirror the rules enforced by the web `POST /api/auth/setup` flow so @@ -860,6 +894,65 @@ mod tests { assert_eq!(is_admin, 1); } + #[tokio::test] + async fn reset_password_updates_hash_and_validates() { + let path = temp_db(); + let config = config_for(&path); + let admin = create_admin_account( + &config, + "admin@example.com", + "Correct-Horse-Battery-Staple-42!", + "Correct-Horse-Battery-Staple-42!", + ) + .await + .unwrap(); + + // Reset to a new password. + reset_password(&config, "NewHorse-Staple-99!") + .await + .unwrap(); + + // The stored row now verifies against the new password. + let conn = libsql::Builder::new_local(path.display().to_string()) + .build() + .await + .unwrap() + .connect() + .unwrap(); + let mut rows = conn + .query( + "SELECT password, salt FROM users WHERE id = ?1", + vec![admin.as_str()], + ) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + let stored_hash: String = row.get(0).unwrap(); + let salt: String = row.get(1).unwrap(); + assert_eq!(stored_hash, hash_password(&salt, "NewHorse-Staple-99!")); + assert_ne!( + stored_hash, + hash_password(&salt, "Correct-Horse-Battery-Staple-42!"), + "old password must no longer verify" + ); + + // Weak password is rejected. + let err = reset_password(&config, "weak").await.unwrap_err(); + assert_eq!( + err, + IdentityError::Validation("Password must be at least 8 characters.") + ); + } + + #[tokio::test] + async fn reset_password_errors_when_no_admin() { + let config = config_for(&temp_db()); + let err = reset_password(&config, "NewHorse-Staple-99!") + .await + .unwrap_err(); + assert_eq!(err, IdentityError::NoAdminAccount); + } + #[tokio::test] async fn create_admin_account_rejects_weak_password() { let config = config_for(&temp_db()); diff --git a/crates/memayu-web/src/components.rs b/crates/memayu-web/src/components.rs index ebdf189..49fdeae 100644 --- a/crates/memayu-web/src/components.rs +++ b/crates/memayu-web/src/components.rs @@ -58,7 +58,7 @@ pub fn render_page( div class="flex-1" { h1 class="text-lg font-semibold" { (topbar_title) } } - div class="flex-none flex items-center gap-1 -mr-2" { + div class="flex-none flex items-center gap-1 -mr-4" { button type="button" class="btn btn-ghost btn-circle" aria-label="Toggle theme" onclick=" var current = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'; document.documentElement.setAttribute('data-theme', current); @@ -110,7 +110,8 @@ pub fn render_page( (nav_item(active_path, "requests", "Requests", "/requests", icon_requests())) div class="divider my-2 mx-2 opacity-25" {} (nav_item(active_path, "api-keys", "API Keys", "/api-keys", icon_keys())) - (nav_item(active_path, "providers", "Configuration", "/providers", icon_config())) + (nav_item(active_path, "providers", "Providers", "/providers", icon_config())) + (nav_item(active_path, "accounts", "Accounts", "/accounts", icon_account())) } div class="px-6 py-4 text-xs text-base-content/40 flex flex-col gap-1" { @@ -185,3 +186,15 @@ fn icon_config() -> maud::Markup { } } } + +fn icon_account() -> maud::Markup { + html! { + svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + stroke-linejoin="round" stroke-linecap="round" stroke-width="2" + class="size-5 inline mr-3" { + circle cx="12" cy="8" r="4" {} + path d="M4 21c0-4 3.6-6 8-6s8 2 8 6" {} + } + } +} diff --git a/crates/memayu-web/src/lib.rs b/crates/memayu-web/src/lib.rs index 72fb800..32d0f65 100644 --- a/crates/memayu-web/src/lib.rs +++ b/crates/memayu-web/src/lib.rs @@ -92,6 +92,16 @@ pub fn build_web_router( "/api-keys/{id}/delete", post(pages::api_keys::delete_api_key), ) + .route("/accounts", get(pages::account::get_account)) + .route("/accounts/email", post(pages::account::post_account_email)) + .route( + "/accounts/password", + post(pages::account::post_account_password), + ) + .route( + "/account", + get(|| async { Redirect::permanent("/accounts") }), + ) .route("/static/{*path}", get(assets::serve_static)) .with_state(state) } diff --git a/crates/memayu-web/src/pages/account.rs b/crates/memayu-web/src/pages/account.rs new file mode 100644 index 0000000..b4d22ce --- /dev/null +++ b/crates/memayu-web/src/pages/account.rs @@ -0,0 +1,202 @@ +//! Account settings page: change the logged-in admin's password and email. +//! Follows the existing Maud + HTMX dashboard pattern (issue #50). + +use crate::auth::CurrentUser; +use crate::components; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Html; +use axum::Form; +use memayu_api::WebServices; + +#[derive(serde::Deserialize)] +pub struct ChangePasswordForm { + pub current_password: String, + pub new_password: String, + pub confirm: String, +} + +#[derive(serde::Deserialize)] +pub struct ChangeEmailForm { + pub email: String, +} + +/// Render the account page body (both forms) with an optional status/error banner. +fn account_content(email: &str, message: Option<&str>, message_is_error: bool) -> maud::Markup { + let banner = match message { + Some(m) if message_is_error => { + maud::html! { + div class="alert alert-error mb-6" role="alert" x-data="{ open: true }" x-show="open" { + svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" { + path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" {} + } + span class="flex-1" { (m) } + button type="button" class="alert-close" x-on:click="open = false" aria-label="Close alert" { + svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" { + path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" {} + } + } + } + } + } + Some(m) => { + maud::html! { + div class="alert alert-success mb-6" role="alert" x-data="{ open: true }" x-show="open" { + svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24" { + path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" {} + } + span class="flex-1" { (m) } + button type="button" class="alert-close" x-on:click="open = false" aria-label="Close alert" { + svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" { + path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" {} + } + } + } + } + } + None => maud::html! {}, + }; + + maud::html! { + div class="mb-6" { + h2 class="text-xl font-bold" { "Accounts" } + p class="text-xs text-base-content/60 mt-1" { + "Manage the administrator credentials for this Memayu instance." + } + } + (banner) + + div class="grid grid-cols-1 lg:grid-cols-2 gap-6" { + // ── Change email ── + div class="card bg-base-100 shadow-sm" { + div class="card-body" { + h3 class="card-title text-lg mb-1" { "Email Address" } + p class="text-xs text-base-content/60 mb-4" { + "Current email: " strong { (email) } + } + form method="post" action="/accounts/email" class="space-y-4" { + fieldset class="fieldset" { + label class="label" { span { "New Email Address" } } + input type="email" name="email" class="input w-full" + value=(email) required; + p class="text-xs text-base-content/50 mt-1" { + "Used to log in to the administrator dashboard." + } + } + div class="pt-2" { + button type="submit" class="btn btn-primary" { "Update email" } + } + } + } + } + + // ── Change password ── + div class="card bg-base-100 shadow-sm" { + div class="card-body" { + h3 class="card-title text-lg mb-1" { "Change Password" } + p class="text-xs text-base-content/60 mb-4" { + "Enter your current password to set a new password." + } + form method="post" action="/accounts/password" class="space-y-4" { + fieldset class="fieldset" { + label class="label" { span { "Current Password" } } + input type="password" name="current_password" class="input w-full" + required autocomplete="current-password"; + } + fieldset class="fieldset" { + label class="label" { span { "New Password" } } + input type="password" name="new_password" class="input w-full" + required autocomplete="new-password" + placeholder="Min 8 chars, upper+lower+digit"; + } + fieldset class="fieldset" { + label class="label" { span { "Confirm New Password" } } + input type="password" name="confirm" class="input w-full" + required autocomplete="new-password"; + } + div class="pt-2" { + button type="submit" class="btn btn-primary" { "Update password" } + } + } + } + } + } + } +} + +/// GET /account +pub async fn get_account(user: CurrentUser) -> Result, (StatusCode, String)> { + Ok(Html(components::render_page( + "account", + Some(&user.email), + "Accounts", + "Accounts", + account_content(&user.email, None, false), + ))) +} + +/// POST /accounts/email +pub async fn post_account_email( + user: CurrentUser, + State(services): State, + Form(form): Form, +) -> Result, (StatusCode, String)> { + match services.auth_change_email(&user.id, &form.email).await { + Ok(new_email) => { + // The session's email is derived from the DB on each resolve, so it + // refreshes automatically. Re-render with the new email. + Ok(Html(components::render_page( + "account", + Some(&new_email), + "Accounts", + "Accounts", + account_content(&new_email, Some("Email updated."), false), + ))) + } + Err(e) => { + let msg = e.message; + Ok(Html(components::render_page( + "account", + Some(&user.email), + "Accounts", + "Accounts", + account_content(&user.email, Some(&msg), true), + ))) + } + } +} + +/// POST /accounts/password +pub async fn post_account_password( + user: CurrentUser, + State(services): State, + Form(form): Form, +) -> Result, (StatusCode, String)> { + match services + .auth_change_password( + &user.id, + &form.current_password, + &form.new_password, + &form.confirm, + ) + .await + { + Ok(()) => Ok(Html(components::render_page( + "account", + Some(&user.email), + "Accounts", + "Accounts", + account_content(&user.email, Some("Password updated."), false), + ))), + Err(e) => { + let msg = e.message; + Ok(Html(components::render_page( + "account", + Some(&user.email), + "Accounts", + "Accounts", + account_content(&user.email, Some(&msg), true), + ))) + } + } +} diff --git a/crates/memayu-web/src/pages/mod.rs b/crates/memayu-web/src/pages/mod.rs index cf9be3f..2fde91b 100644 --- a/crates/memayu-web/src/pages/mod.rs +++ b/crates/memayu-web/src/pages/mod.rs @@ -1,3 +1,4 @@ +pub mod account; pub mod api_keys; pub mod home; pub mod login; diff --git a/crates/memayu-web/tests/dashboard.rs b/crates/memayu-web/tests/dashboard.rs index 27097fd..591b6b1 100644 --- a/crates/memayu-web/tests/dashboard.rs +++ b/crates/memayu-web/tests/dashboard.rs @@ -641,3 +641,264 @@ async fn memory_detail_shows_full_long_content_and_metadata() { assert!(body_str.contains("Created:"), "stdout: {body_str}"); assert!(body_str.contains("Updated:"), "stdout: {body_str}"); } + +#[tokio::test] +async fn account_page_changes_password_and_email() { + // Issue #50: the /account page lets the logged-in admin change password + // (requiring the current one) and change email. + let (app, _db, _storage) = build_raw_test_app().await; + + // Set up admin + capture session cookie. + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/setup") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "email=admin@memayu.test&password=Secret12&confirm=Secret12", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let cookie = resp + .headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap() + .to_string(); + + // GET /account renders both forms. + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/accounts") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 256) + .await + .unwrap(); + let body_str = String::from_utf8_lossy(&body); + assert!(body_str.contains("Current email:"), "stdout: {body_str}"); + assert!(body_str.contains("admin@memayu.test"), "stdout: {body_str}"); + + // Change the password with the correct current password. + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/accounts/password") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", &cookie) + .body(Body::from( + "current_password=Secret12&new_password=NewSecret9&confirm=NewSecret9", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 256) + .await + .unwrap(); + let body_str = String::from_utf8_lossy(&body); + assert!(body_str.contains("Password updated."), "stdout: {body_str}"); + + // Old password no longer logs in; new password does. + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("email=admin@memayu.test&password=Secret12")) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 256) + .await + .unwrap(); + let old_body = String::from_utf8_lossy(&body); + assert!( + old_body.contains("Invalid credentials."), + "old password must be rejected, got: {old_body}" + ); + + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("email=admin@memayu.test&password=NewSecret9")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER, "new password logs in"); + + // Change the email. + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/accounts/email") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", &cookie) + .body(Body::from("email=newadmin@memayu.test")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 256) + .await + .unwrap(); + let body_str = String::from_utf8_lossy(&body); + assert!(body_str.contains("Email updated."), "stdout: {body_str}"); + assert!( + body_str.contains("newadmin@memayu.test"), + "stdout: {body_str}" + ); + + // The new email logs in (with the new password). + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("email=newadmin@memayu.test&password=NewSecret9")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER, "new email logs in"); + + // Wrong current password is rejected with a clear error. + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/accounts/password") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", &cookie) + .body(Body::from( + "current_password=WrongPass1&new_password=Another9&confirm=Another9", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 256) + .await + .unwrap(); + let body_str = String::from_utf8_lossy(&body); + assert!( + body_str.contains("current password is incorrect"), + "stdout: {body_str}" + ); +} + +#[tokio::test] +async fn providers_page_renders_local_models_and_persists() { + let (app, db, _) = build_raw_test_app().await; + + // Setup admin to obtain session cookie + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/setup") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "email=admin@memayu.test&password=Secret12&confirm=Secret12", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let cookie = resp + .headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap() + .to_string(); + + // GET /providers — verify shared local model catalog options are present + let resp = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/providers") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 256) + .await + .unwrap(); + let body_str = String::from_utf8_lossy(&body); + + for m in memayu_setup::LOCAL_MODELS { + assert!(body_str.contains(m.name), "missing model name {}", m.name); + assert!(body_str.contains(m.id), "missing model id {}", m.id); + } + assert!(body_str.contains("model-dropdown-trigger")); + assert!(body_str.contains("Embedding vector dimension:")); + + // POST /providers with local backend + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/providers") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", &cookie) + .body(Body::from( + "provider=embedder&backend=local&model=sentence-transformers%2Fall-MiniLM-L6-v2", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 256) + .await + .unwrap(); + let body_str = String::from_utf8_lossy(&body); + assert!(body_str.contains("Saved."), "stdout: {body_str}"); + + // Verify DB normalized local config (base_url and api_key are empty) + let configs = db.provider_configs().await.unwrap(); + let (backend, base_url, api_key, model) = configs.get("embedder").unwrap(); + assert_eq!(backend, "local"); + assert_eq!(base_url, ""); + assert_eq!(api_key, ""); + assert_eq!(model, "sentence-transformers/all-MiniLM-L6-v2"); +} From 9c5de82215a22a89bd5f1a4f802136eece2c7861 Mon Sep 17 00:00:00 2001 From: savioruz Date: Tue, 25 Aug 2026 22:54:29 +0700 Subject: [PATCH 2/2] ref(web): Refine provider configuration UI - Split LLM and embedder settings into dedicated cards - Add interactive local model selection and backend controls - Correct multilingual model dimensions and language labels - Remove redundant setup model dimension badge --- bin/memayu/src/setup_flow.rs | 10 +- crates/memayu-setup/src/lib.rs | 10 +- crates/memayu-web/src/pages/providers.rs | 312 +++++++++++++++++------ crates/memayu-web/src/pages/setup.rs | 11 +- crates/memayu-web/static/memayu.css | 52 +++- 5 files changed, 301 insertions(+), 94 deletions(-) diff --git a/bin/memayu/src/setup_flow.rs b/bin/memayu/src/setup_flow.rs index 6eddc43..2b51958 100644 --- a/bin/memayu/src/setup_flow.rs +++ b/bin/memayu/src/setup_flow.rs @@ -156,7 +156,7 @@ pub const LOCAL_MODELS: &[LocalModelSpec] = &[ min_ram_mb: 300, min_disk_mb: 500, cpu_notes: "Very fast, ARMv8 OK", - langs: "English-only", + langs: "English", }, LocalModelSpec { id: "BAAI/bge-small-en-v1.5", @@ -167,18 +167,18 @@ pub const LOCAL_MODELS: &[LocalModelSpec] = &[ min_ram_mb: 350, min_disk_mb: 600, cpu_notes: "Fast, ARMv8 OK", - langs: "English-only", + langs: "English", }, LocalModelSpec { id: "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", name: "paraphrase-multilingual-MiniLM-L12-v2", - dim: 768, + dim: 384, fp32_size_mb: 470, int8_size_mb: 120, min_ram_mb: 700, min_disk_mb: 1200, cpu_notes: "Slower (2× layers), ARMv8 OK", - langs: "50+ langs (incl. id)", + langs: "50+ langs", }, LocalModelSpec { id: "nomic-ai/nomic-embed-text-v1.5", @@ -189,7 +189,7 @@ pub const LOCAL_MODELS: &[LocalModelSpec] = &[ min_ram_mb: 800, min_disk_mb: 1400, cpu_notes: "Heavier, slower on Pi", - langs: "English-strong, some multilingual", + langs: "Multilingual", }, ]; diff --git a/crates/memayu-setup/src/lib.rs b/crates/memayu-setup/src/lib.rs index da7eebe..a320b2a 100644 --- a/crates/memayu-setup/src/lib.rs +++ b/crates/memayu-setup/src/lib.rs @@ -168,7 +168,7 @@ pub const LOCAL_MODELS: &[LocalModelSpec] = &[ min_ram_mb: 300, min_disk_mb: 500, cpu_notes: "Very fast, ARMv8 OK", - langs: "English-only", + langs: "English", }, LocalModelSpec { id: "BAAI/bge-small-en-v1.5", @@ -179,18 +179,18 @@ pub const LOCAL_MODELS: &[LocalModelSpec] = &[ min_ram_mb: 350, min_disk_mb: 600, cpu_notes: "Fast, ARMv8 OK", - langs: "English-only", + langs: "English", }, LocalModelSpec { id: "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", name: "paraphrase-multilingual-MiniLM-L12-v2", - dim: 768, + dim: 384, fp32_size_mb: 470, int8_size_mb: 120, min_ram_mb: 700, min_disk_mb: 1200, cpu_notes: "Slower (2× layers), ARMv8 OK", - langs: "50+ langs (incl. id)", + langs: "50+ langs", }, LocalModelSpec { id: "nomic-ai/nomic-embed-text-v1.5", @@ -201,7 +201,7 @@ pub const LOCAL_MODELS: &[LocalModelSpec] = &[ min_ram_mb: 800, min_disk_mb: 1400, cpu_notes: "Heavier, slower on Pi", - langs: "English-strong, some multilingual", + langs: "Multilingual", }, ]; diff --git a/crates/memayu-web/src/pages/providers.rs b/crates/memayu-web/src/pages/providers.rs index c5ce725..439dbe3 100644 --- a/crates/memayu-web/src/pages/providers.rs +++ b/crates/memayu-web/src/pages/providers.rs @@ -8,96 +8,227 @@ use memayu_api::{ConfigRegistry, WebServices}; use memayu_config::{EmbedderBackend, ProviderConfig}; use serde::Deserialize; -fn provider_card( - kind: &str, - title: &str, - cfg: &ProviderConfig, - has_key: bool, - show_backend: bool, -) -> maud::Markup { +fn llm_card(cfg: &ProviderConfig, mode: &str, has_key: bool) -> maud::Markup { let key_hint = if has_key { maud::html! { p class="text-xs text-base-content/50 mt-1" { "API key is saved. Leave blank to keep it." } } } else { maud::html! {} }; - let backend_field = if show_backend { - maud::html! { - fieldset class="fieldset" { - label class="label" { span { "Backend" } } - select name="backend" class="select w-full" { - option value="remote" selected=(cfg.backend == EmbedderBackend::Remote) { - "HTTP (remote OpenAI-compatible API)" - } - option value="local" selected=(cfg.backend == EmbedderBackend::Local) { - "Local (in-process Candle model, no API key)" - } - } - p class="text-xs text-base-content/50 mt-1" { - "Base URL and API key are only used by the HTTP backend. The local backend runs the model on-device; \'model\' is the Hugging Face model id." - } - } - } - } else { - maud::html! {} - }; + + let alpine_expr = format!("{{ mode: '{}' }}", mode); + maud::html! { div class="card bg-base-100 shadow-sm" { div class="card-body" { - h3 class="card-title text-lg" { (title) } - form method="post" action="/providers" class="space-y-4 mt-2" { - input type="hidden" name="provider" value=(kind); - (backend_field) + h3 class="card-title text-lg mb-1" { "LLM & Extraction" } + p class="text-xs text-base-content/60 mb-4" { + "Configure memory extraction mode and upstream LLM inference endpoint." + } + form method="post" action="/providers" class="space-y-4" x-data=(alpine_expr) { + input type="hidden" name="provider" value="llm"; + fieldset class="fieldset" { - label class="label" { span { "Base URL" } } - input type="url" name="base_url" - class="input w-full" - value=(cfg.base_url.as_str()); + label class="label" { span { "Extraction Mode" } } + select name="extraction_mode" class="select w-full" x-model="mode" { + option value="llm" { + "LLM Extraction (structured facts & insights)" + } + option value="raw" { + "Raw Text Mode (store chunks verbatim)" + } + } + p class="text-xs text-base-content/50 mt-1" { + "Mode changes take effect on the next server restart." + } } - fieldset class="fieldset" { - label class="label" { span { "API Key" } } - input type="password" name="api_key" - class="input w-full" - value=@if has_key { "••••••••" } @else { "" } - placeholder=@if has_key { "••••••••" } @else { "sk-..." }; - (key_hint) + + div x-show="mode === 'raw'" { + span class="inline-block bg-base-200 px-3 py-2 text-xs text-base-content/70" { + "Raw Text Mode stores memories verbatim without LLM extraction." + } } - fieldset class="fieldset" { - label class="label" { span { "Model" } } - input type="text" name="model" - class="input w-full" - value=(cfg.model.as_str()) required; + + div x-show="mode === 'llm'" class="space-y-4" { + fieldset class="fieldset" { + label class="label" { span { "Base URL" } } + input type="url" name="base_url" + class="input w-full" + value=(cfg.base_url.as_str()); + } + fieldset class="fieldset" { + label class="label" { span { "API Key" } } + input type="password" name="api_key" + class="input w-full" + value=@if has_key { "••••••••" } @else { "" } + placeholder=@if has_key { "••••••••" } @else { "sk-..." }; + (key_hint) + } + + fieldset class="fieldset" { + label class="label" { span { "Model" } } + input type="text" name="model" + class="input w-full" + value=(cfg.model.as_str()); + } + } + + div class="pt-2" { + button type="submit" class="btn btn-primary" { "Save" } } - button type="submit" class="btn btn-primary" { "Save" } } } } } } -/// Full-width card for editing the DB-authoritative extraction mode. Saved -/// through the same providers form; takes effect on the next server start. -fn extraction_card(mode: &str) -> maud::Markup { +fn embedder_card(cfg: &ProviderConfig, has_key: bool) -> maud::Markup { + let key_hint = if has_key { + maud::html! { p class="text-xs text-base-content/50 mt-1" { "API key is saved. Leave blank to keep it." } } + } else { + maud::html! {} + }; + + let backend_str = if cfg.backend == EmbedderBackend::Local { + "local" + } else { + "remote" + }; + + let matched_local_spec = memayu_setup::LOCAL_MODELS + .iter() + .find(|m| m.id == cfg.model || m.name == cfg.model) + .unwrap_or(&memayu_setup::LOCAL_MODELS[memayu_setup::DEFAULT_MODEL_INDEX]); + + let initial_local_model_id = matched_local_spec.id; + let initial_local_model_name = matched_local_spec.name; + let initial_local_dim = matched_local_spec.dim; + let initial_local_fp32 = matched_local_spec.fp32_size_mb; + let initial_local_int8 = matched_local_spec.int8_size_mb; + let initial_local_ram = matched_local_spec.min_ram_mb; + let initial_local_cpu = matched_local_spec.cpu_notes; + let initial_local_langs = matched_local_spec.langs; + let initial_remote_model = if cfg.backend == EmbedderBackend::Remote { + cfg.model.as_str() + } else { + "text-embedding-3-small" + }; + + let alpine_expr = format!( + "{{ backend: '{}', dropdown_open: false, local_model: '{}', local_model_name: '{}', local_dim: {}, local_fp32: {}, local_int8: {}, local_ram: {}, local_cpu: '{}', local_langs: '{}', remote_model: '{}' }}", + backend_str, + initial_local_model_id, + initial_local_model_name, + initial_local_dim, + initial_local_fp32, + initial_local_int8, + initial_local_ram, + initial_local_cpu, + initial_local_langs, + initial_remote_model, + ); + maud::html! { div class="card bg-base-100 shadow-sm" { div class="card-body" { - h3 class="card-title text-lg" { "Memory Extraction Mode" } - form method="post" action="/providers" class="space-y-4 mt-2" { - input type="hidden" name="provider" value="extraction_mode"; + h3 class="card-title text-lg mb-1" { "Embedder" } + p class="text-xs text-base-content/60 mb-4" { + "Configure vector embedding model for semantic similarity search." + } + form method="post" action="/providers" class="space-y-4" x-data=(alpine_expr) { + input type="hidden" name="provider" value="embedder"; + input type="hidden" name="model" x-bind:value="backend === 'local' ? local_model : remote_model" value=(if cfg.backend == EmbedderBackend::Local { initial_local_model_id } else { cfg.model.as_str() }); + fieldset class="fieldset" { - label class="label" { span { "Extraction Mode" } } - select name="extraction_mode" class="select w-full" { - option value="llm" selected=(mode == "llm") { - "LLM Extraction (structured facts & insights)" + label class="label" { span { "Backend" } } + select name="backend" class="select w-full" x-model="backend" { + option value="remote" { + "HTTP (remote OpenAI-compatible API)" } - option value="raw" selected=(mode == "raw") { - "Raw Text Mode (store chunks verbatim)" + option value="local" { + "Local (in-process Candle model, no API key)" } } - p class="text-xs text-base-content/50 mt-1" { - "Controls how memories are extracted. Takes effect on the next server start." + } + + // ── Local Model Dropdown ── + div x-show="backend === 'local'" class="space-y-4" { + fieldset class="fieldset" { + label class="label" { span { "Model" } } + div class="dropdown w-full" x-on:focusout="if (!$el.contains($event.relatedTarget)) dropdown_open = false" { + button + type="button" + class="model-dropdown-trigger font-normal" + tabindex="0" + x-on:click="dropdown_open = !dropdown_open" { + div class="flex items-center gap-2 flex-1 min-w-0" { + span class="font-bold text-sm truncate" x-text="local_model_name" { (initial_local_model_name) } + span class="text-xs opacity-60 hidden sm:inline" x-text="'~' + local_int8 + ' MB (q8)'" { "~" (initial_local_int8) " MB (q8)" } + } + svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-60 ml-2 shrink-0 transition-transform" x-bind:class="{ 'rotate-180': dropdown_open }" fill="none" viewBox="0 0 24 24" stroke="currentColor" { + path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" {} + } + } + div class="dropdown-content bg-base-200 border border-base-300 w-full z-20 mt-1 shadow-xl overflow-hidden" + x-show="dropdown_open" + x-cloak { + @for m in memayu_setup::LOCAL_MODELS { + div class="model-option-item" + x-bind:class=(format!("{{ 'active': local_model === '{}' }}", m.id)) + x-on:click=(format!("local_model = '{}'; local_model_name = '{}'; local_dim = {}; local_fp32 = {}; local_int8 = {}; local_ram = {}; local_cpu = '{}'; local_langs = '{}'; dropdown_open = false;", m.id, m.name, m.dim, m.fp32_size_mb, m.int8_size_mb, m.min_ram_mb, m.cpu_notes, m.langs)) { + div class="flex items-center justify-between" { + span class="font-bold text-sm text-base-content" { (m.name) } + div class="flex items-center gap-1.5" { + span class="badge badge-sm font-mono text-xs font-semibold" { (m.dim) "D" } + span class="badge badge-sm badge-outline text-xs" { (m.langs) } + } + } + div class="text-xs text-base-content/60 mt-1" { + "~" (m.int8_size_mb) "MB · min " (m.min_ram_mb) "MB RAM" + } + } + } + } + } + } + + div class="flex items-center gap-3 text-xs text-base-content/60 my-2" { + span { "Embedding vector dimension:" } + span class="badge badge-outline text-xs font-mono font-semibold" x-text="local_dim" { (initial_local_dim) } } } - button type="submit" class="btn btn-primary" { "Save" } + + // ── Remote Embedder Fields ── + div x-show="backend === 'remote'" class="space-y-4" { + fieldset class="fieldset" { + label class="label" { span { "Base URL" } } + input type="url" name="base_url" + class="input w-full" + value=(cfg.base_url.as_str()) + placeholder="https://api.openai.com/v1"; + } + fieldset class="fieldset" { + label class="label" { span { "API Key" } } + input type="password" name="api_key" + class="input w-full" + value=@if has_key { "••••••••" } @else { "" } + placeholder=@if has_key { "••••••••" } @else { "sk-..." }; + (key_hint) + } + fieldset class="fieldset" { + label class="label" { span { "Model Name" } } + input type="text" + class="input w-full" + x-model="remote_model" + placeholder="text-embedding-3-small"; + p class="text-xs text-base-content/50 mt-1" { + "Model name for the remote OpenAI-compatible embedding endpoint." + } + } + } + + div class="pt-2" { + button type="submit" class="btn btn-primary" { "Save" } + } } } } @@ -121,8 +252,14 @@ async fn render_config( let llm_has_key = llm.api_key.as_deref().is_some_and(|k| !k.is_empty()); let emb_has_key = embedder.api_key.as_deref().is_some_and(|k| !k.is_empty()); let body = maud::html! { + div class="mb-6" { + h2 class="text-xl font-bold" { "Providers" } + p class="text-xs text-base-content/60 mt-1" { + "Configure embedding models, LLM extraction endpoints, and memory extraction mode." + } + } @if !msg.is_empty() { - div class="alert alert-success mt-4 mb-4" role="alert" x-data="{ open: true }" x-show="open" { + div class="alert alert-success mb-6" role="alert" x-data="{ open: true }" x-show="open" { svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24" { path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" {} } @@ -134,17 +271,16 @@ async fn render_config( } } } - div class="grid grid-cols-1 md:grid-cols-2 gap-6" { - (provider_card("llm", "LLM (extraction)", &llm, llm_has_key, false)) - (provider_card("embedder", "Embedder", &embedder, emb_has_key, true)) + div class="grid grid-cols-1 lg:grid-cols-2 gap-6" { + (llm_card(&llm, &mode, llm_has_key)) + (embedder_card(&embedder, emb_has_key)) } - div class="mt-6" { (extraction_card(&mode)) } }; Ok(Html(components::render_page( "providers", Some(&user.email), - "Configuration", - "Configuration", + "Providers", + "Providers", body, ))) } @@ -161,8 +297,11 @@ pub async fn get_providers( pub struct ProviderForm { pub provider: String, pub backend: Option, + #[serde(default)] pub base_url: String, + #[serde(default)] pub api_key: String, + #[serde(default)] pub model: String, #[serde(default)] pub extraction_mode: Option, @@ -174,7 +313,7 @@ pub async fn post_providers( State(services): State, Form(form): Form, ) -> Result, (StatusCode, String)> { - // Extraction-mode-only form post: validate and persist, then re-render. + // Extraction-mode-only form post (backward compatibility) if form.provider == "extraction_mode" { let mode = form.extraction_mode.clone().unwrap_or_else(|| "llm".into()); if mode != "llm" && mode != "raw" { @@ -190,6 +329,18 @@ pub async fn post_providers( return render_config(user, registry, services, "Saved.").await; } + // When saving the LLM card, also persist the extraction mode if supplied. + if form.provider == "llm" { + if let Some(mode) = &form.extraction_mode { + if mode == "llm" || mode == "raw" { + services + .set_extraction_mode(mode) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + } + } + } + let api_key = if form.api_key == "••••••••" { let existing = services.provider_configs().await.unwrap_or_default(); existing @@ -216,11 +367,22 @@ pub async fn post_providers( .unwrap_or_else(|| registry.embedder().backend), _ => EmbedderBackend::Remote, }; + let model = if form.model.is_empty() && form.provider == "llm" { + registry.llm().model + } else { + form.model.clone() + }; + let base_url = if form.base_url.is_empty() && form.provider == "llm" { + registry.llm().base_url + } else { + form.base_url.clone() + }; + let new_config = ProviderConfig { backend, - base_url: form.base_url.clone(), + base_url: base_url.clone(), api_key: api_key_for_config, - model: form.model.clone(), + model: model.clone(), }; let probe = match form.provider.as_str() { @@ -239,9 +401,9 @@ pub async fn post_providers( .provider_upsert( &form.provider, &backend.to_string(), - &form.base_url, + &base_url, &api_key, - &form.model, + &model, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; diff --git a/crates/memayu-web/src/pages/setup.rs b/crates/memayu-web/src/pages/setup.rs index 3790073..5e87a35 100644 --- a/crates/memayu-web/src/pages/setup.rs +++ b/crates/memayu-web/src/pages/setup.rs @@ -222,7 +222,6 @@ fn setup_form(a: &SetupAnswers, error: Option<&str>) -> maud::Markup { x-on:click="dropdown_open = !dropdown_open" { div class="flex items-center gap-2 flex-1 min-w-0" { span class="font-bold text-sm truncate" x-text="local_model_name" { (initial_model_name) } - span class="badge badge-sm badge-outline font-mono text-xs" x-text="local_dim + 'd'" { (initial_dim) "d" } span class="text-xs opacity-60 hidden sm:inline" x-text="'~' + local_int8 + ' MB (q8)'" { "~" (initial_int8) " MB (q8)" } } svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-60 ml-2 shrink-0 transition-transform" x-bind:class="{ 'rotate-180': dropdown_open }" fill="none" viewBox="0 0 24 24" stroke="currentColor" { @@ -240,21 +239,19 @@ fn setup_form(a: &SetupAnswers, error: Option<&str>) -> maud::Markup { div class="flex items-center justify-between" { span class="font-bold text-sm text-base-content" { (m.name) } div class="flex items-center gap-1.5" { - span class="badge badge-sm font-mono text-xs font-semibold" { (m.dim) "d" } + span class="badge badge-sm font-mono text-xs font-semibold" { (m.dim) "D" } span class="badge badge-sm badge-outline text-xs" { (m.langs) } } } - div class="grid grid-cols-3 gap-2 text-xs text-base-content/70 mt-1.5 pt-1.5 border-t border-base-300/40" { - span { "💾 ~" (m.int8_size_mb) " MB (q8) / ~" (m.fp32_size_mb) " MB" } - span { "🧠 Min ~" (m.min_ram_mb) " MB RAM" } - span class="text-right" { "⚡ " (m.cpu_notes) } + div class="text-xs text-base-content/60 mt-1" { + "~" (m.int8_size_mb) "MB · min " (m.min_ram_mb) "MB RAM" } } } } } } - div class="flex items-center gap-2 text-xs text-base-content/60 my-3" { + div class="flex items-center gap-3 text-xs text-base-content/60 my-3" { span { "Embedding vector dimension:" } span class="badge badge-outline text-xs font-mono font-semibold" x-text="local_dim" { (embedding_dimension(a).unwrap_or(initial_dim as usize)) } } diff --git a/crates/memayu-web/static/memayu.css b/crates/memayu-web/static/memayu.css index 9645eda..9746431 100644 --- a/crates/memayu-web/static/memayu.css +++ b/crates/memayu-web/static/memayu.css @@ -236,6 +236,10 @@ body { gap: 0.25rem; } +.gap-2 { + gap: 0.5rem; +} + .gap-3 { gap: 0.75rem; } @@ -248,7 +252,23 @@ body { gap: 1.5rem; } +.gap-x-2 { + column-gap: 0.5rem; +} + +.gap-x-3 { + column-gap: 0.75rem; +} + /* ── Spacing & Margins ── */ +.space-x-2>*+* { + margin-left: 0.5rem; +} + +.space-x-3>*+* { + margin-left: 0.75rem; +} + .space-y-4>*+* { margin-top: 1rem; } @@ -498,11 +518,37 @@ body { width: 3rem; } +.size-4 { + width: 1rem; + height: 1rem; +} + .size-5 { width: 1.25rem; height: 1.25rem; } +.size-6 { + width: 1.5rem; + height: 1.5rem; +} + +.inline-block { + display: inline-block; +} + +.inline-flex { + display: inline-flex; +} + +.w-fit { + width: fit-content; +} + +.w-auto { + width: auto; +} + .overflow-x-auto { overflow-x: auto; width: 100%; @@ -688,7 +734,8 @@ pre { color: var(--mu-color, #c2c7d0); } -.input { +.input, +.select { width: 100%; height: 2.25rem; padding: 0 0.75rem; @@ -700,7 +747,8 @@ pre { box-sizing: border-box; } -.input:focus { +.input:focus, +.select:focus { border-color: var(--mu-form-element-active-border-color, #0172ad); outline: none; }