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
This commit is contained in:
2026-06-29 14:00:19 -04:00
parent fec0eb1917
commit 9f138deb9d
4 changed files with 300 additions and 74 deletions
+73 -2
View File
@@ -38,6 +38,7 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) {
func (h *Handler) RegisterProtectedRoutes(r chi.Router) { func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
r.Get("/auth/me", h.Me) r.Get("/auth/me", h.Me)
r.Patch("/auth/me", h.UpdateProfile) r.Patch("/auth/me", h.UpdateProfile)
r.Put("/auth/me/password", h.ChangePassword)
} }
type registerRequest struct { type registerRequest struct {
@@ -49,6 +50,7 @@ type registerRequest struct {
type loginRequest struct { type loginRequest struct {
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
@@ -59,6 +61,11 @@ type updateProfileRequest struct {
StatusText string `json:"status_text"` StatusText string `json:"status_text"`
} }
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
type UserProfile struct { type UserProfile struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
@@ -150,10 +157,19 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return 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 var userID, hash string
err := h.db.QueryRowContext(r.Context(), ` err := h.db.QueryRowContext(r.Context(), `
SELECT id, password_hash FROM users WHERE email = $1 SELECT id, password_hash FROM users WHERE email = $1 OR username = $1
`, req.Email).Scan(&userID, &hash) `, login).Scan(&userID, &hash)
if err != nil { if err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return 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) { func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) {
SetSessionCookie(w, h.cfg.Session.CookieName, token, h.cfg.Session.Duration) 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(&currentHash)
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"})
}
+88 -53
View File
@@ -1,13 +1,13 @@
import { useState } from 'react'; import { useState } from "react";
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from '../stores/auth.ts'; import { useAuthStore } from "../stores/auth.ts";
export function LoginForm() { export function LoginForm() {
const [isRegister, setIsRegister] = useState(false); const [isRegister, setIsRegister] = useState(false);
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [username, setUsername] = useState(''); const [username, setUsername] = useState("");
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState("");
const { login, register, isLoading, error, clearError } = useAuthStore(); const { login, register, isLoading, error, clearError } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -16,44 +16,63 @@ export function LoginForm() {
clearError(); clearError();
if (isRegister) { if (isRegister) {
if (password !== confirmPassword) { if (password !== confirmPassword) return;
return;
}
await register(email, username, password); await register(email, username, password);
} else { } else {
await login(email, password); await login(email, password);
} }
navigate('/'); navigate("/");
}; };
return ( return (
<div className="h-full w-full flex items-center justify-center bg-gb-bg p-4"> <div className="h-full w-full flex items-center justify-center bg-gb-bg p-4">
<div className="terminal-border bg-gb-bg-h p-6 w-full max-w-md"> <div className="terminal-border bg-gb-bg-h p-6 w-full max-w-md">
<pre className="text-gb-orange font-mono text-lg mb-6">{'┌─ DUMPSTER ─┐'}</pre> <pre className="text-gb-orange font-mono text-lg mb-6">
<h2 className="text-gb-fg-s mb-4">{isRegister ? '[REGISTER]' : '[LOGIN]'}</h2> {"┌─ DUMPSTER ─┐"}
</pre>
<h2 className="text-gb-fg-s mb-4">
{isRegister ? "[REGISTER]" : "[LOGIN]"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> {isRegister ? (
<label className="block text-gb-fg-t mb-1">EMAIL:</label> <>
<input <div>
type="email" <label className="block text-gb-fg-t mb-1">EMAIL:</label>
value={email} <input
onChange={(e) => setEmail(e.target.value)} type="email"
className="terminal-input" value={email}
required onChange={(e) => setEmail(e.target.value)}
autoComplete="email" className="terminal-input"
/> required
</div> autoComplete="email"
{isRegister && ( />
</div>
<div>
<label className="block text-gb-fg-t mb-1">USERNAME:</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="terminal-input"
required
autoComplete="username"
/>
</div>
</>
) : (
<div> <div>
<label className="block text-gb-fg-t mb-1">USERNAME:</label> <label className="block text-gb-fg-t mb-1">
EMAIL OR USERNAME:
</label>
<input <input
type="text" type="text"
value={username} value={email}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="terminal-input" className="terminal-input"
required required
autoComplete="username" autoComplete="username"
placeholder="email or username"
/> />
</div> </div>
)} )}
@@ -65,12 +84,16 @@ export function LoginForm() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="terminal-input" className="terminal-input"
required required
autoComplete={isRegister ? 'new-password' : 'current-password'} autoComplete={
isRegister ? "new-password" : "current-password"
}
/> />
</div> </div>
{isRegister && ( {isRegister && (
<div> <div>
<label className="block text-gb-fg-t mb-1">CONFIRM PASSWORD:</label> <label className="block text-gb-fg-t mb-1">
CONFIRM PASSWORD:
</label>
<input <input
type="password" type="password"
value={confirmPassword} value={confirmPassword}
@@ -81,8 +104,16 @@ export function LoginForm() {
</div> </div>
)} )}
{error && <p className="text-gb-red text-sm">ERR: {error}</p>} {error && <p className="text-gb-red text-sm">ERR: {error}</p>}
<button type="submit" className="terminal-button w-full" disabled={isLoading}> <button
{isLoading ? '[PROCESSING...]' : isRegister ? '[REGISTER]' : '[LOGIN]'} type="submit"
className="terminal-button w-full"
disabled={isLoading}
>
{isLoading
? "[PROCESSING...]"
: isRegister
? "[REGISTER]"
: "[LOGIN]"}
</button> </button>
</form> </form>
{!isRegister && ( {!isRegister && (
@@ -91,28 +122,29 @@ export function LoginForm() {
type="button" type="button"
onClick={async () => { onClick={async () => {
try { try {
// Get challenge from server const resp = await fetch(
const resp = await fetch('/api/v1/auth/webauthn/login/begin', { method: 'POST', credentials: 'include' }); "/api/v1/auth/webauthn/login/begin",
if (!resp.ok) throw new Error('Passkey login not available'); { method: "POST", credentials: "include" },
);
if (!resp.ok)
throw new Error("Passkey login not available");
const options = await resp.json(); const options = await resp.json();
const credential = await navigator.credentials.get({
// Use WebAuthn API publicKey: options,
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),
}); });
if (!credential) return;
if (finishResp.ok) { const finishResp = await fetch(
navigate('/'); "/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) { } 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" 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" className="text-gb-blue hover:text-gb-aqua text-sm"
> >
{isRegister ? '[BACK TO LOGIN]' : '[CREATE ACCOUNT]'} {isRegister ? "[BACK TO LOGIN]" : "[CREATE ACCOUNT]"}
</button> </button>
</div> </div>
{!isRegister && ( {!isRegister && (
<p className="text-gb-fg-f text-xs mt-4 text-center"> <p className="text-gb-fg-f text-xs mt-4 text-center">
Use Link component: <Link to="/" className="text-gb-blue hover:text-gb-aqua">[HOME]</Link> Use Link component:{" "}
<Link to="/" className="text-gb-blue hover:text-gb-aqua">
[HOME]
</Link>
</p> </p>
)} )}
</div> </div>
+82 -1
View File
@@ -4,7 +4,7 @@ import { useAuthStore } from '../stores/auth.ts';
import { usePushStore } from '../stores/push.ts'; import { usePushStore } from '../stores/push.ts';
export function UserSettings() { 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 { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,6 +17,14 @@ export function UserSettings() {
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(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<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState(false);
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setDisplayName(user.display_name || ''); setDisplayName(user.display_name || '');
@@ -86,6 +94,32 @@ export function UserSettings() {
const hexValid = /^#[0-9a-fA-F]{6}$/.test(accentColor); 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 ( return (
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto"> <div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
@@ -234,6 +268,53 @@ export function UserSettings() {
</div> </div>
</div> </div>
{/* Change Password */}
<div className="border border-gb-bg-t p-4">
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
CHANGE PASSWORD:
</label>
<div className="space-y-3">
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="terminal-input w-full"
placeholder="current password"
autoComplete="current-password"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="terminal-input w-full"
placeholder="new password (min 8 chars)"
autoComplete="new-password"
/>
<input
type="password"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
className="terminal-input w-full"
placeholder="confirm new password"
autoComplete="new-password"
/>
{passwordError && (
<p className="text-gb-red text-xs font-mono">ERR: {passwordError}</p>
)}
{passwordSuccess && (
<p className="text-gb-green text-xs font-mono">[password changed!]</p>
)}
<button
type="button"
onClick={handleChangePassword}
disabled={passwordLoading || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword}
className="terminal-button text-xs"
>
{passwordLoading ? '[CHANGING...]' : '[CHANGE PASSWORD]'}
</button>
</div>
</div>
{/* Push Notifications */} {/* Push Notifications */}
{isSupported && ( {isSupported && (
<div className="border border-gb-bg-t p-4"> <div className="border border-gb-bg-t p-4">
+57 -18
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from "zustand";
import { api } from '../lib/api.ts'; import { api } from "../lib/api.ts";
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline'; export type UserStatus = "online" | "idle" | "dnd" | "offline";
export interface User { export interface User {
id: string; id: string;
@@ -28,11 +28,20 @@ interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
login: (email: string, password: string) => Promise<void>; login: (identifier: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string, displayName?: string) => Promise<void>; register: (
email: string,
username: string,
password: string,
displayName?: string,
) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
fetchMe: () => Promise<void>; fetchMe: () => Promise<void>;
updateProfile: (data: UpdateProfilePayload) => Promise<void>; updateProfile: (data: UpdateProfilePayload) => Promise<void>;
changePassword: (
currentPassword: string,
newPassword: string,
) => Promise<void>;
clearError: () => void; clearError: () => void;
} }
@@ -42,16 +51,19 @@ export const useAuthStore = create<AuthState>((set) => ({
isLoading: false, isLoading: false,
error: null, error: null,
login: async (email, password) => { login: async (identifier, password) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
await api.post('/auth/login/password', { email, password }); await api.post("/auth/login/password", {
const user = await api.get<User>('/auth/me'); email: identifier,
password,
});
const user = await api.get<User>("/auth/me");
set({ user, isAuthenticated: true, isLoading: false }); set({ user, isAuthenticated: true, isLoading: false });
} catch (error) { } catch (error) {
set({ set({
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Login failed', error: error instanceof Error ? error.message : "Login failed",
}); });
throw error; throw error;
} }
@@ -60,13 +72,19 @@ export const useAuthStore = create<AuthState>((set) => ({
register: async (email, username, password, displayName) => { register: async (email, username, password, displayName) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
await api.post('/auth/register', { email, username, password, display_name: displayName || username }); await api.post("/auth/register", {
const user = await api.get<User>('/auth/me'); email,
username,
password,
display_name: displayName || username,
});
const user = await api.get<User>("/auth/me");
set({ user, isAuthenticated: true, isLoading: false }); set({ user, isAuthenticated: true, isLoading: false });
} catch (error) { } catch (error) {
set({ set({
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Registration failed', error:
error instanceof Error ? error.message : "Registration failed",
}); });
throw error; throw error;
} }
@@ -75,12 +93,12 @@ export const useAuthStore = create<AuthState>((set) => ({
logout: async () => { logout: async () => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
await api.post('/auth/logout'); await api.post("/auth/logout");
set({ user: null, isAuthenticated: false, isLoading: false }); set({ user: null, isAuthenticated: false, isLoading: false });
} catch (error) { } catch (error) {
set({ set({
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Logout failed', error: error instanceof Error ? error.message : "Logout failed",
}); });
throw error; throw error;
} }
@@ -89,14 +107,15 @@ export const useAuthStore = create<AuthState>((set) => ({
fetchMe: async () => { fetchMe: async () => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const user = await api.get<User>('/auth/me'); const user = await api.get<User>("/auth/me");
set({ user, isAuthenticated: true, isLoading: false }); set({ user, isAuthenticated: true, isLoading: false });
} catch (error) { } catch (error) {
set({ set({
user: null, user: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: 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<AuthState>((set) => ({
updateProfile: async (data) => { updateProfile: async (data) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const user = await api.patch<User>('/auth/me', data); const user = await api.patch<User>("/auth/me", data);
set({ user, isLoading: false }); set({ user, isLoading: false });
} catch (error) { } catch (error) {
set({ set({
isLoading: false, 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; throw error;
} }