+ 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