feat: account deletion, username reuse, and real-time username availability
- Add account deletion with tombstone (isDeleted flag), password/username confirmation, owned-space guard, and full cleanup transaction - Free deleted usernames by renaming to !deleted:<id> so they can be reused - Add migration to retroactively free usernames from already-tombstoned users - Add GET /api/auth/check-username endpoint with rate limiting for real-time availability checking during registration - Add debounced username availability indicator on registration Step 1 - Add DeleteAccountModal with federation-aware remote account cleanup - Add federation ops utility for remote instance management - Update sanitizeUser to anonymize deleted user profiles - Add instance store improvements and connected instances modal updates
This commit is contained in:
@@ -1,15 +1,31 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { RateLimitError } from '../../api/client';
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [retryAfter, setRetryAfter] = useState(0);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (retryAfter <= 0) return;
|
||||
const timer = setInterval(() => {
|
||||
setRetryAfter((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [retryAfter]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
@@ -27,10 +43,17 @@ export function LoginPage() {
|
||||
await login(username.trim(), password);
|
||||
navigate('/channels/@me');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
if (err instanceof RateLimitError) {
|
||||
setRetryAfter(err.retryAfter);
|
||||
setError('');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = isLoading || retryAfter > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||
@@ -41,6 +64,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{retryAfter > 0 && (
|
||||
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
||||
<p className="font-medium text-accent-amber">Too many login attempts</p>
|
||||
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
{error}
|
||||
@@ -76,10 +106,14 @@ export function LoginPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isDisabled}
|
||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Log In'}
|
||||
{retryAfter > 0
|
||||
? `Try again in ${retryAfter}s`
|
||||
: isLoading
|
||||
? 'Logging in...'
|
||||
: 'Log In'}
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-sm text-txt-tertiary">
|
||||
|
||||
@@ -6,7 +6,9 @@ import { ImageCropModal } from '../ui/ImageCropModal';
|
||||
import { AVATAR_GRADIENT_MAP } from '../../utils/gradients';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import type { AvatarColor } from '@backspace/shared';
|
||||
import { api } from '../../api/client';
|
||||
import { api, RateLimitError } from '../../api/client';
|
||||
|
||||
type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
|
||||
|
||||
export function RegisterPage() {
|
||||
// Step state
|
||||
@@ -18,6 +20,12 @@ export function RegisterPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
// Username availability check
|
||||
const [usernameStatus, setUsernameStatus] = useState<UsernameStatus>('idle');
|
||||
const [usernameStatusMessage, setUsernameStatusMessage] = useState('');
|
||||
const usernameCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const usernameCheckAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Step 2 fields
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [avatarColor, setAvatarColor] = useState<AvatarColor>(
|
||||
@@ -30,6 +38,7 @@ export function RegisterPage() {
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [retryAfter, setRetryAfter] = useState(0);
|
||||
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
@@ -42,6 +51,82 @@ export function RegisterPage() {
|
||||
};
|
||||
}, [avatarPreview]);
|
||||
|
||||
// Debounced username availability check
|
||||
useEffect(() => {
|
||||
// Clear previous timer and abort
|
||||
if (usernameCheckTimerRef.current) clearTimeout(usernameCheckTimerRef.current);
|
||||
if (usernameCheckAbortRef.current) usernameCheckAbortRef.current.abort();
|
||||
|
||||
const trimmed = username.trim();
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
setUsernameStatus('idle');
|
||||
setUsernameStatusMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length < 3 || trimmed.length > 32) {
|
||||
setUsernameStatus(trimmed.length > 0 ? 'invalid' : 'idle');
|
||||
setUsernameStatusMessage(trimmed.length > 0 ? 'Username must be between 3 and 32 characters' : '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
|
||||
setUsernameStatus('invalid');
|
||||
setUsernameStatusMessage('Username can only contain letters, numbers, and underscores');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsernameStatus('checking');
|
||||
setUsernameStatusMessage('Checking availability...');
|
||||
|
||||
usernameCheckTimerRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
usernameCheckAbortRef.current = controller;
|
||||
|
||||
try {
|
||||
const result = await api.auth.checkUsername(trimmed);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (result.reason) {
|
||||
setUsernameStatus('invalid');
|
||||
setUsernameStatusMessage(result.reason);
|
||||
} else if (result.available) {
|
||||
setUsernameStatus('available');
|
||||
setUsernameStatusMessage('Username is available');
|
||||
} else {
|
||||
setUsernameStatus('taken');
|
||||
setUsernameStatusMessage('Username is already taken');
|
||||
}
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
// Network error or rate limit — fall back to idle silently
|
||||
setUsernameStatus('idle');
|
||||
setUsernameStatusMessage('');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (usernameCheckTimerRef.current) clearTimeout(usernameCheckTimerRef.current);
|
||||
if (usernameCheckAbortRef.current) usernameCheckAbortRef.current.abort();
|
||||
};
|
||||
}, [username]);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (retryAfter <= 0) return;
|
||||
const timer = setInterval(() => {
|
||||
setRetryAfter((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [retryAfter]);
|
||||
|
||||
// ── Step 1 validation ──
|
||||
const handleContinue = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -60,6 +145,9 @@ export function RegisterPage() {
|
||||
setError('Username can only contain letters, numbers, and underscores');
|
||||
return;
|
||||
}
|
||||
if (usernameStatus === 'taken' || usernameStatus === 'invalid') {
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setError('Password is required');
|
||||
return;
|
||||
@@ -116,7 +204,12 @@ export function RegisterPage() {
|
||||
|
||||
navigate('/channels/@me');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
if (err instanceof RateLimitError) {
|
||||
setRetryAfter(err.retryAfter);
|
||||
setError('');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
}
|
||||
setIsRegistering(false);
|
||||
}
|
||||
};
|
||||
@@ -125,6 +218,8 @@ export function RegisterPage() {
|
||||
const initial = effectiveDisplayName.charAt(0).toUpperCase();
|
||||
const gradient = AVATAR_GRADIENT_MAP[avatarColor];
|
||||
|
||||
const isDisabled = isRegistering || retryAfter > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||
@@ -160,6 +255,31 @@ export function RegisterPage() {
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
{usernameStatus !== 'idle' && (
|
||||
<div className={`mt-1.5 flex items-center gap-1.5 text-xs ${
|
||||
usernameStatus === 'available' ? 'text-status-online' :
|
||||
usernameStatus === 'checking' ? 'text-txt-tertiary' :
|
||||
'text-txt-danger'
|
||||
}`}>
|
||||
{usernameStatus === 'checking' && (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{usernameStatus === 'available' && (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{(usernameStatus === 'taken' || usernameStatus === 'invalid') && (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{usernameStatusMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
@@ -190,7 +310,8 @@ export function RegisterPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
||||
disabled={usernameStatus === 'taken' || usernameStatus === 'invalid'}
|
||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
@@ -210,6 +331,13 @@ export function RegisterPage() {
|
||||
<p className="text-txt-tertiary text-sm mt-1">Personalize your profile, or skip for now</p>
|
||||
</div>
|
||||
|
||||
{retryAfter > 0 && (
|
||||
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
||||
<p className="font-medium text-accent-amber">Too many attempts</p>
|
||||
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
{error}
|
||||
@@ -297,16 +425,20 @@ export function RegisterPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRegister(false)}
|
||||
disabled={isRegistering}
|
||||
disabled={isDisabled}
|
||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isRegistering ? 'Creating account...' : 'Get Started'}
|
||||
{retryAfter > 0
|
||||
? `Try again in ${retryAfter}s`
|
||||
: isRegistering
|
||||
? 'Creating account...'
|
||||
: 'Get Started'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setError(''); setDirection('back'); setStep(1); }}
|
||||
onClick={() => { setError(''); setRetryAfter(0); setDirection('back'); setStep(1); }}
|
||||
disabled={isRegistering}
|
||||
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -315,7 +447,7 @@ export function RegisterPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRegister(true)}
|
||||
disabled={isRegistering}
|
||||
disabled={isDisabled}
|
||||
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Skip for now
|
||||
|
||||
Reference in New Issue
Block a user