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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-06 23:28:25 -05:00
co-authored by Claude Sonnet 5
parent 5e8b09f6e2
commit 027455cc7d
6 changed files with 167 additions and 9 deletions
+7
View File
@@ -3,3 +3,10 @@
# they're stable, well-known local dev credentials, not secrets. # they're stable, well-known local dev credentials, not secrets.
VITE_SUPABASE_URL=http://127.0.0.1:54321 VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY= 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=
+23
View File
@@ -1,5 +1,6 @@
import type { Session } from '@supabase/supabase-js' import type { Session } from '@supabase/supabase-js'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import CompleteProfileScreen from './components/auth/CompleteProfileScreen'
import LoginScreen from './components/auth/LoginScreen' import LoginScreen from './components/auth/LoginScreen'
import AppShell from './components/layout/AppShell' import AppShell from './components/layout/AppShell'
import { supabase } from './data/supabaseClient' import { supabase } from './data/supabaseClient'
@@ -7,6 +8,8 @@ import { supabase } from './data/supabaseClient'
export default function App() { export default function App() {
// undefined = still checking for an existing session; null = signed out. // undefined = still checking for an existing session; null = signed out.
const [session, setSession] = useState<Session | null | undefined>(undefined) const [session, setSession] = useState<Session | null | undefined>(undefined)
// undefined = haven't checked yet; null = has one; true = needs to set one.
const [needsUsername, setNeedsUsername] = useState<boolean | null | undefined>(undefined)
useEffect(() => { useEffect(() => {
supabase.auth.getSession().then(({ data }) => setSession(data.session)) supabase.auth.getSession().then(({ data }) => setSession(data.session))
@@ -16,6 +19,18 @@ export default function App() {
return () => subscription.unsubscribe() 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) { if (session === undefined) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div> return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
} }
@@ -24,5 +39,13 @@ export default function App() {
return <LoginScreen /> return <LoginScreen />
} }
if (needsUsername === undefined) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
}
if (needsUsername) {
return <CompleteProfileScreen onDone={() => setNeedsUsername(false)} />
}
return <AppShell /> return <AppShell />
} }
@@ -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<string | null>(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 (
<div className="flex h-screen items-center justify-center bg-slate-50">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-sm font-semibold text-indigo-700">One more thing</h1>
<p className="mt-1 text-xs text-slate-500">Choose a username to finish setting up your account.</p>
<label className="mt-4 block text-xs font-medium text-slate-600">
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
{error && <p className="mt-3 text-xs text-red-600">{error}</p>}
<button
type="submit"
disabled={loading || !username.trim()}
className="mt-4 w-full rounded bg-indigo-600 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Saving…' : 'Continue'}
</button>
</form>
</div>
)
}
+44 -9
View File
@@ -2,14 +2,13 @@ import { useState } from 'react'
import { supabase } from '../../data/supabaseClient' import { supabase } from '../../data/supabaseClient'
/** /**
* Bare-bones email/password auth gate — just enough for a real signed-in * Auth gate: Google SSO (per organized-ideas.md §2 — no username prompt
* session to exist so Supabase RLS (which requires auth.uid()) has someone * here, since Google doesn't let us collect one before the redirect; a
* to authorize. This is NOT the Phase 2 auth experience from * first-time Google sign-in lands with no username and App.tsx routes them
* organized-ideas.md §2 (Google SSO, polished signup, hard email-verification * to CompleteProfileScreen to set one) or manual email/password signup
* UX) — it's the minimum viable version of the same schema (username + * (username collected up front here instead, email pulled from the form).
* email + password) so the backend foundation is actually usable end to end * Manual signup respects the hard email-verification gate from
* before that phase exists. Confirmation email respects the same * supabase/config.toml — sign-in is blocked until confirmed.
* hard-verification-gate config as Phase 2 will (supabase/config.toml).
*/ */
export default function LoginScreen() { export default function LoginScreen() {
const [mode, setMode] = useState<'sign-in' | 'sign-up'>('sign-in') const [mode, setMode] = useState<'sign-in' | 'sign-up'>('sign-in')
@@ -20,6 +19,21 @@ export default function LoginScreen() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [confirmSent, setConfirmSent] = useState(false) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
@@ -80,7 +94,28 @@ export default function LoginScreen() {
<h1 className="text-sm font-semibold text-indigo-700">AV Planner</h1> <h1 className="text-sm font-semibold text-indigo-700">AV Planner</h1>
<p className="mt-1 text-xs text-slate-500">{mode === 'sign-in' ? 'Sign in' : 'Create an account'}</p> <p className="mt-1 text-xs text-slate-500">{mode === 'sign-in' ? 'Sign in' : 'Create an account'}</p>
<div className="mt-4 space-y-3"> <button
type="button"
onClick={handleGoogleSignIn}
disabled={loading}
className="mt-4 flex w-full items-center justify-center gap-2 rounded border border-slate-300 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg viewBox="0 0 18 18" className="h-4 w-4" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.9c1.7-1.57 2.7-3.88 2.7-6.62z" />
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.9-2.26c-.8.54-1.83.86-3.06.86-2.35 0-4.34-1.59-5.05-3.72H.96v2.33A9 9 0 0 0 9 18z" />
<path fill="#FBBC05" d="M3.95 10.7A5.4 5.4 0 0 1 3.67 9c0-.59.1-1.17.28-1.7V4.96H.96A9 9 0 0 0 0 9c0 1.45.35 2.83.96 4.04z" />
<path fill="#EA4335" d="M9 3.58c1.32 0 2.51.46 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.96L3.95 7.3C4.66 5.17 6.65 3.58 9 3.58z" />
</svg>
Continue with Google
</button>
<div className="my-4 flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-400">
<div className="h-px flex-1 bg-slate-200" />
or
<div className="h-px flex-1 bg-slate-200" />
</div>
<div className="space-y-3">
{mode === 'sign-up' && ( {mode === 'sign-up' && (
<label className="block text-xs font-medium text-slate-600"> <label className="block text-xs font-medium text-slate-600">
Username Username
+18
View File
@@ -335,6 +335,24 @@ skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. # If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false email_optional = false
# Google SSO (organized-ideas.md §2: username collected up front for manual
# signup, email pulled from Google for this path). Client id/secret come
# from your own Google Cloud OAuth credentials via env var substitution —
# see README/setup notes for how to create them. The CLI auto-loads
# .env/.env.local from the repo root, so set these there, never here.
[auth.external.google]
enabled = true
client_id = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID)"
secret = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET)"
redirect_uri = ""
url = ""
# Required for local sign-in with Google per Supabase's own guidance — the
# local GoTrue instance can't satisfy the nonce check the way a deployed
# instance with a stable public URL can. Revisit (set false) once this is
# ever pointed at a real deployed URL instead of localhost.
skip_nonce_check = true
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. # Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. # You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana] [auth.web3.solana]
@@ -0,0 +1,8 @@
-- Google SSO can't collect a username before the OAuth redirect happens
-- (unlike email/password signup, where we pass it as signup metadata) --
-- so a first-time Google sign-in's profile row is created with no username,
-- and the app prompts for one right after (a "complete your profile" gate,
-- the same hard-gate spirit as email verification). The uniqueness
-- constraint still holds; Postgres treats multiple NULLs as distinct, so
-- several pending Google sign-ins can coexist before choosing a username.
alter table public.profiles alter column username drop not null;