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
+82 -1
View File
@@ -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<string | null>(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(() => {
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 (
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
<div className="max-w-2xl mx-auto">
@@ -234,6 +268,53 @@ export function UserSettings() {
</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 */}
{isSupported && (
<div className="border border-gb-bg-t p-4">