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:
@@ -24,6 +24,14 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
# Rotating this key invalidates all existing ciphertexts; re-enter each secret.
|
||||
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=development
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+43
-32
@@ -4,9 +4,12 @@ import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { oauthProviders, anyOAuthProvider } from '@/lib/auth-config'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const oauth = oauthProviders()
|
||||
const showOAuth = anyOAuthProvider(oauth)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -125,39 +128,47 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-[#333]" />
|
||||
<span className="font-mono text-xs text-zinc-600">OR</span>
|
||||
<div className="flex-1 h-px bg-[#333]" />
|
||||
</div>
|
||||
{showOAuth && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-[#333]" />
|
||||
<span className="font-mono text-xs text-zinc-600">OR</span>
|
||||
<div className="flex-1 h-px bg-[#333]" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
disabled={oauthLoading !== null}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#232323] border border-[#333] text-white font-mono text-xs py-2.5 rounded hover:border-[#555] transition disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
{oauthLoading === 'github' ? 'Redirecting...' : 'GitHub'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
disabled={oauthLoading !== null}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#232323] border border-[#333] text-white font-mono text-xs py-2.5 rounded hover:border-[#555] transition disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
{oauthLoading === 'google' ? 'Redirecting...' : 'Google'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{oauth.github && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
disabled={oauthLoading !== null}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#232323] border border-[#333] text-white font-mono text-xs py-2.5 rounded hover:border-[#555] transition disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
{oauthLoading === 'github' ? 'Redirecting...' : 'GitHub'}
|
||||
</button>
|
||||
)}
|
||||
{oauth.google && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
disabled={oauthLoading !== null}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#232323] border border-[#333] text-white font-mono text-xs py-2.5 rounded hover:border-[#555] transition disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
{oauthLoading === 'google' ? 'Redirecting...' : 'Google'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-center font-mono text-xs text-zinc-600 mt-6">
|
||||
no account?{' '}
|
||||
|
||||
@@ -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
|
||||
- `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
|
||||
|
||||
1. A developer pushes to `main`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user