diff --git a/.gitignore b/.gitignore
index 63bb4ec..f7b17af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@ appdata
.idea
*.exe
*.cmd
-.run
\ No newline at end of file
+.run
+*.out
\ No newline at end of file
diff --git a/frontend/assets/js/api.js b/frontend/assets/js/api.js
index a24a98d..c66dcee 100644
--- a/frontend/assets/js/api.js
+++ b/frontend/assets/js/api.js
@@ -39,6 +39,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById('items-table-body')) loadItems();
if (document.getElementById('locations-table-body')) loadLocations();
if (document.getElementById('projects-table-body')) loadProjects();
+ if (document.getElementById('account-settings-content')) loadAccountSettings();
loadProfile();
});
@@ -481,4 +482,247 @@ async function loadProfile() {
} catch (e) {
username.innerHTML = '
| Failed to load data. |
';
}
-}
\ No newline at end of file
+}
+
+// ---- ACCOUNT SETTINGS ----
+let latestRecoveryCodes = [];
+
+function showAccountSettingsMessage(message, type = 'success') {
+ const box = document.getElementById('account-settings-message');
+ if (!box) return;
+ box.textContent = message;
+ box.className = `message ${type}`;
+ box.style.display = 'block';
+}
+
+function setTwoFactorPanels(enabled) {
+ const badge = document.getElementById('two-factor-badge');
+ const status = document.getElementById('two-factor-status');
+ const disabledPanel = document.getElementById('two-factor-disabled-panel');
+ const enabledPanel = document.getElementById('two-factor-enabled-panel');
+
+ if (!badge || !status || !disabledPanel || !enabledPanel) return;
+
+ if (enabled) {
+ badge.textContent = 'Enabled';
+ badge.classList.add('success');
+ status.textContent = '2FA is enabled for your account.';
+ disabledPanel.style.display = 'none';
+ enabledPanel.style.display = 'block';
+ } else {
+ badge.textContent = 'Disabled';
+ badge.classList.remove('success');
+ status.textContent = '2FA is disabled. Enable it to protect your account with an authenticator app.';
+ disabledPanel.style.display = 'block';
+ enabledPanel.style.display = 'none';
+ }
+}
+
+function renderRecoveryCodes(codes) {
+ latestRecoveryCodes = codes || [];
+ const panel = document.getElementById('recovery-codes-panel');
+ const list = document.getElementById('recovery-codes-list');
+ if (!panel || !list) return;
+
+ if (latestRecoveryCodes.length === 0) {
+ panel.style.display = 'none';
+ list.textContent = '';
+ return;
+ }
+
+ list.textContent = latestRecoveryCodes.join('\n');
+ panel.style.display = 'block';
+}
+
+async function loadAccountSettings() {
+ try {
+ const data = await apiRequest('/api/profile');
+
+ const usernameInput = document.getElementById('settings-username');
+ const avatarPreview = document.getElementById('settings-avatar-preview');
+ const remaining = document.getElementById('recovery-codes-remaining');
+
+ if (usernameInput) usernameInput.value = data.username || '';
+ if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
+ if (remaining) remaining.innerText = data.recovery_codes_remaining || 0;
+
+ setTwoFactorPanels(!!data.two_factor_enabled);
+ renderRecoveryCodes([]);
+ } catch (err) {
+ showAccountSettingsMessage(err.message || 'Failed to load account settings.', 'error');
+ }
+}
+
+async function saveAccountUsername(event) {
+ event.preventDefault();
+
+ try {
+ const data = await apiRequest('/api/account/username', 'POST', {
+ username: document.getElementById('settings-username').value.trim(),
+ password: document.getElementById('settings-username-password').value
+ });
+
+ document.getElementById('settings-username-password').value = '';
+ showAccountSettingsMessage('Username updated.');
+
+ const username = document.getElementById('username');
+ const avatar = document.getElementById('avatar');
+ const avatarPreview = document.getElementById('settings-avatar-preview');
+ if (username) username.innerText = data.username;
+ if (avatar && data.username) avatar.innerText = data.username[0].toLocaleUpperCase();
+ if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
+ } catch (err) {
+ showAccountSettingsMessage(err.message || 'Could not update username.', 'error');
+ }
+}
+
+async function saveAccountPassword(event) {
+ event.preventDefault();
+
+ const currentPassword = document.getElementById('settings-current-password').value;
+ const newPassword = document.getElementById('settings-new-password').value;
+ const confirmPassword = document.getElementById('settings-confirm-password').value;
+
+ if (newPassword !== confirmPassword) {
+ showAccountSettingsMessage('New passwords do not match.', 'error');
+ return;
+ }
+
+ try {
+ const data = await apiRequest('/api/account/password', 'POST', {
+ current_password: currentPassword,
+ new_password: newPassword
+ });
+
+ if (data && data.access_token && data.refresh_token) {
+ localStorage.setItem('access_token', data.access_token);
+ localStorage.setItem('refresh_token', data.refresh_token);
+ }
+
+ document.getElementById('password-form').reset();
+ showAccountSettingsMessage('Password updated. Your session was refreshed.');
+ } catch (err) {
+ showAccountSettingsMessage(err.message || 'Could not update password.', 'error');
+ }
+}
+
+async function startTwoFactorSetup() {
+ try {
+ const data = await apiRequest('/api/2fa/setup', 'POST');
+ const panel = document.getElementById('two-factor-setup-panel');
+ const qr = document.getElementById('two-factor-qr');
+ const secret = document.getElementById('two-factor-secret');
+ const otpauth = document.getElementById('two-factor-otpauth');
+
+ if (panel) panel.style.display = 'block';
+ if (qr) {
+ qr.src = data.qr_code;
+ qr.style.display = data.qr_code ? 'block' : 'none';
+ }
+ if (secret) secret.textContent = data.secret || '';
+ if (otpauth) {
+ otpauth.href = data.otpauth_url || '#';
+ otpauth.textContent = data.otpauth_url || 'No otpauth URL available';
+ }
+
+ showAccountSettingsMessage('Scan the QR code or enter the setup key manually, then confirm the 6-digit code.');
+ } catch (err) {
+ showAccountSettingsMessage(err.message || 'Could not start 2FA setup.', 'error');
+ }
+}
+
+async function enableTwoFactor(event) {
+ event.preventDefault();
+
+ try {
+ const data = await apiRequest('/api/2fa/enable', 'POST', {
+ code: document.getElementById('two-factor-enable-code').value.trim()
+ });
+
+ document.getElementById('two-factor-enable-form').reset();
+ document.getElementById('two-factor-setup-panel').style.display = 'none';
+ setTwoFactorPanels(true);
+ renderRecoveryCodes(data.recovery_codes || []);
+
+ const remaining = document.getElementById('recovery-codes-remaining');
+ if (remaining) remaining.innerText = (data.recovery_codes || []).length;
+
+ showAccountSettingsMessage('2FA enabled. Download your recovery codes now.');
+ loadProfile();
+ } catch (err) {
+ showAccountSettingsMessage(err.message || 'Could not enable 2FA.', 'error');
+ }
+}
+
+async function disableTwoFactor(event) {
+ event.preventDefault();
+
+ if (!confirm('Disable 2FA for your account?')) return;
+
+ try {
+ await apiRequest('/api/2fa/disable', 'POST', {
+ password: document.getElementById('two-factor-disable-password').value,
+ code: document.getElementById('two-factor-disable-code').value.trim()
+ });
+
+ document.getElementById('two-factor-disable-form').reset();
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('refresh_token');
+ setTwoFactorPanels(false);
+ renderRecoveryCodes([]);
+ showAccountSettingsMessage('2FA disabled. Redirecting to login because sessions were revoked.');
+ setTimeout(() => {
+ window.location.href = '/login';
+ }, 1200);
+ } catch (err) {
+ showAccountSettingsMessage(err.message || 'Could not disable 2FA.', 'error');
+ }
+}
+
+async function regenerateRecoveryCodes(event) {
+ event.preventDefault();
+
+ if (!confirm('Generate new recovery codes? Existing unused codes will stop working.')) return;
+
+ try {
+ const data = await apiRequest('/api/2fa/recovery-codes/regenerate', 'POST', {
+ password: document.getElementById('recovery-password').value,
+ code: document.getElementById('recovery-code').value.trim()
+ });
+
+ document.getElementById('recovery-regenerate-form').reset();
+ renderRecoveryCodes(data.recovery_codes || []);
+
+ const remaining = document.getElementById('recovery-codes-remaining');
+ if (remaining) remaining.innerText = (data.recovery_codes || []).length;
+
+ showAccountSettingsMessage('New recovery codes generated. Download them now.');
+ } catch (err) {
+ showAccountSettingsMessage(err.message || 'Could not regenerate recovery codes.', 'error');
+ }
+}
+
+function downloadRecoveryCodes() {
+ if (!latestRecoveryCodes || latestRecoveryCodes.length === 0) {
+ showAccountSettingsMessage('No recovery codes available to download.', 'error');
+ return;
+ }
+
+ const text = [
+ 'MiauInv recovery codes',
+ 'Save these somewhere safe. Each code can be used once.',
+ '',
+ ...latestRecoveryCodes,
+ ''
+ ].join('\n');
+
+ const blob = new Blob([text], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'miauinv-recovery-codes.txt';
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
diff --git a/frontend/handler.go b/frontend/handler.go
index 84b4715..594cc6e 100644
--- a/frontend/handler.go
+++ b/frontend/handler.go
@@ -33,6 +33,10 @@ var projects = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/projects.html"))
+var accountSettings = template.Must(template.ParseFiles(
+ "frontend/htmx/contents/dash/base.html",
+ "frontend/htmx/contents/dash/account_settings.html"))
+
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
func Home(w http.ResponseWriter, r *http.Request) {
@@ -51,7 +55,6 @@ func Home(w http.ResponseWriter, r *http.Request) {
return
}
}
-
func Dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@@ -142,6 +145,17 @@ func Projects(w http.ResponseWriter, r *http.Request) {
return
}
}
+func AccountSettings(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ err := accountSettings.ExecuteTemplate(w, "base.html", struct {
+ Title string
+ }{
+ Title: "Account Settings",
+ })
+ if err != nil {
+ return
+ }
+}
var minifier *minify.M
@@ -151,7 +165,6 @@ func init() {
minifier.AddFunc("text/css", css.Minify)
minifier.AddFunc("text/javascript", js.Minify)
}
-
func Assets(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/assets/")
fullPath := filepath.Join("frontend/assets", path)
diff --git a/frontend/htmx/contents/dash/account_settings.html b/frontend/htmx/contents/dash/account_settings.html
new file mode 100644
index 0000000..068db94
--- /dev/null
+++ b/frontend/htmx/contents/dash/account_settings.html
@@ -0,0 +1,139 @@
+{{ define "content" }}
+
+
+
+
+
+
+
+
Profile
+
Change your username. Avatar upload is planned for later.
+
+
+
+
+
Avatar
+
+
M
+
+
Avatar upload is not implemented yet.
+
This placeholder keeps the settings layout ready for it.
+
+
+
+
+
+
+
Password
+
Change your password. You will receive a fresh session afterwards.
+
+
+
+
+
+
+
+
+
Two-factor authentication
+
Loading 2FA status...
+
+
Unknown
+
+
+
+
Use an authenticator app. You can scan the QR code or enter the setup key manually.
+
+
+
+
+
+
Scan QR code
+
![2FA QR code]()
+
+
+
Manual setup
+
If you do not want to scan the QR code, enter this key manually in your authenticator app.
+
+
Open otpauth URL
+
+
+
+
+
+
+
+
+
Recovery codes remaining: 0
+
+
+
Recovery codes
+
Save these now. They are shown only once.
+
+
+
+
+
+
+
Regenerate recovery codes
+
This invalidates all existing recovery codes.
+
+
+
+
+
Disable 2FA
+
Disabling 2FA revokes your active refresh sessions.
+
+
+
+
+
+
+{{ end }}
diff --git a/handlers/account.go b/handlers/account.go
index 283da61..c212485 100644
--- a/handlers/account.go
+++ b/handlers/account.go
@@ -59,7 +59,6 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
}
-
func APILogin(w http.ResponseWriter, r *http.Request) {
var creds struct {
Username string `json:"username"`
@@ -110,7 +109,6 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
issueLoginSession(w, r, user)
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
}
-
func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
var req struct {
TwoFactorToken string `json:"two_factor_token"`
@@ -165,6 +163,117 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
}
+func AccountUpdateUsername(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ username := strings.TrimSpace(req.Username)
+ if username == "" || req.Password == "" {
+ http.Error(w, "Username and password required", http.StatusBadRequest)
+ return
+ }
+
+ claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
+ user, err := storage.GetUserById(claims.UserID)
+ if err != nil {
+ log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
+ http.Error(w, "User not found", http.StatusNotFound)
+ return
+ }
+
+ if !auth.CheckPasswordHash(req.Password, user.Password) {
+ http.Error(w, "Invalid password", http.StatusUnauthorized)
+ return
+ }
+
+ if err := storage.UpdateUserUsername(user.ID, username); err != nil {
+ log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
+ http.Error(w, "Username already exists or could not be saved", http.StatusConflict)
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "username": strings.ToLower(username),
+ })
+ log.Println("POST [api/account/username] " + r.RemoteAddr + ": Updated username")
+}
+func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ CurrentPassword string `json:"current_password"`
+ NewPassword string `json:"new_password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ if req.CurrentPassword == "" || req.NewPassword == "" {
+ http.Error(w, "Current and new password required", http.StatusBadRequest)
+ return
+ }
+ if len(req.NewPassword) > 72 {
+ http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
+ return
+ }
+ if req.CurrentPassword == req.NewPassword {
+ http.Error(w, "New password must be different", http.StatusBadRequest)
+ return
+ }
+
+ claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
+ user, err := storage.GetUserById(claims.UserID)
+ if err != nil {
+ log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
+ http.Error(w, "User not found", http.StatusNotFound)
+ return
+ }
+
+ if !auth.CheckPasswordHash(req.CurrentPassword, user.Password) {
+ http.Error(w, "Invalid password", http.StatusUnauthorized)
+ return
+ }
+
+ hashed, err := auth.HashPassword(req.NewPassword)
+ if err != nil {
+ log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
+ http.Error(w, "Could not hash password", http.StatusInternalServerError)
+ return
+ }
+
+ if err := storage.UpdateUserPassword(user.ID, hashed); err != nil {
+ log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
+ http.Error(w, "Could not update password", http.StatusInternalServerError)
+ return
+ }
+
+ if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
+ log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
+ http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
+ return
+ }
+
+ user.Password = hashed
+ issueLoginSession(w, r, user)
+ log.Println("POST [api/account/password] " + r.RemoteAddr + ": Updated password")
+}
func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -222,7 +331,6 @@ func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
})
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge")
}
-
func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -275,7 +383,6 @@ func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
})
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA and generated recovery codes")
}
-
func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -326,7 +433,6 @@ func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"two_factor_enabled": false})
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA")
}
-
func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -396,7 +502,6 @@ func Logout(w http.ResponseWriter, r *http.Request) {
clearAuthCookies(w)
w.WriteHeader(http.StatusNoContent)
}
-
func TestHandler(w http.ResponseWriter, r *http.Request) {
claims, _ := utils.IsLoggedIn(w, r)
@@ -412,7 +517,6 @@ func TestHandler(w http.ResponseWriter, r *http.Request) {
}
log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection")
}
-
func RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
@@ -456,7 +560,6 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
issueLoginSession(w, r, user)
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
}
-
func UserInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed")
@@ -525,7 +628,6 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
}
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
}
-
func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) {
secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) == 0 {
@@ -599,7 +701,6 @@ func generateRecoveryCodes(count int) ([]string, []string, error) {
return codes, hashes, nil
}
-
func generateRecoveryCode() (string, error) {
bytes := make([]byte, 10)
if _, err := rand.Read(bytes); err != nil {
@@ -609,7 +710,6 @@ func generateRecoveryCode() (string, error) {
raw := hex.EncodeToString(bytes)
return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil
}
-
func normalizeRecoveryCode(code string) string {
code = strings.TrimSpace(code)
code = strings.ReplaceAll(code, "-", "")
@@ -637,7 +737,6 @@ func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
SameSite: http.SameSiteLaxMode,
})
}
-
func clearAuthCookies(w http.ResponseWriter) {
for _, name := range []string{"access_token", "refresh_token"} {
http.SetCookie(w, &http.Cookie{
diff --git a/server/server.go b/server/server.go
index 6391363..a473074 100644
--- a/server/server.go
+++ b/server/server.go
@@ -71,6 +71,7 @@ func (this *Server) Run() {
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
+ mux.Handle("/profile/settings", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.AccountSettings)))
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
if this.AllowRegistration {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
@@ -91,6 +92,8 @@ func (this *Server) Run() {
mux.Handle("/api/2fa/disable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable)))
mux.Handle("/api/2fa/recovery-codes/regenerate", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes)))
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
+ mux.Handle("/api/account/username", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername)))
+ mux.Handle("/api/account/password", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword)))
if this.AllowRegistration {
mux.HandleFunc("/api/register", handlers.APIRegister)
}
diff --git a/storage/storage.go b/storage/storage.go
index cf40588..3c1b5f2 100644
--- a/storage/storage.go
+++ b/storage/storage.go
@@ -150,6 +150,36 @@ func scanUser(row *sql.Row) (models.User, error) {
return user, err
}
+func UpdateUserUsername(userID, username string) error {
+ res, err := DB.Exec("UPDATE users SET username = ? WHERE id = ?", strings.ToLower(username), userID)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func UpdateUserPassword(userID, passwordHash string) error {
+ res, err := DB.Exec("UPDATE users SET password = ? WHERE id = ?", passwordHash, userID)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
func SetUserTwoFactorSecret(userID, secret string) error {
_, err := DB.Exec("UPDATE users SET two_factor_secret = ? WHERE id = ?", secret, userID)
return err