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:
@@ -26,7 +26,7 @@ export default async function MePage() {
|
|||||||
const { data: member } = await supabase
|
const { data: member } = await supabase
|
||||||
.from('space_members')
|
.from('space_members')
|
||||||
.select(
|
.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)
|
.eq('user_id', user.id)
|
||||||
.in('status', ['current', 'unverified', 'late'])
|
.in('status', ['current', 'unverified', 'late'])
|
||||||
@@ -34,6 +34,7 @@ export default async function MePage() {
|
|||||||
if (!member) redirect('/signup')
|
if (!member) redirect('/signup')
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
|
email: (member.email as string | null) ?? '',
|
||||||
display_name: (member.display_name as string | null) ?? '',
|
display_name: (member.display_name as string | null) ?? '',
|
||||||
handle: (member.handle as string | null) ?? '',
|
handle: (member.handle as string | null) ?? '',
|
||||||
phone: (member.phone as string | null) ?? '',
|
phone: (member.phone as string | null) ?? '',
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { updateMyProfile, discloseAffiliations } from '@/lib/actions'
|
import { updateMyProfile, discloseAffiliations, requestEmailChange } from '@/lib/actions'
|
||||||
|
|
||||||
export type ProfileInitial = {
|
export type ProfileInitial = {
|
||||||
|
email: string
|
||||||
display_name: string
|
display_name: string
|
||||||
handle: string
|
handle: string
|
||||||
phone: string
|
phone: string
|
||||||
@@ -28,6 +29,8 @@ const sameList = (a: string[], b: string[]) =>
|
|||||||
export function ProfileForm({ initial }: { initial: ProfileInitial }) {
|
export function ProfileForm({ initial }: { initial: ProfileInitial }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [emailBusy, setEmailBusy] = useState(false)
|
||||||
|
const [newEmail, setNewEmail] = useState('')
|
||||||
const [displayName, setDisplayName] = useState(initial.display_name)
|
const [displayName, setDisplayName] = useState(initial.display_name)
|
||||||
const [handle, setHandle] = useState(initial.handle)
|
const [handle, setHandle] = useState(initial.handle)
|
||||||
const [phone, setPhone] = useState(initial.phone)
|
const [phone, setPhone] = useState(initial.phone)
|
||||||
@@ -71,7 +74,21 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
|
|||||||
router.refresh()
|
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 (
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="bg-card rounded border border-border p-4 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="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -118,5 +135,36 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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="new@email.com"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { createAdminClient } from '@/lib/supabase/admin'
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { linkSubmissionsByEmail } from './forms'
|
import { linkSubmissionsByEmail } from './forms'
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
updateMemberSchema,
|
updateMemberSchema,
|
||||||
updateMyProfileSchema,
|
updateMyProfileSchema,
|
||||||
discloseAffiliationsSchema,
|
discloseAffiliationsSchema,
|
||||||
|
emailChangeSchema,
|
||||||
uuidSchema,
|
uuidSchema,
|
||||||
bulkMemberIdsSchema,
|
bulkMemberIdsSchema,
|
||||||
} from '@/lib/validations'
|
} from '@/lib/validations'
|
||||||
@@ -219,6 +221,40 @@ export async function discloseAffiliations(formData: { affiliations: string[] })
|
|||||||
return { success: true as const }
|
return { success: true as const }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member-initiated change of their own login email. Calls Supabase Auth
|
||||||
|
* updateUser; with "Secure email change" on (recommended) Supabase emails
|
||||||
|
* both the old and new address and the change only applies after both links
|
||||||
|
* are verified at /auth/confirm. The denormalized space_members.email is
|
||||||
|
* synced there, post-verification, never here.
|
||||||
|
*
|
||||||
|
* Requires Supabase project config: the "Change Email Address" template must
|
||||||
|
* link to {origin}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change
|
||||||
|
* and that URL must be in the redirect allowlist (see docs/DEPLOYMENT.md).
|
||||||
|
*/
|
||||||
|
export async function requestEmailChange(input: { email: string }) {
|
||||||
|
const v = parseInput(emailChangeSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
const auth = await requireMember(supabase)
|
||||||
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
|
||||||
|
const h = await headers()
|
||||||
|
const host = h.get('x-forwarded-host') ?? h.get('host')
|
||||||
|
const proto = h.get('x-forwarded-proto') ?? 'https'
|
||||||
|
const origin = host
|
||||||
|
? `${proto}://${host}`
|
||||||
|
: process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.updateUser(
|
||||||
|
{ email: v.data.email },
|
||||||
|
{ emailRedirectTo: `${origin}/auth/confirm` },
|
||||||
|
)
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
return { success: true as const }
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeMember(memberId: string) {
|
export async function removeMember(memberId: string) {
|
||||||
const v = parseInput(uuidSchema, memberId)
|
const v = parseInput(uuidSchema, memberId)
|
||||||
if (!v.ok) return { error: 'Invalid member ID' }
|
if (!v.ok) return { error: 'Invalid member ID' }
|
||||||
|
|||||||
@@ -327,6 +327,10 @@ export const updatePolicyStatusSchema = z.object({
|
|||||||
|
|
||||||
// ─── Member self-profile and COI ─────────────────────────────────────────────
|
// ─── Member self-profile and COI ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const emailChangeSchema = z.object({
|
||||||
|
email: z.string().email('Enter a valid email address').max(254),
|
||||||
|
})
|
||||||
|
|
||||||
export const updateMyProfileSchema = z.object({
|
export const updateMyProfileSchema = z.object({
|
||||||
display_name: z.string().min(1).max(100).optional(),
|
display_name: z.string().min(1).max(100).optional(),
|
||||||
handle: z.string().max(50).optional().nullable(),
|
handle: z.string().max(50).optional().nullable(),
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ const PUBLIC_ROUTES = [
|
|||||||
'/join',
|
'/join',
|
||||||
// Public anonymous-incident status lookup by reporter token.
|
// Public anonymous-incident status lookup by reporter token.
|
||||||
'/track',
|
'/track',
|
||||||
// Only the OAuth callback is anonymous; scope to the exact route so a
|
// Only the OAuth callback + email-change confirm are anonymous (the link
|
||||||
|
// is clicked from an email, no session); scope to exact routes so a
|
||||||
// future /auth/* page is not silently public.
|
// future /auth/* page is not silently public.
|
||||||
'/auth/callback',
|
'/auth/callback',
|
||||||
|
'/auth/confirm',
|
||||||
'/api/health',
|
'/api/health',
|
||||||
// Stripe calls this unauthenticated; it is verified by the per-space
|
// Stripe calls this unauthenticated; it is verified by the per-space
|
||||||
// webhook signature, not a session. Must NOT be redirected to /login
|
// webhook signature, not a session. Must NOT be redirected to /login
|
||||||
|
|||||||
Reference in New Issue
Block a user