From 027455cc7de68094cf65e232aca84de1085822c3 Mon Sep 17 00:00:00 2001 From: aarbit Date: Sun, 6 Sep 2026 23:28:25 -0500 Subject: [PATCH] Add Google SSO - Migration: profiles.username is now nullable -- Google's OAuth redirect can't collect a username up front the way the email/password signup form does, so a first-time Google sign-in's profile is created with no username. - supabase/config.toml: [auth.external.google] enabled, credentials via env() substitution (SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID/_SECRET in .env.local, which the CLI auto-loads). skip_nonce_check is on, which Supabase's own docs call out as required for local sign-in. - LoginScreen: "Continue with Google" alongside the existing email/password form. - CompleteProfileScreen: one-time gate for a signed-in user with no username yet (i.e. first Google sign-in) -- same hard-gate spirit as email verification, nothing else is usable until a username is set. - App.tsx now checks profiles.username after establishing a session and routes to CompleteProfileScreen before AppShell when it's missing. RLS test suite re-run clean (23/23) after the schema change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x --- .env.example | 7 ++ src/App.tsx | 23 +++++++ src/components/auth/CompleteProfileScreen.tsx | 67 +++++++++++++++++++ src/components/auth/LoginScreen.tsx | 53 ++++++++++++--- supabase/config.toml | 18 +++++ ...07040804_allow_null_username_for_oauth.sql | 8 +++ 6 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 src/components/auth/CompleteProfileScreen.tsx create mode 100644 supabase/migrations/20260907040804_allow_null_username_for_oauth.sql diff --git a/.env.example b/.env.example index c94e5be..5e78b5d 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,10 @@ # they're stable, well-known local dev credentials, not secrets. VITE_SUPABASE_URL=http://127.0.0.1:54321 VITE_SUPABASE_ANON_KEY= + +# Google OAuth credentials (real secrets — from your own Google Cloud +# project, never commit real values). Read by the Supabase CLI itself for +# supabase/config.toml's env() substitution, not by the frontend. +# Authorized redirect URI to set in Google Cloud: http://127.0.0.1:54321/auth/v1/callback +SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID= +SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET= diff --git a/src/App.tsx b/src/App.tsx index 7c42032..9d31d4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import type { Session } from '@supabase/supabase-js' import { useEffect, useState } from 'react' +import CompleteProfileScreen from './components/auth/CompleteProfileScreen' import LoginScreen from './components/auth/LoginScreen' import AppShell from './components/layout/AppShell' import { supabase } from './data/supabaseClient' @@ -7,6 +8,8 @@ import { supabase } from './data/supabaseClient' export default function App() { // undefined = still checking for an existing session; null = signed out. const [session, setSession] = useState(undefined) + // undefined = haven't checked yet; null = has one; true = needs to set one. + const [needsUsername, setNeedsUsername] = useState(undefined) useEffect(() => { supabase.auth.getSession().then(({ data }) => setSession(data.session)) @@ -16,6 +19,18 @@ export default function App() { return () => subscription.unsubscribe() }, []) + useEffect(() => { + // Nothing to check while signed out — the render logic below never + // reads needsUsername in that case anyway. + if (!session) return + supabase + .from('profiles') + .select('username') + .eq('id', session.user.id) + .single() + .then(({ data }) => setNeedsUsername(!data?.username)) + }, [session]) + if (session === undefined) { return
Loading…
} @@ -24,5 +39,13 @@ export default function App() { return } + if (needsUsername === undefined) { + return
Loading…
+ } + + if (needsUsername) { + return setNeedsUsername(false)} /> + } + return } diff --git a/src/components/auth/CompleteProfileScreen.tsx b/src/components/auth/CompleteProfileScreen.tsx new file mode 100644 index 0000000..85b5eab --- /dev/null +++ b/src/components/auth/CompleteProfileScreen.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { supabase } from '../../data/supabaseClient' + +/** + * Shown when a signed-in user's profile has no username yet — the only way + * that happens today is a first-time Google sign-in, since Google doesn't + * let us collect one before the OAuth redirect the way the email/password + * form does up front. One-time gate, same hard-gate spirit as email + * verification: nothing else in the app is usable until this is set. + */ +export default function CompleteProfileScreen({ onDone }: { onDone: () => void }) { + const [username, setUsername] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + try { + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) throw new Error('Not signed in.') + + const { error } = await supabase.from('profiles').update({ username: username.trim() }).eq('id', user.id) + if (error) { + throw error.code === '23505' ? new Error('That username is already taken.') : error + } + onDone() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+
+

One more thing

+

Choose a username to finish setting up your account.

+ + + + {error &&

{error}

} + + +
+
+ ) +} diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 9317dfd..45ec20a 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -2,14 +2,13 @@ import { useState } from 'react' import { supabase } from '../../data/supabaseClient' /** - * Bare-bones email/password auth gate — just enough for a real signed-in - * session to exist so Supabase RLS (which requires auth.uid()) has someone - * to authorize. This is NOT the Phase 2 auth experience from - * organized-ideas.md §2 (Google SSO, polished signup, hard email-verification - * UX) — it's the minimum viable version of the same schema (username + - * email + password) so the backend foundation is actually usable end to end - * before that phase exists. Confirmation email respects the same - * hard-verification-gate config as Phase 2 will (supabase/config.toml). + * Auth gate: Google SSO (per organized-ideas.md §2 — no username prompt + * here, since Google doesn't let us collect one before the redirect; a + * first-time Google sign-in lands with no username and App.tsx routes them + * to CompleteProfileScreen to set one) or manual email/password signup + * (username collected up front here instead, email pulled from the form). + * Manual signup respects the hard email-verification gate from + * supabase/config.toml — sign-in is blocked until confirmed. */ export default function LoginScreen() { const [mode, setMode] = useState<'sign-in' | 'sign-up'>('sign-in') @@ -20,6 +19,21 @@ export default function LoginScreen() { const [error, setError] = useState(null) const [confirmSent, setConfirmSent] = useState(false) + const handleGoogleSignIn = async () => { + setError(null) + setLoading(true) + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: window.location.origin }, + }) + // On success this redirects the whole page to Google, so we only ever + // reach here if kicking off the redirect itself failed. + if (error) { + setError(error.message) + setLoading(false) + } + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) @@ -80,7 +94,28 @@ export default function LoginScreen() {

AV Planner

{mode === 'sign-in' ? 'Sign in' : 'Create an account'}

-
+ + +
+
+ or +
+
+ +
{mode === 'sign-up' && (