feat: add admin password change via /settings page

Backend:
- DB: add UpdateAdminPasswordHash(id, hash) in queries_users.go
- Auth: add ChangePassword(username, old, new) method that verifies
  old password, validates length >= 8, and stores bcrypt hash
- Handlers: add handleChangePassword on PUT /api/auth/password (auth required)
- Router: register route inside protected group

Frontend:
- api.ts: add changePassword(oldPassword, newPassword)
- Settings.tsx: new page with form (current + new + confirm), client-side
  validation, success/error feedback
- App.tsx: add /settings route
- Layout.tsx: add 'Ajustes' nav item
This commit is contained in:
2026-07-05 22:31:45 -04:00
parent 4ae7335b31
commit 72a8336087
8 changed files with 174 additions and 1 deletions
+2
View File
@@ -9,6 +9,7 @@ import Users from "./pages/Users";
import Samba from "./pages/Samba";
import Nfs from "./pages/Nfs";
import Log from "./pages/Log";
import Settings from "./pages/Settings";
type AuthState = { loading: boolean; authenticated: boolean; username: string };
@@ -54,6 +55,7 @@ export default function App() {
<Route path="/samba" element={<Samba />} />
<Route path="/nfs" element={<Nfs />} />
<Route path="/log" element={<Log />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
+2
View File
@@ -87,6 +87,8 @@ export const api = {
login: (username: string, password: string) =>
request<{ username: string }>("POST", "/auth/login", { username, password }),
logout: () => request<{ ok: boolean }>("POST", "/auth/logout"),
changePassword: (oldPassword: string, newPassword: string) =>
request<{ ok: boolean }>("PUT", "/auth/password", { old_password: oldPassword, new_password: newPassword }),
// dirty / apply
dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"),
+1
View File
@@ -8,6 +8,7 @@ const navItems = [
{ to: "/samba", label: "SMB / Samba" },
{ to: "/nfs", label: "NFS" },
{ to: "/log", label: "Historial" },
{ to: "/settings", label: "Ajustes" },
];
export default function Layout({ username, onLogout }: { username: string; onLogout: () => void }) {
+97
View File
@@ -0,0 +1,97 @@
import { FormEvent, useState } from "react";
import { api } from "../api";
export default function Settings() {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSuccess(false);
if (newPassword.length < 8) {
setError("La nueva contraseña debe tener al menos 8 caracteres.");
return;
}
if (newPassword !== confirm) {
setError("La nueva contraseña y la confirmación no coinciden.");
return;
}
setLoading(true);
try {
await api.changePassword(oldPassword, newPassword);
setSuccess(true);
setOldPassword("");
setNewPassword("");
setConfirm("");
} catch (err) {
setError(err instanceof Error ? err.message : "Error al cambiar la contraseña.");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-white">Ajustes</h1>
<div className="card max-w-lg">
<h2 className="mb-4 text-lg font-semibold text-white">Cambiar contraseña</h2>
{error && (
<div className="mb-4 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
)}
{success && (
<div className="mb-4 rounded-md bg-emerald-500/15 px-3 py-2 text-sm text-emerald-200">
Contraseña cambiada correctamente.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Contraseña actual</label>
<input
className="input"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div>
<label className="label">Nueva contraseña</label>
<input
className="input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
<p className="mt-1 text-xs text-slate-500">Mínimo 8 caracteres.</p>
</div>
<div>
<label className="label">Confirmar nueva contraseña</label>
<input
className="input"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="pt-2">
<button className="btn-primary" type="submit" disabled={loading}>
{loading ? "Guardando..." : "Guardar contraseña"}
</button>
</div>
</form>
</div>
</div>
);
}