From c8e2945c07824cf54fdc04acdac4e265aae6bf97 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:43:20 +0100 Subject: [PATCH] feat: two-step registration with avatar/color picker, fix focus ring clipping Refactor RegisterPage into a two-step flow: credentials first, then personalization (display name, avatar upload, avatar color). Replace the dual-panel sliding layout with conditional rendering and CSS keyframe animations to eliminate overflow-hidden clipping of focus rings. Supporting changes: - Server accepts avatarColor on registration - Auth store resets all user-scoped stores on login/register/logout - Voice store gains resetSession() for full session cleanup - Sync presence status to federated instances - Propagate presence_update to socialStore regardless of origin --- packages/server/src/routes/auth.ts | 6 +- packages/shared/src/types.ts | 1 + .../web/src/components/auth/RegisterPage.tsx | 358 ++++++++++++++---- packages/web/src/hooks/useWebSocket.ts | 4 +- packages/web/src/stores/authStore.ts | 26 +- packages/web/src/stores/voiceStore.ts | 33 ++ packages/web/src/styles/globals.css | 15 + packages/web/src/utils/profileSync.ts | 5 +- 8 files changed, 364 insertions(+), 84 deletions(-) diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index ee3d9dce..60814b0b 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -18,7 +18,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }, }, }, async (request, reply) => { - const { username, password, displayName, homeInstance, homeUserId } = request.body; + const { username, password, displayName, avatarColor: requestedAvatarColor, homeInstance, homeUserId } = request.body; if (!username || typeof username !== 'string') { return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); @@ -96,7 +96,9 @@ export async function authRoutes(app: FastifyInstance): Promise { const userCount = db.select().from(schema.users).all().length; const isFirstUser = userCount === 0 && !homeInstance; - const avatarColor = AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]; + const avatarColor = (requestedAvatarColor && (AVATAR_COLORS as readonly string[]).includes(requestedAvatarColor)) + ? requestedAvatarColor + : AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]; db.insert(schema.users).values({ id: userId, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a9b99e71..e1208e9a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -303,6 +303,7 @@ export interface RegisterRequest { username: string; password: string; displayName?: string; + avatarColor?: string; homeInstance?: string; homeUserId?: string; } diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 1d9ddb91..a205dc61 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -1,29 +1,62 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../stores/authStore'; +import { Avatar } from '../ui/Avatar'; +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'; export function RegisterPage() { + // Step state + const [step, setStep] = useState<1 | 2>(1); + const [direction, setDirection] = useState<'forward' | 'back'>('forward'); + + // Step 1 fields const [username, setUsername] = useState(''); - const [displayName, setDisplayName] = useState(''); const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + // Step 2 fields + const [displayName, setDisplayName] = useState(''); + const [avatarColor, setAvatarColor] = useState( + () => AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'mint' + ); + const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarFile, setAvatarFile] = useState(null); + const [avatarCropSrc, setAvatarCropSrc] = useState(null); + const avatarInputRef = useRef(null); + const [error, setError] = useState(''); + const [isRegistering, setIsRegistering] = useState(false); + const register = useAuthStore((s) => s.register); - const isLoading = useAuthStore((s) => s.isLoading); + const updateProfile = useAuthStore((s) => s.updateProfile); const navigate = useNavigate(); - const handleSubmit = async (e: React.FormEvent) => { + // Cleanup blob URL on unmount + useEffect(() => { + return () => { + if (avatarPreview) URL.revokeObjectURL(avatarPreview); + }; + }, [avatarPreview]); + + // ── Step 1 validation ── + const handleContinue = (e: React.FormEvent) => { e.preventDefault(); setError(''); - if (!username.trim()) { + const trimmed = username.trim(); + if (!trimmed) { setError('Username is required'); return; } - if (username.trim().length < 3 || username.trim().length > 32) { + if (trimmed.length < 3 || trimmed.length > 32) { setError('Username must be between 3 and 32 characters'); return; } - if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) { + if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) { setError('Username can only contain letters, numbers, and underscores'); return; } @@ -35,86 +68,275 @@ export function RegisterPage() { setError('Password must be at least 6 characters'); return; } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + setDirection('forward'); + setStep(2); + }; + + // ── Avatar file selection ── + const handleAvatarSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => setAvatarCropSrc(reader.result as string); + reader.readAsDataURL(file); + if (avatarInputRef.current) avatarInputRef.current.value = ''; + }; + + const handleAvatarCropComplete = (blob: Blob) => { + if (avatarPreview) URL.revokeObjectURL(avatarPreview); + const previewUrl = URL.createObjectURL(blob); + setAvatarPreview(previewUrl); + setAvatarFile(new File([blob], 'avatar.png', { type: 'image/png' })); + setAvatarCropSrc(null); + }; + + // ── Registration ── + const handleRegister = async (skip: boolean) => { + setError(''); + setIsRegistering(true); try { - await register(username.trim(), password, displayName.trim() || undefined); + const dn = skip ? undefined : displayName.trim() || undefined; + const ac = skip ? undefined : avatarColor; + await register(username.trim(), password, dn, ac); + + // Upload avatar if chosen (non-fatal — account already created) + if (!skip && avatarFile) { + try { + const attachment = await api.uploads.upload(avatarFile); + await updateProfile({ avatar: attachment.filename }); + } catch { + // Avatar upload failed — user can set it later in settings + } + } + navigate('/channels/@me'); } catch (err) { setError(err instanceof Error ? err.message : 'Registration failed'); + setIsRegistering(false); } }; + const effectiveDisplayName = displayName.trim() || username.trim(); + const initial = effectiveDisplayName.charAt(0).toUpperCase(); + const gradient = AVATAR_GRADIENT_MAP[avatarColor]; + return (
-
-
-

Create an account

+
+ {/* Progress dots */} +
+
+
-
- {error && ( -
- {error} + {step === 1 ? ( +
+
+

Create an account

- )} -
- - setUsername(e.target.value)} - className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" - autoFocus - autoComplete="username" - /> + + {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" + autoFocus + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" + autoComplete="new-password" + /> +
+ + + +

+ Already have an account?{' '} + + Log In + +

+
+ ) : ( +
+
+

Make it yours

+

Personalize your profile, or skip for now

+
-
- - setDisplayName(e.target.value)} - className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" - autoComplete="name" - /> + {error && ( +
+ {error} +
+ )} + + {/* Avatar preview */} +
+ + + +
+ + {/* Display Name */} +
+ + setDisplayName(e.target.value)} + placeholder={username.trim() || 'Display name'} + className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all placeholder:text-txt-tertiary" + autoComplete="name" + /> +
+ + {/* Avatar Color Picker */} +
+ +
+ {AVATAR_COLORS.map((key) => { + const entry = AVATAR_GRADIENT_MAP[key]; + return ( +
+
+ + {/* Actions */} + + +
+ + +
- -
- - setPassword(e.target.value)} - className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" - autoComplete="new-password" - /> -
- - - -

- Already have an account?{' '} - - Log In - -

- + )}
+ + {/* Image Crop Modal */} + {avatarCropSrc && ( + setAvatarCropSrc(null)} + imageSrc={avatarCropSrc} + onCropComplete={handleAvatarCropComplete} + title="Crop Avatar" + aspectRatio={1} + cropShape="round" + /> + )}
); } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 71d319d3..50caa029 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -305,9 +305,7 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'presence_update': updateMemberPresence(event.userId, event.status); - if (isHome) { - useSocialStore.getState().updateFriendPresence(event.userId, event.status); - } + useSocialStore.getState().updateFriendPresence(event.userId, event.status); break; case 'user_updated': { diff --git a/packages/web/src/stores/authStore.ts b/packages/web/src/stores/authStore.ts index e22b4905..9cf12747 100644 --- a/packages/web/src/stores/authStore.ts +++ b/packages/web/src/stores/authStore.ts @@ -14,7 +14,7 @@ interface AuthState { isLoading: boolean; error: string | null; login: (username: string, password: string) => Promise; - register: (username: string, password: string, displayName?: string) => Promise; + register: (username: string, password: string, displayName?: string, avatarColor?: string) => Promise; logout: () => void; loadUser: () => Promise; updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise; @@ -22,6 +22,15 @@ interface AuthState { clearError: () => void; } +/** Reset all user-scoped stores to prevent data leaking between sessions */ +function resetUserStores() { + useChatStore.getState().clearAllMessages(); + useSpaceStore.getState().populateFromReady('', [], [], []); + useSocialStore.getState().reset(); + useVoiceStore.getState().resetSession(); + useInstanceStore.getState().reset(); +} + export const useAuthStore = create((set, get) => ({ token: localStorage.getItem('backspace_token'), user: null, @@ -32,6 +41,7 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const response = await api.auth.login({ username, password }); + resetUserStores(); localStorage.setItem('backspace_token', response.token); set({ token: response.token, user: response.user, isLoading: false }); // Auto-connect to remote instances (fire-and-forget) @@ -42,12 +52,15 @@ export const useAuthStore = create((set, get) => ({ } }, - register: async (username: string, password: string, displayName?: string) => { + register: async (username: string, password: string, displayName?: string, avatarColor?: string) => { set({ isLoading: true, error: null }); try { - const response = await api.auth.register({ username, password, displayName }); + const response = await api.auth.register({ username, password, displayName, avatarColor }); + resetUserStores(); localStorage.setItem('backspace_token', response.token); set({ token: response.token, user: response.user, isLoading: false }); + // Auto-connect to remote instances (fire-and-forget) + useInstanceStore.getState().autoConnectAll().catch(() => {}); } catch (err) { set({ isLoading: false, error: err instanceof Error ? err.message : 'Registration failed' }); throw err; @@ -56,12 +69,7 @@ export const useAuthStore = create((set, get) => ({ logout: () => { localStorage.removeItem('backspace_token'); - // Clear all user-scoped state to prevent data leaking between sessions - useChatStore.getState().clearAllMessages(); - useSpaceStore.getState().populateFromReady('', [], [], []); - useSocialStore.getState().reset(); - useVoiceStore.getState().clearAllVoiceUsers(); - useInstanceStore.getState().reset(); + resetUserStores(); set({ token: null, user: null }); }, diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 9f84cfaa..ce4484ae 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -104,6 +104,7 @@ interface VoiceState { setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void; getVoiceUsers: (channelId: string) => string[]; clearAllVoiceUsers: () => void; + resetSession: () => void; clearVoiceUsersForOrigin: (origin: string) => void; leaveVoice: () => void; handleForceDisconnect: () => void; @@ -365,6 +366,38 @@ export const useVoiceStore = create()( clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }), + resetSession: () => set({ + // Connection state + voiceUsers: new Map(), + voiceUserStates: new Map(), + currentVoiceChannelId: null, + participants: [], + speakingParticipantIds: new Set(), + connectionError: null, + isLiveKitConnected: false, + connectionQuality: 'unknown', + focusedParticipantId: null, + // Call state + incomingCall: null, + outgoingCall: null, + activeDmCall: null, + // Per-session media state + isCameraOn: false, + isScreenSharing: false, + // Per-session maps + participantVolumes: new Map(), + participantMutes: new Map(), + deafenedUserIds: new Set(), + streamVolumes: new Map(), + streamMutes: new Map(), + watchingStreams: new Set(), + unwatchedCameras: new Set(), + // Server-enforced restrictions + serverMutedUserIds: new Set(), + serverDeafenedUserIds: new Set(), + permissionMutedUserIds: new Set(), + }), + clearVoiceUsersForOrigin: (origin: string) => { const { channelOriginMap } = useSpaceStore.getState(); set((state) => { diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index a91a9c77..04f9cc25 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -260,3 +260,18 @@ 100% { background-color: transparent; } } .search-highlight { animation: search-flash 2s ease-out; } + +@keyframes stepForward { + from { transform: translateX(24px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +@keyframes stepBack { + from { transform: translateX(-24px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +.animate-step-forward { + animation: stepForward 0.25s ease-out; +} +.animate-step-back { + animation: stepBack 0.25s ease-out; +} diff --git a/packages/web/src/utils/profileSync.ts b/packages/web/src/utils/profileSync.ts index eeccba53..9cb26f4f 100644 --- a/packages/web/src/utils/profileSync.ts +++ b/packages/web/src/utils/profileSync.ts @@ -30,6 +30,7 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise