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:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user