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
+18
View File
@@ -238,6 +238,24 @@ func (d *DB) CreateAdmin(username, passwordHash string) (Admin, error) {
return d.GetAdminByUsername(username) return d.GetAdminByUsername(username)
} }
func (d *DB) UpdateAdminPasswordHash(id int64, hash string) error {
result, err := d.conn.Exec(
`UPDATE admins SET password_hash = ?, updated_at = datetime('now') WHERE id = ?`,
hash, id,
)
if err != nil {
return fmt.Errorf("update admin password: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("admin not found")
}
return nil
}
func (d *DB) GetSetting(key string) (string, bool, error) { func (d *DB) GetSetting(key string) (string, bool, error) {
var value string var value string
err := d.conn.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) err := d.conn.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
+21 -1
View File
@@ -78,13 +78,33 @@ func (a *AuthService) EnsureAdmin(username, password string) (bool, error) {
func (a *AuthService) Authenticate(username, password string) bool { func (a *AuthService) Authenticate(username, password string) bool {
admin, err := a.db.GetAdminByUsername(username) admin, err := a.db.GetAdminByUsername(username)
if err != nil { if err != nil {
// Run a dummy hash comparison to reduce timing side-channels.
_ = bcrypt.CompareHashAndPassword([]byte("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password)) _ = bcrypt.CompareHashAndPassword([]byte("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password))
return false return false
} }
return bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) == nil return bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) == nil
} }
func (a *AuthService) ChangePassword(username, oldPassword, newPassword string) error {
if len(newPassword) < 8 {
return fmt.Errorf("la nueva contraseña debe tener al menos 8 caracteres")
}
admin, err := a.db.GetAdminByUsername(username)
if err != nil {
return fmt.Errorf("admin no encontrado")
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(oldPassword)); err != nil {
return fmt.Errorf("contraseña actual incorrecta")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := a.db.UpdateAdminPasswordHash(admin.ID, string(hash)); err != nil {
return err
}
return nil
}
func (a *AuthService) issueToken(username string) string { func (a *AuthService) issueToken(username string) string {
expiry := time.Now().Add(sessionTTL).Unix() expiry := time.Now().Add(sessionTTL).Unix()
payload := fmt.Sprintf("%s|%d", username, expiry) payload := fmt.Sprintf("%s|%d", username, expiry)
+32
View File
@@ -10,6 +10,11 @@ type loginRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
type changePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil { if s.Auth == nil {
writeError(w, http.StatusServiceUnavailable, "auth not configured") writeError(w, http.StatusServiceUnavailable, "auth not configured")
@@ -47,3 +52,30 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"username": username, "username": username,
}) })
} }
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
writeError(w, http.StatusServiceUnavailable, "auth not configured")
return
}
username, ok := s.Auth.currentUser(r)
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
defer r.Body.Close()
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.OldPassword == "" || req.NewPassword == "" {
writeError(w, http.StatusBadRequest, "old_password and new_password are required")
return
}
if err := s.Auth.ChangePassword(username, req.OldPassword, req.NewPassword); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+1
View File
@@ -29,6 +29,7 @@ func NewRouter(s *Server) chi.Router {
protected.Post("/apply", s.handleApply) protected.Post("/apply", s.handleApply)
protected.Get("/apply/log", s.handleApplyLog) protected.Get("/apply/log", s.handleApplyLog)
protected.Get("/system/status", s.handleSystemStatus) protected.Get("/system/status", s.handleSystemStatus)
protected.Put("/auth/password", s.handleChangePassword)
protected.Get("/import/status", s.handleImportStatus) protected.Get("/import/status", s.handleImportStatus)
protected.Post("/import/samba", s.handleImportSamba) protected.Post("/import/samba", s.handleImportSamba)
+2
View File
@@ -9,6 +9,7 @@ import Users from "./pages/Users";
import Samba from "./pages/Samba"; import Samba from "./pages/Samba";
import Nfs from "./pages/Nfs"; import Nfs from "./pages/Nfs";
import Log from "./pages/Log"; import Log from "./pages/Log";
import Settings from "./pages/Settings";
type AuthState = { loading: boolean; authenticated: boolean; username: string }; type AuthState = { loading: boolean; authenticated: boolean; username: string };
@@ -54,6 +55,7 @@ export default function App() {
<Route path="/samba" element={<Samba />} /> <Route path="/samba" element={<Samba />} />
<Route path="/nfs" element={<Nfs />} /> <Route path="/nfs" element={<Nfs />} />
<Route path="/log" element={<Log />} /> <Route path="/log" element={<Log />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>
+2
View File
@@ -87,6 +87,8 @@ export const api = {
login: (username: string, password: string) => login: (username: string, password: string) =>
request<{ username: string }>("POST", "/auth/login", { username, password }), request<{ username: string }>("POST", "/auth/login", { username, password }),
logout: () => request<{ ok: boolean }>("POST", "/auth/logout"), 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 / apply
dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"), dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"),
+1
View File
@@ -8,6 +8,7 @@ const navItems = [
{ to: "/samba", label: "SMB / Samba" }, { to: "/samba", label: "SMB / Samba" },
{ to: "/nfs", label: "NFS" }, { to: "/nfs", label: "NFS" },
{ to: "/log", label: "Historial" }, { to: "/log", label: "Historial" },
{ to: "/settings", label: "Ajustes" },
]; ];
export default function Layout({ username, onLogout }: { username: string; onLogout: () => void }) { 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>
);
}