diff --git a/app/(app)/me/page.tsx b/app/(app)/me/page.tsx index 18208d0..a56d8a4 100644 --- a/app/(app)/me/page.tsx +++ b/app/(app)/me/page.tsx @@ -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) ?? '', diff --git a/app/(app)/me/profile-form.tsx b/app/(app)/me/profile-form.tsx index 72fdcbb..5557f7a 100644 --- a/app/(app)/me/profile-form.tsx +++ b/app/(app)/me/profile-form.tsx @@ -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 ( +
@@ -118,5 +135,36 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
+ +
+
+

Login email

+

+ {initial.email || 'not set'} +

+
+
+
+ + setNewEmail(e.target.value)} + maxLength={254} + placeholder="new@email.com" + /> +
+
+
+

+ You must confirm via links sent to both your current and new address. +

+ +
+
+
) } diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts new file mode 100644 index 0000000..eeb3cbd --- /dev/null +++ b/app/auth/confirm/route.ts @@ -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`) +} diff --git a/lib/actions/members.ts b/lib/actions/members.ts index 410de3d..a6f3a06 100644 --- a/lib/actions/members.ts +++ b/lib/actions/members.ts @@ -1,6 +1,7 @@ 'use server' import { revalidatePath } from 'next/cache' +import { headers } from 'next/headers' import { createClient } from '@/lib/supabase/server' import { createAdminClient } from '@/lib/supabase/admin' import { linkSubmissionsByEmail } from './forms' @@ -16,6 +17,7 @@ import { updateMemberSchema, updateMyProfileSchema, discloseAffiliationsSchema, + emailChangeSchema, uuidSchema, bulkMemberIdsSchema, } from '@/lib/validations' @@ -219,6 +221,40 @@ export async function discloseAffiliations(formData: { affiliations: string[] }) 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) { const v = parseInput(uuidSchema, memberId) if (!v.ok) return { error: 'Invalid member ID' } diff --git a/lib/validations.ts b/lib/validations.ts index 5b62af2..bbcdce3 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -327,6 +327,10 @@ export const updatePolicyStatusSchema = z.object({ // ─── 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({ display_name: z.string().min(1).max(100).optional(), handle: z.string().max(50).optional().nullable(), diff --git a/proxy.ts b/proxy.ts index c83f493..7f1ec2a 100644 --- a/proxy.ts +++ b/proxy.ts @@ -14,9 +14,11 @@ const PUBLIC_ROUTES = [ '/join', // Public anonymous-incident status lookup by reporter token. '/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. '/auth/callback', + '/auth/confirm', '/api/health', // Stripe calls this unauthenticated; it is verified by the per-space // webhook signature, not a session. Must NOT be redirected to /login