From 359876ae1c077cb5c7c3c97b152ed95f16fc41b4 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 10 Apr 2026 23:54:41 +0000 Subject: [PATCH] feat: username editing in account settings Add username editing to the web UI account settings page and backend profile update handler (TASK-496). Backend: - handleUpdateCurrentUser accepts optional username field - Validates format, reserved words, and uniqueness (skips if unchanged) - Returns clear errors for taken/invalid/reserved usernames Frontend (settings page): - Username field with @ prefix indicator below name - Debounced real-time availability checking (400ms) - Status indicators: checking/available/taken - Save blocked when username is invalid --- internal/server/handlers_auth.go | 27 ++++- .../[workspace]/settings/+page.svelte | 110 +++++++++++++++++- 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index 1d854484..0a5a60f4 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -548,6 +548,7 @@ func (s *Server) handleUpdateCurrentUser(w http.ResponseWriter, r *http.Request) var input struct { Name *string `json:"name,omitempty"` + Username *string `json:"username,omitempty"` CurrentPassword string `json:"current_password,omitempty"` NewPassword string `json:"new_password,omitempty"` } @@ -566,6 +567,29 @@ func (s *Server) handleUpdateCurrentUser(w http.ResponseWriter, r *http.Request) input.Name = &trimmed } + // Validate username if provided + if input.Username != nil { + trimmed := strings.ToLower(strings.TrimSpace(*input.Username)) + input.Username = &trimmed + + if err := ValidateUsername(trimmed); err != nil { + writeError(w, http.StatusBadRequest, "validation_error", err.Error()) + return + } + // Check uniqueness (skip if unchanged) + if trimmed != user.Username { + existing, err := s.store.GetUserByUsername(trimmed) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check username") + return + } + if existing != nil { + writeError(w, http.StatusConflict, "conflict", "Username is already taken") + return + } + } + } + // Validate password change if input.NewPassword != "" { if input.CurrentPassword == "" { @@ -592,7 +616,8 @@ func (s *Server) handleUpdateCurrentUser(w http.ResponseWriter, r *http.Request) // Build update update := models.UserUpdate{ - Name: input.Name, + Name: input.Name, + Username: input.Username, } if input.NewPassword != "" { update.Password = &input.NewPassword diff --git a/web/src/routes/[username]/[workspace]/settings/+page.svelte b/web/src/routes/[username]/[workspace]/settings/+page.svelte index 7f518a35..98801974 100644 --- a/web/src/routes/[username]/[workspace]/settings/+page.svelte +++ b/web/src/routes/[username]/[workspace]/settings/+page.svelte @@ -39,9 +39,19 @@ // Account let profileName = $state(''); let profileEmail = $state(''); + let profileUsername = $state(''); + let originalUsername = $state(''); let profileRole = $state(''); let savingProfile = $state(false); let profileStatus = $state<'idle' | 'saved' | 'error'>('idle'); + let profileError = $state(''); + + // Username availability checking + let usernameChecking = $state(false); + let usernameAvailable = $state(null); + let usernameError = $state(''); + let usernameCheckTimeout = $state | null>(null); + let usernameChanged = $derived(profileUsername !== originalUsername); let currentPassword = $state(''); let newPassword = $state(''); @@ -120,6 +130,8 @@ const me = await api.auth.me(); profileName = me.name; profileEmail = me.email; + profileUsername = me.username; + originalUsername = me.username; profileRole = me.role; } catch {} try { @@ -313,16 +325,62 @@ } } + function checkUsernameAvailability() { + if (usernameCheckTimeout) clearTimeout(usernameCheckTimeout); + usernameAvailable = null; + usernameError = ''; + + if (!usernameChanged) { + usernameChecking = false; + return; + } + + if (!profileUsername || profileUsername.length < 3) { + usernameChecking = false; + if (profileUsername.length > 0) { + usernameError = 'Username must be at least 3 characters'; + } + return; + } + + usernameChecking = true; + usernameCheckTimeout = setTimeout(async () => { + try { + const result = await api.auth.checkUsername(profileUsername); + usernameAvailable = result.available; + usernameError = result.message || ''; + } catch { + usernameError = ''; + usernameAvailable = null; + } finally { + usernameChecking = false; + } + }, 400); + } + async function saveProfile() { if (!profileName.trim() || savingProfile) return; + if (usernameChanged && usernameAvailable === false) return; + if (usernameChanged && profileUsername.length > 0 && profileUsername.length < 3) return; savingProfile = true; profileStatus = 'idle'; + profileError = ''; try { - await api.auth.updateProfile({ name: profileName.trim() }); + const update: { name: string; username?: string } = { name: profileName.trim() }; + if (usernameChanged) { + update.username = profileUsername.trim(); + } + await api.auth.updateProfile(update); + if (usernameChanged) { + originalUsername = profileUsername.trim(); + usernameAvailable = null; + usernameError = ''; + } profileStatus = 'saved'; setTimeout(() => (profileStatus = 'idle'), 2000); - } catch { + } catch (err: unknown) { profileStatus = 'error'; + profileError = err instanceof Error ? err.message : 'Failed to save profile'; } finally { savingProfile = false; } @@ -588,13 +646,46 @@
e.key === 'Enter' && saveProfile()} /> -
+ +
+ +
+
+ @ + e.key === 'Enter' && saveProfile()} + placeholder="username" + autocomplete="username" + /> +
+ {#if usernameChanged} + {#if usernameChecking} + checking... + {:else if usernameAvailable === true} + available + {:else if usernameAvailable === false} + {usernameError || 'not available'} + {:else if usernameError} + {usernameError} + {/if} + {/if} +
+
+
+ +
+ {#if profileStatus === 'saved'} Saved {:else if profileStatus === 'error'} - Error + {profileError || 'Error'} {/if}
@@ -1090,6 +1181,17 @@ .token-secret-warning { font-size: 0.82em; color: var(--accent-orange); margin: 0 0 var(--space-2); font-weight: 500; } .token-secret-row { display: flex; align-items: center; gap: var(--space-2); } .token-secret { font-size: 0.82em; background: var(--bg-tertiary); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); word-break: break-all; flex: 1; } + /* ── Username field ──── */ + .username-input-wrapper { display: flex; align-items: center; flex: 1; min-width: 120px; max-width: 300px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } + .username-input-wrapper:focus-within { border-color: var(--accent-blue); } + .username-prefix { padding: var(--space-1) 0 var(--space-1) var(--space-2); font-size: 0.9em; color: var(--text-muted); flex-shrink: 0; user-select: none; } + .username-input-wrapper input { border: none; background: none; padding-left: var(--space-1); min-width: 0; flex: 1; } + .username-input-wrapper input:focus { outline: none; box-shadow: none; } + .username-status { font-size: 0.78em; white-space: nowrap; } + .username-status.checking { color: var(--text-muted); } + .username-status.available { color: var(--accent-green); } + .username-status.taken { color: #ef4444; } + .profile-actions-row { border-top: none !important; padding-top: 0; } /* ── Platform ──── */ .platform-actions { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-3); flex-wrap: wrap; }