From fabe5319aeeaeb4876ea70ec93cd8becf895c28d Mon Sep 17 00:00:00 2001 From: miaurizius Date: Wed, 10 Jun 2026 01:00:45 +0200 Subject: [PATCH] Added UI for mfa and other profile settings --- .gitignore | 3 +- frontend/assets/js/api.js | 246 +++++++++++++++++- frontend/handler.go | 17 +- .../htmx/contents/dash/account_settings.html | 139 ++++++++++ handlers/account.go | 123 ++++++++- server/server.go | 3 + storage/storage.go | 30 +++ 7 files changed, 545 insertions(+), 16 deletions(-) create mode 100644 frontend/htmx/contents/dash/account_settings.html 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" }} + + +
+ + + + +
+
+
+

Two-factor authentication

+

Loading 2FA status...

+
+ Unknown +
+ + + + +
+
+{{ 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