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]"}
+
{isRegister && (
-
+
)}
{error &&
ERR: {error}
}
-
{!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!]
+ )}
+
+ {passwordLoading ? '[CHANGING...]' : '[CHANGE PASSWORD]'}
+
+
+
+
{/* 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;
}