Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ NEXT_PUBLIC_SENTRY_DSN=
NEXT_SERVER_CSRF_URL=http://backend:8000/csrf/
NEXT_SERVER_DISABLE_SSR=false
NEXT_SERVER_GRAPHQL_URL=http://backend:8000/graphql/
NEXT_SERVER_GITHUB_CLIENT_ID=
NEXT_PUBLIC_GITHUB_CLIENT_ID=
NEXT_SERVER_GITHUB_CLIENT_SECRET=
NEXT_SERVER_NEXTAUTH_SECRET=
NEXTAUTH_SECRET=
29 changes: 3 additions & 26 deletions frontend/__tests__/e2e/pages/Login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mockHomeData } from '@e2e/data/mockHomeData'
import { test, expect } from '@playwright/test'

test.describe('Login Page', () => {
test.describe('LoginPage - Auth Disabled State', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/graphql/', async (route) => {
await route.fulfill({
Expand All @@ -23,31 +23,8 @@ test.describe('Login Page', () => {
])
})

test('displays GitHub login button when unauthenticated', async ({ page }) => {
test('should display auth disabled message if env vars are missing', async ({ page }) => {
await page.goto('/login')

const button = page.getByRole('button', { name: /sign in with github/i })
await expect(button).toBeVisible()
})

test('displays loading spinner when logging in', async ({ page }) => {
await page.goto('/login')

const button = page.getByRole('button', { name: /sign in with github/i })
await button.click()
await expect(page).toHaveURL(/github\.com/)
})
test('shows spinner while loading session', async ({ page }) => {
await page.route('**/api/auth/session', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 500))
await route.fulfill({
status: 200,
body: JSON.stringify({}),
})
})

await page.goto('/login')

await expect(page.getByText(/checking session/i)).toBeVisible()
await expect(page.getByText(/authentication is disabled/i)).toBeVisible()
})
})
35 changes: 30 additions & 5 deletions frontend/__tests__/unit/pages/Login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'
import { useSession, signIn } from 'next-auth/react'
import { render } from 'wrappers/testUtil'
import LoginPage from 'app/login/page'
import { isAuthEnable } from 'utils/constants'

jest.mock('next-auth/react', () => ({
useSession: jest.fn(),
Expand All @@ -18,22 +19,44 @@ jest.mock('@heroui/toast', () => ({
addToast: jest.fn(),
}))

jest.mock('utils/constants', () => ({
isAuthEnable: jest.fn(),
userAuthStatus: {
LOADING: 'loading',
AUTHENTICATED: 'authenticated',
UNAUTHENTICATED: 'unauthenticated',
},
}))

describe('LoginPage', () => {
const pushMock = jest.fn()
const mockIsAuthEnable = isAuthEnable as jest.Mock

beforeEach(() => {
jest.clearAllMocks()
;(useRouter as jest.Mock).mockReturnValue({ push: pushMock })
})

it('renders loading state', () => {
it('shows "Authentication is disabled" if auth is turned off', () => {
mockIsAuthEnable.mockReturnValue(false)
;(useSession as jest.Mock).mockReturnValue({ status: 'unauthenticated' })

render(<LoginPage />)

expect(screen.getByText(/Authentication is disabled/i)).toBeInTheDocument()
})

it('renders loading spinner when session is loading', () => {
mockIsAuthEnable.mockReturnValue(true)
;(useSession as jest.Mock).mockReturnValue({ status: 'loading' })

render(<LoginPage />)

expect(screen.getByText(/Checking session/i)).toBeInTheDocument()
})

it('shows redirect spinner if authenticated and calls router.push and addToast', () => {
it('renders redirect spinner and calls router + toast when authenticated', () => {
mockIsAuthEnable.mockReturnValue(true)
;(useSession as jest.Mock).mockReturnValue({ status: 'authenticated' })

render(<LoginPage />)
Expand All @@ -48,20 +71,22 @@ describe('LoginPage', () => {
expect(pushMock).toHaveBeenCalledWith('/')
})

it('shows login button if unauthenticated', () => {
it('shows login button when unauthenticated and auth enabled', () => {
mockIsAuthEnable.mockReturnValue(true)
;(useSession as jest.Mock).mockReturnValue({ status: 'unauthenticated' })

render(<LoginPage />)

expect(screen.getByText(/Sign in with GitHub/i)).toBeInTheDocument()
})

it('calls signIn on GitHub login button click', () => {
it('triggers signIn when login button clicked', () => {
mockIsAuthEnable.mockReturnValue(true)
;(useSession as jest.Mock).mockReturnValue({ status: 'unauthenticated' })

render(<LoginPage />)

fireEvent.click(screen.getByText(/Sign in with GitHub/i))

expect(signIn).toHaveBeenCalledWith('github', { callbackUrl: '/' })
})
})
23 changes: 17 additions & 6 deletions frontend/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,36 @@ import { gql } from '@apollo/client'
import NextAuth from 'next-auth'
import GitHubProvider from 'next-auth/providers/github'
import { apolloClient } from 'server/apolloClient'
import { isAuthEnable } from 'utils/constants'
import {
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
NEXTAUTH_SECRET,
NEXTAUTH_URL,
} from 'utils/credentials'

const authOptions = {
providers: [
const providers = []

if (isAuthEnable()) {
providers.push(
GitHubProvider({
clientId: GITHUB_CLIENT_ID!,
clientSecret: GITHUB_CLIENT_SECRET!,
}),
],
clientId: GITHUB_CLIENT_ID,
clientSecret: GITHUB_CLIENT_SECRET,
})
)
}

const authOptions = {
providers,
session: {
strategy: 'jwt' as const,
},
callbacks: {
async signIn({ account }) {
if (!isAuthEnable() && account?.provider === 'github') {
return false
}

if (account?.provider === 'github' && account.access_token) {
try {
const { data } = await apolloClient.mutate({
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { addToast } from '@heroui/toast'
import { useRouter } from 'next/navigation'
import { useSession, signIn } from 'next-auth/react'
import { useEffect } from 'react'
import { userAuthStatus } from 'utils/constants'
import { isAuthEnable, userAuthStatus } from 'utils/constants'

export default function LoginPage() {
const { status } = useSession()
Expand All @@ -27,6 +27,14 @@ export default function LoginPage() {
}
}, [status, router])

if (!isAuthEnable()) {
return (
<div className="flex min-h-[80vh] items-center justify-center">
<span className="text-lg text-gray-500">Authentication is disabled</span>
</div>
)
}

if (status === userAuthStatus.LOADING) {
return (
<div className="flex min-h-[80vh] items-center justify-center">
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Skeleton } from '@heroui/react'
import Image from 'next/image'
import { useSession, signIn, signOut } from 'next-auth/react'
import { userAuthStatus } from 'utils/constants'
import { isAuthEnable, userAuthStatus } from 'utils/constants'

export default function UserMenu() {
const { data: session, status } = useSession()
Expand All @@ -15,10 +15,12 @@ export default function UserMenu() {
</div>
)
}
if (!session) {

if (!session || !isAuthEnable()) {
return (
<button
onClick={() => signIn('github', { callbackUrl: '/', prompt: 'login' })}
onClick={() => isAuthEnable() && signIn('github', { callbackUrl: '/', prompt: 'login' })}
disabled={!isAuthEnable()}
className="group relative flex h-10 w-full cursor-pointer items-center justify-center gap-2 overflow-hidden whitespace-pre rounded-md bg-[#87a1bc] px-4 py-2 text-sm font-medium text-black hover:ring-1 hover:ring-[#b0c7de] hover:ring-offset-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 dark:bg-slate-900 dark:text-white dark:hover:bg-slate-900/90 dark:hover:ring-[#46576b] md:flex"
>
Sign in
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { faGithub, faSlack, faBluesky, faLinkedin } from '@fortawesome/free-brands-svg-icons'
import { Link } from 'types/link'
import { Section } from 'types/section'
import { GITHUB_CLIENT_ID } from 'utils/credentials'

export const headerLinks: Link[] = [
{
Expand Down Expand Up @@ -123,3 +124,7 @@ export const userAuthStatus = {
LOADING: 'loading',
UNAUTHENTICATED: 'unauthenticated',
}

export const isAuthEnable = () => {
return Boolean(GITHUB_CLIENT_ID)
}
4 changes: 2 additions & 2 deletions frontend/src/utils/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ export const API_URL = process.env.NEXT_PUBLIC_API_URL
export const CSRF_URL = process.env.NEXT_PUBLIC_CSRF_URL
export const ENVIRONMENT = process.env.NEXT_PUBLIC_ENVIRONMENT
export const GRAPHQL_URL = process.env.NEXT_PUBLIC_GRAPHQL_URL
export const GITHUB_CLIENT_ID = process.env.NEXT_SERVER_GITHUB_CLIENT_ID
export const GITHUB_CLIENT_ID = process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID
export const GITHUB_CLIENT_SECRET = process.env.NEXT_SERVER_GITHUB_CLIENT_SECRET
export const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID
export const IDX_URL = process.env.NEXT_PUBLIC_IDX_URL
export const RELEASE_VERSION = process.env.NEXT_PUBLIC_RELEASE_VERSION
export const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN
export const NEXTAUTH_SECRET = process.env.NEXT_SERVER_NEXTAUTH_SECRET
export const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET
export const NEXTAUTH_URL = process.env.NEXT_SERVER_NEXTAUTH_URL