feat(auth): gate OAuth buttons on login behind config flags

GitHub/Google buttons on the login page pointed at providers that may
not be configured in Supabase, dead-ending in an error. Add
NEXT_PUBLIC_OAUTH_GITHUB / NEXT_PUBLIC_OAUTH_GOOGLE (opt-in, "true" to
show); a provider button renders only when enabled and the whole social
block is hidden when neither is. Pure resolveOAuthProviders helper is
unit-tested; flags documented in .env.example and DEPLOYMENT.md.
This commit is contained in:
Moheeb Zara
2026-05-16 18:26:36 -07:00
parent ad477252c8
commit a762fae1f1
5 changed files with 129 additions and 32 deletions
+8
View File
@@ -24,6 +24,14 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
# Rotating this key invalidates all existing ciphertexts; re-enter each secret. # Rotating this key invalidates all existing ciphertexts; re-enter each secret.
SECRETS_ENCRYPTION_KEY= SECRETS_ENCRYPTION_KEY=
# OAuth provider buttons on the login page (optional).
# Set to "true" only for a provider you have actually configured in Supabase
# Auth. Unset/anything-else hides that button (an unconfigured provider button
# just dead-ends in a Supabase error). If neither is "true" the whole
# "OR / social sign-in" block is hidden.
NEXT_PUBLIC_OAUTH_GITHUB=
NEXT_PUBLIC_OAUTH_GOOGLE=
# Node env (optional, defaults to "development" locally) # Node env (optional, defaults to "development" locally)
NODE_ENV=development NODE_ENV=development
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { resolveOAuthProviders, anyOAuthProvider } from '@/lib/auth-config'
describe('resolveOAuthProviders', () => {
it('enables a provider only when its flag is exactly "true"', () => {
expect(resolveOAuthProviders({ github: 'true', google: 'true' })).toEqual({
github: true,
google: true,
})
})
it('treats unset / empty / other values as disabled', () => {
expect(resolveOAuthProviders({})).toEqual({ github: false, google: false })
expect(resolveOAuthProviders({ github: '', google: 'false' })).toEqual({
github: false,
google: false,
})
expect(resolveOAuthProviders({ github: 'TRUE', google: '1' })).toEqual({
github: false,
google: false,
})
})
it('resolves each provider independently', () => {
expect(resolveOAuthProviders({ github: 'true' })).toEqual({
github: true,
google: false,
})
})
})
describe('anyOAuthProvider', () => {
it('is false when no provider is enabled', () => {
expect(anyOAuthProvider({ github: false, google: false })).toBe(false)
})
it('is true when at least one provider is enabled', () => {
expect(anyOAuthProvider({ github: true, google: false })).toBe(true)
expect(anyOAuthProvider({ github: false, google: true })).toBe(true)
})
})
+11
View File
@@ -4,9 +4,12 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { oauthProviders, anyOAuthProvider } from '@/lib/auth-config'
export default function LoginPage() { export default function LoginPage() {
const router = useRouter() const router = useRouter()
const oauth = oauthProviders()
const showOAuth = anyOAuthProvider(oauth)
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -125,6 +128,8 @@ export default function LoginPage() {
</button> </button>
</form> </form>
{showOAuth && (
<>
<div className="flex items-center gap-3 my-5"> <div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-[#333]" /> <div className="flex-1 h-px bg-[#333]" />
<span className="font-mono text-xs text-zinc-600">OR</span> <span className="font-mono text-xs text-zinc-600">OR</span>
@@ -132,6 +137,7 @@ export default function LoginPage() {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
{oauth.github && (
<button <button
type="button" type="button"
onClick={() => handleOAuthLogin('github')} onClick={() => handleOAuthLogin('github')}
@@ -143,6 +149,8 @@ export default function LoginPage() {
</svg> </svg>
{oauthLoading === 'github' ? 'Redirecting...' : 'GitHub'} {oauthLoading === 'github' ? 'Redirecting...' : 'GitHub'}
</button> </button>
)}
{oauth.google && (
<button <button
type="button" type="button"
onClick={() => handleOAuthLogin('google')} onClick={() => handleOAuthLogin('google')}
@@ -157,7 +165,10 @@ export default function LoginPage() {
</svg> </svg>
{oauthLoading === 'google' ? 'Redirecting...' : 'Google'} {oauthLoading === 'google' ? 'Redirecting...' : 'Google'}
</button> </button>
)}
</div> </div>
</>
)}
<p className="text-center font-mono text-xs text-zinc-600 mt-6"> <p className="text-center font-mono text-xs text-zinc-600 mt-6">
no account?{' '} no account?{' '}
+4
View File
@@ -24,6 +24,10 @@ All variables are listed in [.env.example](../.env.example). At minimum:
- `SUPABASE_SERVICE_ROLE_KEY` — service-role JWT, server-side only - `SUPABASE_SERVICE_ROLE_KEY` — service-role JWT, server-side only
- `NEXT_PUBLIC_APP_URL` — public URL of the deployed app, used in OAuth redirects - `NEXT_PUBLIC_APP_URL` — public URL of the deployed app, used in OAuth redirects
Optional:
- `NEXT_PUBLIC_OAUTH_GITHUB` / `NEXT_PUBLIC_OAUTH_GOOGLE` — set to `"true"` only for an OAuth provider you have actually configured in Supabase Auth. The login page hides any provider button that is not enabled, and hides the whole social-sign-in block if neither is set. These are build-time public values, so a change requires a redeploy to take effect.
## How a deploy runs ## How a deploy runs
1. A developer pushes to `main`. 1. A developer pushes to `main`.
+33
View File
@@ -0,0 +1,33 @@
// Which third-party OAuth providers this deployment has wired up in Supabase.
// A provider button that points at an unconfigured provider just dead-ends in
// a Supabase error, so the buttons are opt-in: shown only when the deployer
// explicitly sets the flag. Flags are NEXT_PUBLIC_ because the login page is a
// client component and needs the value in the browser bundle.
export type OAuthProvider = 'github' | 'google'
// Pure core: decide enabled providers from the raw flag strings. Unit-tested.
export function resolveOAuthProviders(flags: {
github?: string
google?: string
}): Record<OAuthProvider, boolean> {
return {
github: flags.github === 'true',
google: flags.google === 'true',
}
}
// Env-reading wrapper. The process.env member expressions are written as
// literals so Next.js statically inlines them into the client bundle.
export function oauthProviders(): Record<OAuthProvider, boolean> {
return resolveOAuthProviders({
github: process.env.NEXT_PUBLIC_OAUTH_GITHUB,
google: process.env.NEXT_PUBLIC_OAUTH_GOOGLE,
})
}
export function anyOAuthProvider(
providers: Record<OAuthProvider, boolean>,
): boolean {
return providers.github || providers.google
}