feat(account): add editable phone and password change with validation

- Add editable phone field in /account General tab with UK phone
  validation
- Create PUT /api/user/change-password endpoint in backend
- Add zxcvbn password strength meter to change password modal
- Add "passwords don't match" validation message to both /account and
  /register
- Fix navbar logout reactivity with invalidateAll and $derived values
- Fix a11y warnings: add labels, roles, and keyboard handlers
- Remove unused CSS from account page
This commit is contained in:
2026-02-20 20:17:38 +00:00
parent 41dc839830
commit 5a4cd29b44
7 changed files with 285 additions and 141 deletions
+67
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -486,3 +487,69 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
return
}
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, "current password and new password are required", http.StatusBadRequest)
return
}
if len(req.NewPassword) < 8 {
http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
return
}
if len(req.NewPassword) > 72 {
http.Error(w, "password must be less than 72 characters", http.StatusBadRequest)
return
}
var passwordHash string
err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "user not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch password hash for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil {
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("Failed to hash new password for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
_, err = db.DB.Exec(r.Context(), `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(newHash), userID)
if err != nil {
log.Printf("Failed to update password for user %s: %v", userID, err)
http.Error(w, "failed to update password", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
+1
View File
@@ -139,6 +139,7 @@ func main() {
r.Get("/user/profile", user.GetProfileHandler)
r.Put("/user/profile", user.UpdateProfileHandler)
r.Put("/user/change-password", user.ChangePasswordHandler)
r.Delete("/user/account", user.DeleteAccountHandler)
r.Get("/user/loyalty", user.GetLoyaltyHandler)
@@ -416,27 +416,14 @@
</AlertDialog.Root>
<style>
/* Hide number input arrows for Chrome, Safari, Edge */
input.no-spin::-webkit-outer-spin-button,
input.no-spin::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Hide number input arrows for Firefox */
input.no-spin[type='number'] {
/* Hide number input arrows for all number inputs in the component */
:global(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
/* Remove arrows from all number inputs in the component */
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
:global(input[type='number']::-webkit-outer-spin-button),
:global(input[type='number']::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
@@ -440,7 +440,7 @@
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700">Tags</label>
<label for="tag-input" class="text-sm font-medium text-gray-700">Tags</label>
<div class="relative">
<div
@@ -494,6 +494,9 @@
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-gray-100'}"
onclick={() => selectSuggestion(s)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectSuggestion(s)}
role="button"
tabindex="0"
>
{s}
</div>
@@ -16,19 +16,21 @@
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' }
];
let mobileMenuOpen: boolean = false;
let mobileMenuOpen = $state(false);
function toggleMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
// Close mobile menu when navigation starts
$: if ($navigating) {
$effect(() => {
if ($navigating) {
mobileMenuOpen = false;
}
});
// Helper to decide visibility
// Helper to decide visibility - use $derived for reactivity
const canShow = (link: { href: string; label: string; showWhen: string; width: string }) => {
if (authStore.isLoading) return true; // keep skeleton placeholders
if (authStore.isLoading) return true;
// hide booking link for admins
if (link.href === '/book' && authStore.currentUser?.role === 'admin') return false;
@@ -40,6 +42,11 @@
if (link.showWhen === 'admin' && authStore.currentUser?.role === 'admin') return true;
return false;
};
// Derived values for auth state - ensures reactivity
let isAuthenticated = $derived(authStore.isAuthenticated);
let currentUserRole = $derived(authStore.currentUser?.role);
let isLoading = $derived(authStore.isLoading);
</script>
<nav
@@ -80,7 +87,7 @@
<div class="hidden space-x-8 md:flex">
{#each links as link}
{#if canShow(link)}
{#if authStore.isLoading}
{#if isLoading}
<Skeleton class={`h-4 ${link.width} rounded`} />
{:else}
<a href={link.href} class="font-medium text-gray-800 hover:text-primary"
@@ -93,17 +100,17 @@
<!-- Desktop Login Button -->
<div class="hidden items-center md:flex">
{#if !authStore.isLoading && !authStore.isAuthenticated && $page.url.pathname !== '/login'}
{#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'}
<Button href="/login">Login</Button>
{/if}
{#if authStore.isLoading}
{#if isLoading}
<Skeleton class="h-8 w-16 rounded" />
{/if}
</div>
<!-- Mobile: Burger -->
<div class="flex items-center md:hidden">
<button on:click={toggleMenu} class="focus:outline-none" aria-label="Toggle menu">
<button onclick={toggleMenu} class="focus:outline-none" aria-label="Toggle menu">
<svg class="h-6 w-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@@ -123,7 +130,7 @@
<div class="space-y-1 px-2 pt-2 pb-3">
{#each links as link}
{#if canShow(link)}
{#if authStore.isLoading}
{#if isLoading}
<Skeleton class="h-4 w-full rounded" />
{:else}
<a
@@ -136,9 +143,9 @@
{/if}
{/each}
{#if authStore.isLoading}
{#if isLoading}
<Skeleton class="mt-2 h-8 w-full rounded" />
{:else if !authStore.isAuthenticated}
{:else if !isAuthenticated}
<Button href="/login" class="mt-2 w-full text-center">Login</Button>
{/if}
</div>
+1 -1
View File
@@ -146,7 +146,7 @@ class AuthStore {
// inside AuthStore
logout = () => {
this.clearAuth();
goto('/');
goto('/', { invalidateAll: true });
};
private clearAuth() {
+187 -108
View File
@@ -6,6 +6,21 @@
import { toast } from 'svelte-sonner';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
// zxcvbn-ts imports
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
import * as languageCommon from '@zxcvbn-ts/language-common';
import * as languageEn from '@zxcvbn-ts/language-en';
// set up options so that feedback, dictionary etc. are included
zxcvbnOptions.setOptions({
translations: languageEn.translations,
graphs: languageCommon.adjacencyGraphs,
dictionary: {
...languageCommon.dictionary,
...languageEn.dictionary
}
});
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
@@ -66,6 +81,102 @@
let loadingUser = $state(true);
let stamps = $state(0);
// =============== Phone Edit Mode ===============
let editingPhone = $state(false);
let phoneInput = $state('');
let phoneError = $state('');
let savingPhone = $state(false);
// Phone validation (UK format)
function validatePhone(phone: string): boolean {
if (!phone) {
phoneError = '';
return true;
}
const cleanPhone = phone.replace(/[\s\-()]/g, '');
// UK phone regex: +44 followed by 10-11 digits, or 0 followed by 10-11 digits
const phoneRegex = /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/;
const isValid = phoneRegex.test(cleanPhone);
phoneError = isValid ? '' : 'Invalid UK phone number';
return isValid;
}
// Format phone number as user types
function formatPhoneInput(value: string): string {
// Remove all non-digits except +
const digits = value.replace(/[^\d+]/g, '');
// Format UK numbers
if (digits.startsWith('+44')) {
return digits; // Keep +44 as is
}
if (digits.startsWith('44')) {
return '+' + digits;
}
if (digits.startsWith('0')) {
// Format 07xx xxx xxxx
if (digits.length <= 5) {
return digits;
}
if (digits.length <= 10) {
return digits.slice(0, 5) + ' ' + digits.slice(5);
}
return digits.slice(0, 5) + ' ' + digits.slice(5, 10) + ' ' + digits.slice(10, 12);
}
return digits;
}
function startEditPhone() {
phoneInput = userData?.phone || '';
phoneError = '';
editingPhone = true;
}
function cancelEditPhone() {
editingPhone = false;
phoneInput = '';
phoneError = '';
}
async function savePhone() {
const formattedPhone = phoneInput.replace(/[\s\-()]/g, '');
if (!validatePhone(formattedPhone)) {
return;
}
savingPhone = true;
const loadingToast = toast.loading('Updating phone number...');
try {
const response = await fetch('/api/user/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: userData?.firstName,
lastName: userData?.lastName,
phone: formattedPhone
})
});
if (response.ok) {
toast.success('Phone number updated successfully!', { id: loadingToast });
editingPhone = false;
// Refresh user data
await fetchUserData();
} else {
const text = await response.text();
toast.error(text || 'Failed to update phone number', { id: loadingToast });
}
} catch (err) {
console.error('Error updating phone:', err);
toast.error('Network error', { id: loadingToast });
} finally {
savingPhone = false;
}
}
// =============== Fetch User Data ===============
async function fetchUserData() {
if (pageState !== 'authorized') return;
@@ -232,6 +343,11 @@
});
let changingPassword = $state(false);
// Password strength using zxcvbn
let newPasswordStrength = $derived(passwordData.new ? zxcvbn(passwordData.new) : null);
let isPasswordStrongEnough = $derived(!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2);
let passwordsMatch = $derived(passwordData.confirm === '' || passwordData.new === passwordData.confirm);
async function changePassword() {
if (passwordData.new !== passwordData.confirm) {
toast.error('New passwords do not match');
@@ -243,6 +359,11 @@
return;
}
if (!isPasswordStrongEnough) {
toast.error('Please choose a stronger password');
return;
}
changingPassword = true;
const loadingToast = toast.loading('Changing password...');
@@ -493,29 +614,59 @@
{:else if userData}
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="text-sm font-medium text-gray-600">First Name</label>
<span class="text-sm font-medium text-gray-600">First Name</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
{userData.firstName}
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-600">Last Name</label>
<span class="text-sm font-medium text-gray-600">Last Name</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
{userData.lastName}
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-600">Email</label>
<span class="text-sm font-medium text-gray-600">Email</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
{userData.email}
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-600">Phone</label>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
{userData.phone || '—'}
<span class="text-sm font-medium text-gray-600">Phone</span>
{#if editingPhone}
<div class="mt-1 space-y-2">
<Input
id="phone"
type="tel"
bind:value={phoneInput}
placeholder="Enter phone number"
class="font-medium"
/>
{#if phoneError}
<p class="text-sm text-red-500">{phoneError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={savePhone} disabled={savingPhone}>
{savingPhone ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="outline" onclick={cancelEditPhone} disabled={savingPhone}>
Cancel
</Button>
</div>
</div>
{:else}
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
<span>{userData.phone || '—'}</span>
<Button size="sm" variant="ghost" onclick={startEditPhone}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
</div>
<Separator class="my-4" />
@@ -945,6 +1096,32 @@
placeholder="Enter new password"
class="mt-1"
/>
{#if passwordData.new && newPasswordStrength}
<div class="mt-2 space-y-1">
<div class="flex h-1.5 w-full overflow-hidden rounded bg-gray-200">
<div
class="transition-all duration-300"
style="width: {(newPasswordStrength.score + 1) * 20}%; background-color: {newPasswordStrength.score < 2
? '#ef4444'
: newPasswordStrength.score === 2
? '#f59e0b'
: newPasswordStrength.score === 3
? '#22c55e'
: '#15803d'}"
></div>
</div>
<p class="text-xs {newPasswordStrength.score < 2 ? 'text-red-500' : newPasswordStrength.score === 2 ? 'text-amber-500' : 'text-green-600'}">
{newPasswordStrength.feedback.warning
? newPasswordStrength.feedback.warning
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][newPasswordStrength.score]}`}
</p>
{#if newPasswordStrength.feedback.suggestions.length > 0}
<p class="text-xs text-gray-500">
{newPasswordStrength.feedback.suggestions[0]}
</p>
{/if}
</div>
{/if}
</div>
<div>
<label for="confirm-password" class="text-sm font-medium">Confirm New Password</label>
@@ -955,6 +1132,9 @@
placeholder="Confirm new password"
class="mt-1"
/>
{#if !passwordsMatch}
<p class="mt-1 text-xs text-red-500">Passwords do not match</p>
{/if}
</div>
</Card.Content>
<Card.Footer class="flex justify-end gap-2">
@@ -967,7 +1147,7 @@
>
Cancel
</Button>
<Button onclick={changePassword} disabled={changingPassword}>
<Button onclick={changePassword} disabled={changingPassword || !isPasswordStrongEnough}>
{changingPassword ? 'Changing...' : 'Change Password'}
</Button>
</Card.Footer>
@@ -1047,105 +1227,4 @@
display: block;
}
}
.menu {
margin: 0;
display: flex;
width: 100%;
font-size: 1.5em;
padding: 0 2.85em;
position: relative;
align-items: center;
justify-content: center;
background-color: var(--bgColorMenu);
}
.menu__item {
all: unset;
flex-grow: 1;
z-index: 100;
display: flex;
cursor: pointer;
position: relative;
border-radius: 50%;
align-items: center;
will-change: transform;
justify-content: center;
padding: 0.55em 0 0.85em;
transition: transform var(--duration);
}
.menu__item::before {
content: '';
z-index: -1;
width: 4.2em;
height: 4.2em;
border-radius: 50%;
position: absolute;
transform: scale(0);
transition:
background-color var(--duration),
transform var(--duration);
}
.menu__item.active {
transform: translate3d(0, -0.8em, 0);
}
.menu__item.active::before {
transform: scale(1);
background-color: var(--bgColorItem);
}
.icon {
width: 2.6em;
height: 2.6em;
stroke: white;
fill: transparent;
stroke-width: 1pt;
stroke-miterlimit: 10;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 400;
}
@keyframes strok {
100% {
stroke-dashoffset: 400;
}
}
.menu__border {
left: 0;
bottom: 99%;
width: 10.9em;
height: 2.4em;
position: absolute;
clip-path: url(#menu);
will-change: transform;
background-color: var(--bgColorMenu);
transition: transform var(--duration);
}
.svg-container {
width: 0;
height: 0;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media screen and (max-width: 50em) {
.menu {
font-size: 0.8em;
}
}
</style>