From 6d97f27a0c57cd15ef3fdc2ef1d53b39f439fe28 Mon Sep 17 00:00:00 2001 From: SAMKIEL Date: Wed, 20 May 2026 18:53:51 +0100 Subject: [PATCH] feat: add username and SAMKIEL ID to all users - Unique username chosen at registration - System-generated permanent SAMKIEL ID (SKL-XXXXXX) - Login accepts email, username, or SAMKIEL ID - Username changeable once every 23 days - Displayed on account.samkiel.tech and Lighthouse Co-Authored-By: Claude Opus 4.7 --- app/(account)/page.tsx | 20 ++++ app/(account)/personal-info/page.tsx | 114 ++++++++++++++++++++- app/(auth)/login/page.tsx | 9 +- app/(auth)/register/page.tsx | 145 ++++++++++++++++++++++++--- components/ui/CopyButton.tsx | 44 ++++++++ package.json | 2 +- 6 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 components/ui/CopyButton.tsx diff --git a/app/(account)/page.tsx b/app/(account)/page.tsx index 4860fed..dec0432 100644 --- a/app/(account)/page.tsx +++ b/app/(account)/page.tsx @@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { Avatar } from '@/components/ui/Avatar'; import { Skeleton } from '@/components/ui/Skeleton'; +import { CopyButton } from '@/components/ui/CopyButton'; import { User, Shield, Grid, Activity, AlertCircle, ArrowRight, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -139,6 +140,25 @@ export default function OverviewPage() { )} + {/* SAMKIEL ID */} +
+ +
+
+

Your SAMKIEL ID

+
+ + {user?.samkielId ?? '—'} + + {user?.samkielId && } +
+

This is your permanent identifier. It never changes.

+ {user?.username &&

@{user.username}

} +
+
+
+
+ {/* Summary grid */}
diff --git a/app/(account)/personal-info/page.tsx b/app/(account)/personal-info/page.tsx index b932152..b094fc8 100644 --- a/app/(account)/personal-info/page.tsx +++ b/app/(account)/personal-info/page.tsx @@ -2,13 +2,14 @@ import React, { useRef, useState } from 'react'; import { useAuth } from '@samkiel/authsdk/react'; -import { SamkielAuthError } from '@samkiel/authsdk'; +import { SamkielAuthError, UsernameChangeCooldownError } from '@samkiel/authsdk'; import { Card } from '@/components/ui/Card'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { Avatar } from '@/components/ui/Avatar'; import { Badge } from '@/components/ui/Badge'; import { Skeleton } from '@/components/ui/Skeleton'; +import { CopyButton } from '@/components/ui/CopyButton'; import { Camera, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -21,12 +22,25 @@ function formatJoinDate(iso?: string) { } } +function formatFullDate(iso?: string) { + if (!iso) return 'a later date'; + try { + return new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); + } catch { + return 'a later date'; + } +} + export default function PersonalInfoPage() { - const { user, isLoading: authLoading, updateName, updateEmail, uploadAvatar } = useAuth(); + const { user, isLoading: authLoading, updateName, updateEmail, uploadAvatar, changeUsername } = useAuth(); const [name, setName] = useState(user?.name ?? ''); const [isSavingName, setIsSavingName] = useState(false); + const [editingUsername, setEditingUsername] = useState(false); + const [usernameInput, setUsernameInput] = useState(''); + const [isSavingUsername, setIsSavingUsername] = useState(false); + const [newEmail, setNewEmail] = useState(''); const [currentPassword, setCurrentPassword] = useState(''); const [isSavingEmail, setIsSavingEmail] = useState(false); @@ -74,6 +88,36 @@ export default function PersonalInfoPage() { } }; + const startEditUsername = () => { + setUsernameInput(user?.username ?? ''); + setEditingUsername(true); + }; + + const handleChangeUsername = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = usernameInput.trim().toLowerCase(); + if (!trimmed || trimmed === user?.username) { + setEditingUsername(false); + return; + } + + setIsSavingUsername(true); + try { + await changeUsername(trimmed); + toast.success('Username updated.'); + setEditingUsername(false); + } catch (err) { + if (err instanceof UsernameChangeCooldownError) { + toast.error(`You can next change your username on ${formatFullDate(err.nextChangeAt)}.`); + } else { + const msg = err instanceof Error ? err.message : 'Could not update your username.'; + toast.error(msg); + } + } finally { + setIsSavingUsername(false); + } + }; + const handleUpdateEmail = async (e: React.FormEvent) => { e.preventDefault(); if (!newEmail || !currentPassword) return; @@ -205,6 +249,72 @@ export default function PersonalInfoPage() { + {/* Username */} + +
+
+

Username

+

+ Your unique handle across SAMKIEL. Changeable once every 23 days. +

+
+ {!editingUsername && ( + + )} +
+ {editingUsername ? ( +
+ setUsernameInput(e.target.value.toLowerCase())} + placeholder="ezekiel_dev" + required + disabled={isSavingUsername} + hint="3-20 characters. Letters, numbers, and underscores only." + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +
+ + +
+
+ ) : ( +

@{user?.username ?? '—'}

+ )} +
+ + {/* SAMKIEL ID */} + +
+

SAMKIEL ID

+

Cannot be changed.

+
+
+ + {user?.samkielId ?? '—'} + + {user?.samkielId && } +
+
+

Email address

diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 76b0e7c..8094efc 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -114,13 +114,16 @@ function LoginContent() {
setEmail(e.target.value)} required disabled={isLoading} + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} className="bg-background/50" />
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 5900a83..4d7f181 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -5,16 +5,26 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuth } from '@samkiel/authsdk/react'; import { toast } from 'sonner'; +import { Check, X, Loader2 } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/utils'; function RegisterContent() { const [name, setName] = useState(''); + const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); - const { register, user, signInWithProvider } = useAuth(); + + // Live username validation/availability state. + const [usernameError, setUsernameError] = useState(null); + const [usernameAvailable, setUsernameAvailable] = useState(false); + const [checkingUsername, setCheckingUsername] = useState(false); + const [suggestion, setSuggestion] = useState(''); + + const { register, user, signInWithProvider, checkUsernameAvailability, suggestUsername } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); const redirect = searchParams.get('redirect'); @@ -25,21 +35,81 @@ function RegisterContent() { } }, [user, router]); + // Validate format instantly, then debounce the availability check by 400ms. + useEffect(() => { + setUsernameAvailable(false); + if (!username) { + setUsernameError(null); + return; + } + if (/[^a-z0-9_]/.test(username)) { + setUsernameError('Letters, numbers, and underscores only.'); + return; + } + if (username.length < 3) { + setUsernameError('Must be at least 3 characters.'); + return; + } + if (username.length > 20) { + setUsernameError('Must be 20 characters or fewer.'); + return; + } + setUsernameError(null); + setCheckingUsername(true); + + const timer = setTimeout(async () => { + try { + const { available, error } = await checkUsernameAvailability(username); + setUsernameAvailable(available); + setUsernameError(available ? null : error || 'That username is taken.'); + } catch { + // Ignore transient network errors for the live check. + } finally { + setCheckingUsername(false); + } + }, 400); + + return () => clearTimeout(timer); + }, [username, checkUsernameAvailability]); + + // Suggest a handle from the name (debounced) while the username is untouched. + useEffect(() => { + if (!name.trim()) { + setSuggestion(''); + return; + } + const timer = setTimeout(async () => { + try { + const res = await suggestUsername(name); + setSuggestion(res.suggestion); + } catch { + // Suggestions are best-effort. + } + }, 400); + return () => clearTimeout(timer); + }, [name, suggestUsername]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + if (usernameError || (!usernameAvailable && username.length > 0)) { + toast.error('Please choose a valid, available username.'); + return; + } + setIsLoading(true); try { - // Adjusted to match SDK signature (name, email, password) - await register(name, email, password); + await register(name, email, password, username); toast.success('Account created. Check your email to verify.'); - } catch (error: any) { const errorMessage = error.message || ''; - if (errorMessage.includes('409') || errorMessage.includes('exists')) { + if (errorMessage.toLowerCase().includes('username')) { + toast.error(errorMessage); + } else if (errorMessage.includes('409') || errorMessage.toLowerCase().includes('exists')) { toast.error('An account with this email already exists.'); } else { - toast.error('We couldn\'t create your account. Please try again.'); + toast.error("We couldn't create your account. Please try again."); } } finally { setIsLoading(false); @@ -74,6 +144,57 @@ function RegisterContent() { disabled={isLoading} className="bg-background/50" /> + + {/* Username — lowercase, @ prefix (UI only), live availability check */} +
+ +
+ @ + setUsername(e.target.value.toLowerCase())} + required + disabled={isLoading} + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + aria-invalid={usernameError ? 'true' : 'false'} + className={cn( + 'flex h-11 w-full rounded-lg border border-border bg-background/50 pl-8 pr-11 py-2 text-base text-white placeholder:text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-50 transition-colors', + usernameError && 'border-red-500 focus:border-red-500 focus:ring-red-500', + usernameAvailable && 'border-green-500 focus:border-green-500 focus:ring-green-500', + )} + /> + + {checkingUsername ? ( + + ) : usernameAvailable ? ( + + ) : usernameError ? ( + + ) : null} + +
+ {usernameError ? ( + {usernameError} + ) : ( + + 3-20 characters. Letters, numbers, and underscores only. + + )} + {suggestion && !username && ( + + )} +
+ - + ); +} diff --git a/package.json b/package.json index 0d21f03..6bd19f7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint" }, "dependencies": { - "@samkiel/authsdk": "^1.5.0", + "@samkiel/authsdk": "^1.6.0", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "clsx": "^2.1.1",