feat(me): self-service email change, verified (P3e)

requestEmailChange -> supabase.auth.updateUser({email},{emailRedirectTo
/auth/confirm}); with Secure email change on (recommended) Supabase
double-confirms old+new. New /auth/confirm route verifyOtp(email_change)
then syncs the denormalized space_members.email to the authoritative
auth email ONLY post-verification (idempotent). proxy.ts whitelists
/auth/confirm (exact). Profile-tab UI shows current email + change
control. REQUIRES Supabase config (template -> /auth/confirm?token_hash=
{{ .TokenHash }}&type=email_change + redirect allowlist) — documented in
P3f. 521 tests; build clean (flow not exercisable without that config).
This commit is contained in:
Moheeb Zara
2026-05-18 15:53:39 -07:00
parent 2c847befeb
commit 5edd778cf6
6 changed files with 139 additions and 3 deletions
+2 -1
View File
@@ -26,7 +26,7 @@ export default async function MePage() {
const { data: member } = await supabase
.from('space_members')
.select(
'id, space_id, role, display_name, handle, phone, bio, skills, interests, willing_to, affiliations',
'id, space_id, role, email, display_name, handle, phone, bio, skills, interests, willing_to, affiliations',
)
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
@@ -34,6 +34,7 @@ export default async function MePage() {
if (!member) redirect('/signup')
const profile = {
email: (member.email as string | null) ?? '',
display_name: (member.display_name as string | null) ?? '',
handle: (member.handle as string | null) ?? '',
phone: (member.phone as string | null) ?? '',
+49 -1
View File
@@ -7,9 +7,10 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { updateMyProfile, discloseAffiliations } from '@/lib/actions'
import { updateMyProfile, discloseAffiliations, requestEmailChange } from '@/lib/actions'
export type ProfileInitial = {
email: string
display_name: string
handle: string
phone: string
@@ -28,6 +29,8 @@ const sameList = (a: string[], b: string[]) =>
export function ProfileForm({ initial }: { initial: ProfileInitial }) {
const router = useRouter()
const [busy, setBusy] = useState(false)
const [emailBusy, setEmailBusy] = useState(false)
const [newEmail, setNewEmail] = useState('')
const [displayName, setDisplayName] = useState(initial.display_name)
const [handle, setHandle] = useState(initial.handle)
const [phone, setPhone] = useState(initial.phone)
@@ -71,7 +74,21 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
router.refresh()
}
async function changeEmail() {
const e = newEmail.trim()
if (!e) return toast.error('Enter the new email address.')
setEmailBusy(true)
const res = await requestEmailChange({ email: e })
setEmailBusy(false)
if ('error' in res && res.error) return toast.error(res.error)
setNewEmail('')
toast.success(
'Confirmation sent. Check BOTH your current and new email to finish the change.',
)
}
return (
<div className="space-y-4">
<div className="bg-card rounded border border-border p-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
@@ -118,5 +135,36 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
</Button>
</div>
</div>
<div className="bg-card rounded border border-border p-4 space-y-3">
<div>
<p className="font-sans text-sm font-medium text-foreground">Login email</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{initial.email || 'not set'}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="pf-email">New email</Label>
<Input
id="pf-email"
type="email"
value={newEmail}
onChange={e => setNewEmail(e.target.value)}
maxLength={254}
placeholder="[email protected]"
/>
</div>
</div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="font-sans text-xs text-muted-foreground">
You must confirm via links sent to both your current and new address.
</p>
<Button size="sm" variant="outline" disabled={emailBusy} onClick={changeEmail}>
{emailBusy ? 'Sending…' : 'Change email'}
</Button>
</div>
</div>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
// Email-change confirmation landing. Supabase's "Change Email Address"
// template must point here with ?token_hash={{ .TokenHash }}&type=email_change
// (NOT the OAuth code flow — that is /auth/callback). With "Secure email
// change" on (recommended), Supabase emails BOTH the old and new address and
// the change applies to auth.users only after both links are verified; this
// route is hit once per link and is idempotent.
//
// The denormalized space_members.email is synced to the authoritative
// auth.users email ONLY here, after a successful verifyOtp — never at request
// time (the user has not proven ownership yet).
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { NextResponse } from 'next/server'
import type { EmailOtpType } from '@supabase/supabase-js'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const tokenHash = searchParams.get('token_hash')
const type = (searchParams.get('type') ?? 'email_change') as EmailOtpType
if (!tokenHash) {
return NextResponse.redirect(`${origin}/login?error=email_change`)
}
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({ type, token_hash: tokenHash })
if (error) {
return NextResponse.redirect(`${origin}/login?error=email_change`)
}
// Sync the denormalized copy to whatever auth.users now holds. On the first
// of two secure-change links the email may still be the old one (no-op);
// the second link's hit syncs it. Idempotent either way.
const {
data: { user },
} = await supabase.auth.getUser()
if (user?.email) {
await createAdminClient()
.from('space_members')
.update({ email: user.email })
.eq('user_id', user.id)
}
return NextResponse.redirect(`${origin}/me?email=confirmed`)
}