From 1de2e6484e6f415f0246e1c007e168a01a28e0c6 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:18:33 -0500 Subject: [PATCH 01/13] feat(backend): add change username, password, and clear data endpoints --- backend/src/handlers/auth.rs | 152 +++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 428c960..a624013 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -207,3 +207,155 @@ pub async fn export_db( data, )) } + +#[derive(Deserialize, ToSchema, Validate)] +pub struct ChangeUsernameRequest { + #[validate(length(min = 3, max = 32))] + pub new_username: String, +} + +#[utoipa::path( + put, + path = "/api/auth/change-username", + request_body = ChangeUsernameRequest, + responses( + (status = 200, description = "Username changed successfully", body = AuthResponse), + (status = 409, description = "Username already exists"), + (status = 500, description = "Internal server error") + ), + tag = "Auth", + summary = "Change username", + description = "Updates the authenticated user's username." +)] +pub async fn change_username( + State(pool): State, + axum::Extension(claims): axum::Extension, + Json(payload): Json, +) -> Result, PaymeError> { + payload.validate()?; + + sqlx::query("UPDATE users SET username = ? WHERE id = ?") + .bind(&payload.new_username) + .bind(claims.sub) + .execute(&pool) + .await?; + + Ok(Json(AuthResponse { + id: claims.sub, + username: payload.new_username, + })) +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct ChangePasswordRequest { + #[validate(length(min = 6, max = 128))] + pub current_password: String, + #[validate(length(min = 6, max = 128))] + pub new_password: String, +} + +#[utoipa::path( + put, + path = "/api/auth/change-password", + request_body = ChangePasswordRequest, + responses( + (status = 200, description = "Password changed successfully"), + (status = 401, description = "Invalid current password"), + (status = 500, description = "Internal server error") + ), + tag = "Auth", + summary = "Change password", + description = "Updates the authenticated user's password." +)] +pub async fn change_password( + State(pool): State, + axum::Extension(claims): axum::Extension, + Json(payload): Json, +) -> Result { + payload.validate()?; + + let user: (String,) = sqlx::query_as("SELECT password_hash FROM users WHERE id = ?") + .bind(claims.sub) + .fetch_optional(&pool) + .await? + .ok_or(PaymeError::NotFound)?; + + let parsed_hash = + PasswordHash::new(&user.0).map_err(|e| PaymeError::Internal(e.to_string()))?; + Argon2::default() + .verify_password(payload.current_password.as_bytes(), &parsed_hash) + .map_err(|_| PaymeError::Unauthorized)?; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let new_password_hash = argon2 + .hash_password(payload.new_password.as_bytes(), &salt) + .map_err(|e| PaymeError::Internal(e.to_string()))? + .to_string(); + + sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") + .bind(&new_password_hash) + .bind(claims.sub) + .execute(&pool) + .await?; + + Ok(Json( + serde_json::json!({"message": "Password changed successfully"}), + )) +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct ClearDataRequest { + #[validate(length(min = 6, max = 128))] + pub password: String, +} + +#[utoipa::path( + delete, + path = "/api/auth/clear-data", + request_body = ClearDataRequest, + responses( + (status = 200, description = "All data cleared successfully"), + (status = 401, description = "Invalid password"), + (status = 500, description = "Internal server error") + ), + tag = "Auth", + summary = "Clear all user data", + description = "Deletes all data associated with the authenticated user." +)] +pub async fn clear_all_data( + State(pool): State, + jar: CookieJar, + axum::Extension(claims): axum::Extension, + Json(payload): Json, +) -> Result { + payload.validate()?; + + let user: (String,) = sqlx::query_as("SELECT password_hash FROM users WHERE id = ?") + .bind(claims.sub) + .fetch_optional(&pool) + .await? + .ok_or(PaymeError::NotFound)?; + + let parsed_hash = + PasswordHash::new(&user.0).map_err(|e| PaymeError::Internal(e.to_string()))?; + Argon2::default() + .verify_password(payload.password.as_bytes(), &parsed_hash) + .map_err(|_| PaymeError::Unauthorized)?; + + sqlx::query("DELETE FROM users WHERE id = ?") + .bind(claims.sub) + .execute(&pool) + .await?; + + let cookie = Cookie::build(("token", "")) + .path("/") + .http_only(true) + .max_age(time::Duration::seconds(0)) + .build(); + + Ok(( + jar.add(cookie), + Json(serde_json::json!({"message": "All data cleared"})), + )) +} From a422986dde4993d83ed7173ddd4b2708e36d45de Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:18:34 -0500 Subject: [PATCH 02/13] feat(backend): register settings API routes --- backend/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main.rs b/backend/src/main.rs index 5a25ddb..3e6db22 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -71,6 +71,9 @@ async fn main() { let protected_routes = Router::new() .route("/api/auth/logout", post(auth::logout)) .route("/api/auth/me", get(auth::me)) + .route("/api/auth/change-username", put(auth::change_username)) + .route("/api/auth/change-password", put(auth::change_password)) + .route("/api/auth/clear-data", delete(auth::clear_all_data)) .route("/api/export", get(auth::export_db)) .route("/api/audit/logs", get(audit_handlers::list_audit_logs)) .route( From 7e47bcc389c867bb82742323df78779df84c0f15 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:18:35 -0500 Subject: [PATCH 03/13] feat(frontend): add settings API client methods --- frontend/src/api/client.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d4c6500..5545c1c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -38,6 +38,21 @@ export const api = { }), logout: () => request("/auth/logout", { method: "POST" }), me: () => request<{ id: number; username: string }>("/auth/me"), + changeUsername: (newUsername: string) => + request<{ id: number; username: string }>("/auth/change-username", { + method: "PUT", + body: JSON.stringify({ new_username: newUsername }), + }), + changePassword: (currentPassword: string, newPassword: string) => + request<{ message: string }>("/auth/change-password", { + method: "PUT", + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }), + clearAllData: (password: string) => + request<{ message: string }>("/auth/clear-data", { + method: "DELETE", + body: JSON.stringify({ password }), + }), }, months: { From f48103c33879332d9c9190cbf82c0a1c4dd8ac97 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:18:35 -0500 Subject: [PATCH 04/13] feat(frontend): add updateUsername to AuthContext --- frontend/src/context/AuthContext.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 12e81c7..6fef0c1 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -12,6 +12,7 @@ interface AuthContextType { login: (username: string, password: string) => Promise; register: (username: string, password: string) => Promise; logout: () => Promise; + updateUsername: (username: string) => void; } const AuthContext = createContext(undefined); @@ -42,14 +43,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { await api.auth.logout(); } catch { - // Logout failed, ignore } finally { setUser(null); } }; + const updateUsername = (username: string) => { + if (user) { + setUser({ ...user, username }); + } + }; + return ( - + {children} ); From 30ae85d2ebdcc59841b834d26dbf0f505d9f09e5 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:18:36 -0500 Subject: [PATCH 05/13] feat(frontend): add Settings page component --- frontend/src/pages/Settings.tsx | 252 ++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 frontend/src/pages/Settings.tsx diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..e4a90d2 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,252 @@ +import { useState } from "react"; +import { Layout } from "../components/Layout"; +import { Button } from "../components/ui/Button"; +import { Input } from "../components/ui/Input"; +import { Modal } from "../components/ui/Modal"; +import { useAuth } from "../context/AuthContext"; +import { api } from "../api/client"; +import { ArrowLeft } from "lucide-react"; + +interface SettingsProps { + onBack: () => void; +} + +export function Settings({ onBack }: SettingsProps) { + const { user, logout, updateUsername } = useAuth(); + const [newUsername, setNewUsername] = useState(user?.username || ""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [deletePassword, setDeletePassword] = useState(""); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [usernameLoading, setUsernameLoading] = useState(false); + const [passwordLoading, setPasswordLoading] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [usernameError, setUsernameError] = useState(""); + const [passwordError, setPasswordError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [usernameSuccess, setUsernameSuccess] = useState(false); + const [passwordSuccess, setPasswordSuccess] = useState(false); + + const handleChangeUsername = async (e: React.FormEvent) => { + e.preventDefault(); + setUsernameError(""); + setUsernameSuccess(false); + + if (newUsername.length < 3 || newUsername.length > 32) { + setUsernameError("Username must be 3-32 characters"); + return; + } + + setUsernameLoading(true); + try { + const response = await api.auth.changeUsername(newUsername); + updateUsername(response.username); + setUsernameSuccess(true); + setTimeout(() => setUsernameSuccess(false), 3000); + } catch (error) { + setUsernameError("Failed to change username. It may already be taken."); + } finally { + setUsernameLoading(false); + } + }; + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setPasswordError(""); + setPasswordSuccess(false); + + if (newPassword.length < 6 || newPassword.length > 128) { + setPasswordError("Password must be 6-128 characters"); + return; + } + + if (newPassword !== confirmPassword) { + setPasswordError("Passwords do not match"); + return; + } + + setPasswordLoading(true); + try { + await api.auth.changePassword(currentPassword, newPassword); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setPasswordSuccess(true); + setTimeout(() => setPasswordSuccess(false), 3000); + } catch (error) { + setPasswordError("Failed to change password. Check your current password."); + } finally { + setPasswordLoading(false); + } + }; + + const handleClearData = async () => { + setDeleteError(""); + + if (deletePassword.length < 6) { + setDeleteError("Please enter your password"); + return; + } + + setDeleteLoading(true); + try { + await api.auth.clearAllData(deletePassword); + await logout(); + } catch (error) { + setDeleteError("Failed to clear data. Check your password."); + setDeleteLoading(false); + } + }; + + return ( + +
+ + +

+ Settings +

+ +
+
+

+ Change Username +

+
+ setNewUsername(e.target.value)} + placeholder="Enter new username" + disabled={usernameLoading} + /> + {usernameError && ( +

{usernameError}

+ )} + {usernameSuccess && ( +

Username changed successfully

+ )} + +
+
+ +
+

+ Change Password +

+
+ setCurrentPassword(e.target.value)} + placeholder="Enter current password" + disabled={passwordLoading} + /> + setNewPassword(e.target.value)} + placeholder="Enter new password" + disabled={passwordLoading} + /> + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + disabled={passwordLoading} + /> + {passwordError && ( +

{passwordError}

+ )} + {passwordSuccess && ( +

Password changed successfully

+ )} + +
+
+ +
+

+ Danger Zone +

+

+ This action cannot be undone. All your data will be permanently deleted. +

+ +
+
+
+ + { + setShowDeleteModal(false); + setDeletePassword(""); + setDeleteError(""); + }} + title="Clear All Data" + > +
+

+ This will permanently delete all your data including: +

+
    +
  • All months and transactions
  • +
  • All budget categories
  • +
  • All fixed expenses
  • +
  • All income entries
  • +
  • Your account and settings
  • +
+

+ This action cannot be undone. +

+ setDeletePassword(e.target.value)} + placeholder="Enter your password" + disabled={deleteLoading} + /> + {deleteError && ( +

{deleteError}

+ )} +
+ + +
+
+
+
+ ); +} From 48b72268a36a5f98b75653fac7184a8103e2d143 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:18:36 -0500 Subject: [PATCH 06/13] feat(frontend): add settings gear icon to Layout header --- frontend/src/components/Layout.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ac552a4..5845592 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,5 @@ import { ReactNode, useRef, useState } from "react"; -import { Moon, Sun, LogOut, Download, Upload } from "lucide-react"; +import { Moon, Sun, LogOut, Download, Upload, Settings } from "lucide-react"; import { useTheme } from "../context/ThemeContext"; import { useAuth } from "../context/AuthContext"; import { api, UserExport } from "../api/client"; @@ -8,9 +8,10 @@ import { Button } from "./ui/Button"; interface LayoutProps { children: ReactNode; + onSettingsClick?: () => void; } -export function Layout({ children }: LayoutProps) { +export function Layout({ children, onSettingsClick }: LayoutProps) { const { isDark, toggle } = useTheme(); const { user, logout } = useAuth(); const fileInputRef = useRef(null); @@ -112,6 +113,15 @@ export function Layout({ children }: LayoutProps) { > {isDark ? : } + {user && onSettingsClick && ( + + )} {user && ( @@ -109,15 +111,17 @@ export function Layout({ children, onSettingsClick }: LayoutProps) { )} {user && onSettingsClick && ( @@ -125,7 +129,8 @@ export function Layout({ children, onSettingsClick }: LayoutProps) { {user && ( @@ -133,7 +138,7 @@ export function Layout({ children, onSettingsClick }: LayoutProps) { -
{children}
+
{children}
setShowImportConfirm(false)} title="Import Data">
@@ -147,11 +152,11 @@ export function Layout({ children, onSettingsClick }: LayoutProps) {
{pendingImport.months.length} months
)} -
- -
From b2c95229ac51004cfcc28dacdb100a0bb7f25886 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:28:36 -0500 Subject: [PATCH 10/13] feat(mobile): optimize Settings page for mobile devices --- frontend/src/pages/Settings.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b1eac39..2919f6a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -104,19 +104,19 @@ export function Settings({ onBack }: SettingsProps) {
-

+

Settings

-
-
-

+
+
+

Change Username

@@ -140,8 +140,8 @@ export function Settings({ onBack }: SettingsProps) {
-
-

+
+

Change Password

@@ -181,8 +181,8 @@ export function Settings({ onBack }: SettingsProps) {
-
-

+
+

Danger Zone

@@ -229,8 +229,8 @@ export function Settings({ onBack }: SettingsProps) { {deleteError && (

{deleteError}

)} -
- From 173e85146c47990a7f5e4bace5ce3eb75f2485f4 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:28:37 -0500 Subject: [PATCH 11/13] feat(mobile): make MonthNav responsive with stacked layout --- frontend/src/components/MonthNav.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/MonthNav.tsx b/frontend/src/components/MonthNav.tsx index 57e19fc..99d1cdf 100644 --- a/frontend/src/components/MonthNav.tsx +++ b/frontend/src/components/MonthNav.tsx @@ -47,17 +47,18 @@ export function MonthNav({ if (!selectedMonth) return null; return ( -
-
+
+
-
+
{MONTH_NAMES[selectedMonth.month - 1]} {selectedMonth.year}
@@ -74,21 +75,22 @@ export function MonthNav({
-
+
{selectedMonth.is_closed && ( - )} {canClose && ( - )} From 257e0d886b4716539554b29b94e2cc37cc1c0442 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:28:39 -0500 Subject: [PATCH 12/13] feat(mobile): restructure Dashboard controls for mobile --- frontend/src/pages/Dashboard.tsx | 40 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 7ddb0e7..def0566 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -61,27 +61,31 @@ export function Dashboard({ onSettingsClick }: DashboardProps) { return ( -
-
- -
- +
+ +
+
+
+ +
+
+ setShowVarianceModal(true)} + /> +
-
- setShowVarianceModal(true)} - /> +
+
-
From a773954a6e46cead739a7e800eae9cda5e1f3f98 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 14 Jan 2026 19:28:40 -0500 Subject: [PATCH 13/13] feat(mobile): adjust Summary component spacing and sizing --- frontend/src/components/Summary.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Summary.tsx b/frontend/src/components/Summary.tsx index f5f0fa9..62b16ae 100644 --- a/frontend/src/components/Summary.tsx +++ b/frontend/src/components/Summary.tsx @@ -52,20 +52,20 @@ export function Summary({ totalIncome, totalFixed, totalSpent, remaining, extraC
{item.label}
-
+
${Math.abs(item.value).toFixed(2)} {item.label === "Remaining" && item.value < 0 && ( deficit )}
- +
); return ( -
+
{items.map(renderCard)} {extraCard} {itemsAfter.map(renderCard)}