Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions bin/memayu/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,18 @@ fn print_memory(m: &Memory, json: bool) -> Result<(), String> {
}
Ok(())
}

/// `memayu reset-password <new-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(())
}
7 changes: 7 additions & 0 deletions bin/memayu/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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" => {
Expand Down Expand Up @@ -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");
Expand Down
10 changes: 5 additions & 5 deletions bin/memayu/src/setup_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
},
];

Expand Down
1 change: 1 addition & 0 deletions crates/memayu-api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions crates/memayu-api/src/modules/auth/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions crates/memayu-api/src/modules/auth/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,102 @@ impl DbClient {
}
}

pub async fn find_user_by_id(&self, user_id: &str) -> Result<Option<User>, 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<bool, String> {
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<bool, String> {
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(
Expand Down
Loading
Loading