From 9f138deb9d703fea2a5de9e4eb43cb91b6b42e4f Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 29 Jun 2026 14:00:19 -0400 Subject: [PATCH] feat: login with username or email + change password Backend: - Login handler accepts username OR email via the same field - New PUT /auth/me/password endpoint (verifies current password, requires min 8 chars for new password) Frontend: - Login form shows EMAIL OR USERNAME field (single field for both) - Auth store updated with changePassword function - UserSettings has new CHANGE PASSWORD section --- internal/auth/handlers.go | 75 ++++++++++++++- web/src/components/LoginForm.tsx | 141 +++++++++++++++++----------- web/src/components/UserSettings.tsx | 83 +++++++++++++++- web/src/stores/auth.ts | 75 +++++++++++---- 4 files changed, 300 insertions(+), 74 deletions(-) diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 24d08ee..90b7999 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -38,6 +38,7 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) { func (h *Handler) RegisterProtectedRoutes(r chi.Router) { r.Get("/auth/me", h.Me) r.Patch("/auth/me", h.UpdateProfile) + r.Put("/auth/me/password", h.ChangePassword) } type registerRequest struct { @@ -49,6 +50,7 @@ type registerRequest struct { type loginRequest struct { Email string `json:"email"` + Username string `json:"username"` Password string `json:"password"` } @@ -59,6 +61,11 @@ type updateProfileRequest struct { StatusText string `json:"status_text"` } +type changePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + type UserProfile struct { ID string `json:"id"` Username string `json:"username"` @@ -150,10 +157,19 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { return } + login := strings.TrimSpace(req.Email) + if login == "" { + login = strings.TrimSpace(req.Username) + } + if login == "" || req.Password == "" { + http.Error(w, `{"error":"email/username and password required"}`, http.StatusBadRequest) + return + } + var userID, hash string err := h.db.QueryRowContext(r.Context(), ` - SELECT id, password_hash FROM users WHERE email = $1 - `, req.Email).Scan(&userID, &hash) + SELECT id, password_hash FROM users WHERE email = $1 OR username = $1 + `, login).Scan(&userID, &hash) if err != nil { http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) return @@ -295,3 +311,58 @@ func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, e func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) { SetSessionCookie(w, h.cfg.Session.CookieName, token, h.cfg.Session.Duration) } + +// ChangePassword handles PUT /auth/me/password. +func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req changePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + if req.CurrentPassword == "" || req.NewPassword == "" { + http.Error(w, `{"error":"current and new password required"}`, http.StatusBadRequest) + return + } + + if len(req.NewPassword) < 8 { + http.Error(w, `{"error":"password must be at least 8 characters"}`, http.StatusBadRequest) + return + } + + // Verify current password + var currentHash string + err := h.db.QueryRowContext(r.Context(), "SELECT password_hash FROM users WHERE id = $1", userID).Scan(¤tHash) + if err != nil { + http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) + return + } + + ok, err = VerifyPassword(req.CurrentPassword, currentHash) + if err != nil || !ok { + http.Error(w, `{"error":"current password is incorrect"}`, http.StatusUnauthorized) + return + } + + // Hash new password + newHash, err := HashPassword(req.NewPassword) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + _, err = h.db.ExecContext(r.Context(), "UPDATE users SET password_hash = $1 WHERE id = $2", newHash, userID) + if err != nil { + http.Error(w, `{"error":"failed to update password"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "password updated"}) +} diff --git a/web/src/components/LoginForm.tsx b/web/src/components/LoginForm.tsx index 25781c1..2946d2b 100644 --- a/web/src/components/LoginForm.tsx +++ b/web/src/components/LoginForm.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { useAuthStore } from '../stores/auth.ts'; +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuthStore } from "../stores/auth.ts"; export function LoginForm() { const [isRegister, setIsRegister] = useState(false); - const [email, setEmail] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const { login, register, isLoading, error, clearError } = useAuthStore(); const navigate = useNavigate(); @@ -16,44 +16,63 @@ export function LoginForm() { clearError(); if (isRegister) { - if (password !== confirmPassword) { - return; - } + if (password !== confirmPassword) return; await register(email, username, password); } else { await login(email, password); } - navigate('/'); + navigate("/"); }; return (
-
{'┌─ DUMPSTER ─┐'}
-

{isRegister ? '[REGISTER]' : '[LOGIN]'}

+
+          {"┌─ DUMPSTER ─┐"}
+        
+

+ {isRegister ? "[REGISTER]" : "[LOGIN]"} +

-
- - setEmail(e.target.value)} - className="terminal-input" - required - autoComplete="email" - /> -
- {isRegister && ( + {isRegister ? ( + <> +
+ + setEmail(e.target.value)} + className="terminal-input" + required + autoComplete="email" + /> +
+
+ + setUsername(e.target.value)} + className="terminal-input" + required + autoComplete="username" + /> +
+ + ) : (
- + setUsername(e.target.value)} + value={email} + onChange={(e) => setEmail(e.target.value)} className="terminal-input" required autoComplete="username" + placeholder="email or username" />
)} @@ -65,12 +84,16 @@ export function LoginForm() { onChange={(e) => setPassword(e.target.value)} className="terminal-input" required - autoComplete={isRegister ? 'new-password' : 'current-password'} + autoComplete={ + isRegister ? "new-password" : "current-password" + } />
{isRegister && (
- + )} {error &&

ERR: {error}

} - {!isRegister && ( @@ -91,28 +122,29 @@ export function LoginForm() { type="button" onClick={async () => { try { - // Get challenge from server - const resp = await fetch('/api/v1/auth/webauthn/login/begin', { method: 'POST', credentials: 'include' }); - if (!resp.ok) throw new Error('Passkey login not available'); + const resp = await fetch( + "/api/v1/auth/webauthn/login/begin", + { method: "POST", credentials: "include" }, + ); + if (!resp.ok) + throw new Error("Passkey login not available"); const options = await resp.json(); - - // Use WebAuthn API - const credential = await navigator.credentials.get({ publicKey: options }); - if (!credential) return; - - // Send to server - const finishResp = await fetch('/api/v1/auth/webauthn/login/finish', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credential), + const credential = await navigator.credentials.get({ + publicKey: options, }); - - if (finishResp.ok) { - navigate('/'); - } + if (!credential) return; + const finishResp = await fetch( + "/api/v1/auth/webauthn/login/finish", + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(credential), + }, + ); + if (finishResp.ok) navigate("/"); } catch (err) { - console.error('Passkey login failed:', err); + console.error("Passkey login failed:", err); } }} className="terminal-button w-full border-gb-aqua text-gb-aqua" @@ -130,12 +162,15 @@ export function LoginForm() { }} className="text-gb-blue hover:text-gb-aqua text-sm" > - {isRegister ? '[BACK TO LOGIN]' : '[CREATE ACCOUNT]'} + {isRegister ? "[BACK TO LOGIN]" : "[CREATE ACCOUNT]"}
{!isRegister && (

- Use Link component: [HOME] + Use Link component:{" "} + + [HOME] +

)}
diff --git a/web/src/components/UserSettings.tsx b/web/src/components/UserSettings.tsx index 955d792..1453ea5 100644 --- a/web/src/components/UserSettings.tsx +++ b/web/src/components/UserSettings.tsx @@ -4,7 +4,7 @@ import { useAuthStore } from '../stores/auth.ts'; import { usePushStore } from '../stores/push.ts'; export function UserSettings() { - const { user, updateProfile, isLoading, error, clearError, fetchMe } = useAuthStore(); + const { user, updateProfile, changePassword, isLoading, error, clearError, fetchMe } = useAuthStore(); const { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore(); const navigate = useNavigate(); @@ -17,6 +17,14 @@ export function UserSettings() { const [uploadError, setUploadError] = useState(null); const fileInputRef = useRef(null); + // Password change state + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmNewPassword, setConfirmNewPassword] = useState(''); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [passwordSuccess, setPasswordSuccess] = useState(false); + useEffect(() => { if (user) { setDisplayName(user.display_name || ''); @@ -86,6 +94,32 @@ export function UserSettings() { const hexValid = /^#[0-9a-fA-F]{6}$/.test(accentColor); + const handleChangePassword = async () => { + setPasswordError(null); + setPasswordSuccess(false); + if (newPassword !== confirmNewPassword) { + setPasswordError("new passwords do not match"); + return; + } + if (newPassword.length < 8) { + setPasswordError("password must be at least 8 characters"); + return; + } + setPasswordLoading(true); + try { + await changePassword(currentPassword, newPassword); + setPasswordSuccess(true); + setCurrentPassword(''); + setNewPassword(''); + setConfirmNewPassword(''); + setTimeout(() => setPasswordSuccess(false), 3000); + } catch { + // error is set in store + } finally { + setPasswordLoading(false); + } + }; + return (
@@ -234,6 +268,53 @@ export function UserSettings() {
+ {/* Change Password */} +
+ +
+ setCurrentPassword(e.target.value)} + className="terminal-input w-full" + placeholder="current password" + autoComplete="current-password" + /> + setNewPassword(e.target.value)} + className="terminal-input w-full" + placeholder="new password (min 8 chars)" + autoComplete="new-password" + /> + setConfirmNewPassword(e.target.value)} + className="terminal-input w-full" + placeholder="confirm new password" + autoComplete="new-password" + /> + {passwordError && ( +

ERR: {passwordError}

+ )} + {passwordSuccess && ( +

[password changed!]

+ )} + +
+
+ {/* Push Notifications */} {isSupported && (
diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 19c00dd..372d0d0 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -1,7 +1,7 @@ -import { create } from 'zustand'; -import { api } from '../lib/api.ts'; +import { create } from "zustand"; +import { api } from "../lib/api.ts"; -export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline'; +export type UserStatus = "online" | "idle" | "dnd" | "offline"; export interface User { id: string; @@ -28,11 +28,20 @@ interface AuthState { isAuthenticated: boolean; isLoading: boolean; error: string | null; - login: (email: string, password: string) => Promise; - register: (email: string, username: string, password: string, displayName?: string) => Promise; + login: (identifier: string, password: string) => Promise; + register: ( + email: string, + username: string, + password: string, + displayName?: string, + ) => Promise; logout: () => Promise; fetchMe: () => Promise; updateProfile: (data: UpdateProfilePayload) => Promise; + changePassword: ( + currentPassword: string, + newPassword: string, + ) => Promise; clearError: () => void; } @@ -42,16 +51,19 @@ export const useAuthStore = create((set) => ({ isLoading: false, error: null, - login: async (email, password) => { + login: async (identifier, password) => { set({ isLoading: true, error: null }); try { - await api.post('/auth/login/password', { email, password }); - const user = await api.get('/auth/me'); + await api.post("/auth/login/password", { + email: identifier, + password, + }); + const user = await api.get("/auth/me"); set({ user, isAuthenticated: true, isLoading: false }); } catch (error) { set({ isLoading: false, - error: error instanceof Error ? error.message : 'Login failed', + error: error instanceof Error ? error.message : "Login failed", }); throw error; } @@ -60,13 +72,19 @@ export const useAuthStore = create((set) => ({ register: async (email, username, password, displayName) => { set({ isLoading: true, error: null }); try { - await api.post('/auth/register', { email, username, password, display_name: displayName || username }); - const user = await api.get('/auth/me'); + await api.post("/auth/register", { + email, + username, + password, + display_name: displayName || username, + }); + const user = await api.get("/auth/me"); set({ user, isAuthenticated: true, isLoading: false }); } catch (error) { set({ isLoading: false, - error: error instanceof Error ? error.message : 'Registration failed', + error: + error instanceof Error ? error.message : "Registration failed", }); throw error; } @@ -75,12 +93,12 @@ export const useAuthStore = create((set) => ({ logout: async () => { set({ isLoading: true, error: null }); try { - await api.post('/auth/logout'); + await api.post("/auth/logout"); set({ user: null, isAuthenticated: false, isLoading: false }); } catch (error) { set({ isLoading: false, - error: error instanceof Error ? error.message : 'Logout failed', + error: error instanceof Error ? error.message : "Logout failed", }); throw error; } @@ -89,14 +107,15 @@ export const useAuthStore = create((set) => ({ fetchMe: async () => { set({ isLoading: true, error: null }); try { - const user = await api.get('/auth/me'); + const user = await api.get("/auth/me"); set({ user, isAuthenticated: true, isLoading: false }); } catch (error) { set({ user: null, isAuthenticated: false, isLoading: false, - error: error instanceof Error ? error.message : 'Failed to fetch user', + error: + error instanceof Error ? error.message : "Failed to fetch user", }); } }, @@ -104,12 +123,32 @@ export const useAuthStore = create((set) => ({ updateProfile: async (data) => { set({ isLoading: true, error: null }); try { - const user = await api.patch('/auth/me', data); + const user = await api.patch("/auth/me", data); set({ user, isLoading: false }); } catch (error) { set({ isLoading: false, - error: error instanceof Error ? error.message : 'Update failed', + error: error instanceof Error ? error.message : "Update failed", + }); + throw error; + } + }, + + changePassword: async (currentPassword, newPassword) => { + set({ isLoading: true, error: null }); + try { + await api.put("/auth/me/password", { + current_password: currentPassword, + new_password: newPassword, + }); + set({ isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: + error instanceof Error + ? error.message + : "Password change failed", }); throw error; }