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:
+23
@@ -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<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(() => {
|
||||
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 <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 />
|
||||
}
|
||||
|
||||
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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(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() {
|
||||
<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>
|
||||
|
||||
<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' && (
|
||||
<label className="block text-xs font-medium text-slate-600">
|
||||
Username
|
||||
|
||||
Reference in New Issue
Block a user