fix: auth disabled routing and UI

This commit is contained in:
Leon
2025-07-19 10:42:11 +02:00
parent d267c7271b
commit ab45139e7e
6 changed files with 111 additions and 24 deletions

View File

@@ -3,27 +3,38 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import "@testing-library/jest-dom" import "@testing-library/jest-dom"
import LoginPage from "@/app/login/page" import LoginPage from "@/app/login/page"
import { useAuth } from "@/hooks/useAuth" import { useAuth } from "@/hooks/useAuth"
import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
// Mock the necessary hooks and modules
jest.mock("@/hooks/useAuth") jest.mock("@/hooks/useAuth")
jest.mock("sonner", () => ({ jest.mock("sonner", () => ({
toast: { toast: {
error: jest.fn(), error: jest.fn(),
}, },
})) }))
jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
}))
const mockedUseAuth = useAuth as jest.Mock const mockedUseAuth = useAuth as jest.Mock
const mockedToast = toast as jest.Mocked<typeof toast> const mockedToast = toast as jest.Mocked<typeof toast>
const mockedUseRouter = useRouter as jest.Mock
describe("LoginPage", () => { describe("LoginPage", () => {
const login = jest.fn() const login = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
mockedUseAuth.mockReturnValue({ login }) // Default mock for useAuth
mockedUseAuth.mockReturnValue({
login,
isAuthenticated: false,
isLoading: false,
})
}) })
it("renders the login page", () => { it("renders the login page when not authenticated", () => {
render(<LoginPage />) render(<LoginPage />)
expect(screen.getByText("LetterFeed")).toBeInTheDocument() expect(screen.getByText("LetterFeed")).toBeInTheDocument()
expect(screen.getByLabelText("Username")).toBeInTheDocument() expect(screen.getByLabelText("Username")).toBeInTheDocument()
@@ -31,6 +42,28 @@ describe("LoginPage", () => {
expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument() expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument()
}) })
it("shows loading spinner when isLoading is true", () => {
mockedUseAuth.mockReturnValue({
login,
isAuthenticated: false,
isLoading: true,
})
render(<LoginPage />)
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument()
})
it("redirects when authenticated", () => {
const push = jest.fn()
mockedUseRouter.mockReturnValue({ push })
mockedUseAuth.mockReturnValue({
login,
isAuthenticated: true,
isLoading: false,
})
render(<LoginPage />)
expect(push).toHaveBeenCalledWith("/")
})
it("allows typing in the username and password fields", () => { it("allows typing in the username and password fields", () => {
render(<LoginPage />) render(<LoginPage />)
const usernameInput = screen.getByLabelText("Username") const usernameInput = screen.getByLabelText("Username")

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { useState } from "react" import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -9,11 +9,20 @@ import { Label } from "@/components/ui/label"
import Image from "next/image" import Image from "next/image"
import { useAuth } from "@/hooks/useAuth" import { useAuth } from "@/hooks/useAuth"
import { toast } from "sonner" import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { LoadingSpinner } from "@/components/letterfeed/LoadingSpinner"
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const { login } = useAuth() const { login, isAuthenticated, isLoading } = useAuth()
const router = useRouter()
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push("/")
}
}, [isLoading, isAuthenticated, router])
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -25,11 +34,15 @@ export default function LoginPage() {
try { try {
await login(username, password) await login(username, password)
} catch { } catch {
// The error is already toasted by the API layer, // The error is already toasted by the API layer,
} }
} }
if (isLoading || (!isLoading && isAuthenticated)) {
return <LoadingSpinner />
}
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center"> <div className="min-h-screen bg-background flex items-center justify-center">
<div className="max-w-md w-full space-y-8 p-8"> <div className="max-w-md w-full space-y-8 p-8">

View File

@@ -13,7 +13,7 @@ interface HeaderProps {
} }
export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) { export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
const { logout } = useAuth() const { logout, isAuthEnabled } = useAuth()
const handleProcessEmails = async () => { const handleProcessEmails = async () => {
try { try {
await processEmails() await processEmails()
@@ -62,10 +62,12 @@ export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
Settings Settings
</Button> </Button>
<Button variant="outline" onClick={logout}> {isAuthEnabled && (
<LogOut className="w-4 h-4 mr-2" /> <Button variant="outline" onClick={logout}>
Logout <LogOut className="w-4 h-4 mr-2" />
</Button> Logout
</Button>
)}
</div> </div>
</div> </div>
) )

View File

@@ -4,6 +4,7 @@ import { Toaster } from "@/components/ui/sonner"
import { toast } from "sonner" import { toast } from "sonner"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { AuthProvider } from "@/contexts/AuthContext" import { AuthProvider } from "@/contexts/AuthContext"
import { useAuth } from "@/hooks/useAuth"
jest.mock("@/lib/api") jest.mock("@/lib/api")
jest.mock("next/navigation", () => ({ jest.mock("next/navigation", () => ({
@@ -11,7 +12,10 @@ jest.mock("next/navigation", () => ({
push: jest.fn(), push: jest.fn(),
}), }),
})) }))
jest.mock("@/hooks/useAuth")
const mockedApi = api as jest.Mocked<typeof api> const mockedApi = api as jest.Mocked<typeof api>
const mockedUseAuth = useAuth as jest.Mock
// Mock the toast functions // Mock the toast functions
jest.mock("sonner", () => { jest.mock("sonner", () => {
@@ -28,6 +32,7 @@ jest.mock("sonner", () => {
describe("Header", () => { describe("Header", () => {
const onOpenAddNewsletter = jest.fn() const onOpenAddNewsletter = jest.fn()
const onOpenSettings = jest.fn() const onOpenSettings = jest.fn()
const logout = jest.fn()
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}) const consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
const renderWithAuthProvider = (component: React.ReactElement) => { const renderWithAuthProvider = (component: React.ReactElement) => {
@@ -37,14 +42,18 @@ describe("Header", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
consoleError.mockClear() consoleError.mockClear()
mockedUseAuth.mockReturnValue({
logout,
isAuthEnabled: true, // Default to auth being enabled for most tests
})
}) })
afterAll(() => { afterAll(() => {
consoleError.mockRestore() consoleError.mockRestore()
}) })
it("renders the header with title and buttons", () => { it("renders the header with title and buttons including logout", () => {
renderWithAuthProvider( render(
<Header <Header
onOpenAddNewsletter={onOpenAddNewsletter} onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
@@ -54,10 +63,25 @@ describe("Header", () => {
expect(screen.getByText("Add Newsletter")).toBeInTheDocument() expect(screen.getByText("Add Newsletter")).toBeInTheDocument()
expect(screen.getByText("Settings")).toBeInTheDocument() expect(screen.getByText("Settings")).toBeInTheDocument()
expect(screen.getByText("Process Now")).toBeInTheDocument() expect(screen.getByText("Process Now")).toBeInTheDocument()
expect(screen.getByText("Logout")).toBeInTheDocument()
})
it("does not render the logout button if auth is disabled", () => {
mockedUseAuth.mockReturnValue({
logout,
isAuthEnabled: false,
})
render(
<Header
onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings}
/>
)
expect(screen.queryByText("Logout")).not.toBeInTheDocument()
}) })
it('calls onOpenAddNewsletter when "Add Newsletter" button is clicked', () => { it('calls onOpenAddNewsletter when "Add Newsletter" button is clicked', () => {
renderWithAuthProvider( render(
<Header <Header
onOpenAddNewsletter={onOpenAddNewsletter} onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
@@ -68,7 +92,7 @@ describe("Header", () => {
}) })
it('calls onOpenSettings when "Settings" button is clicked', () => { it('calls onOpenSettings when "Settings" button is clicked', () => {
renderWithAuthProvider( render(
<Header <Header
onOpenAddNewsletter={onOpenAddNewsletter} onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
@@ -81,7 +105,7 @@ describe("Header", () => {
it('calls the process emails API when "Process Now" button is clicked and shows success toast', async () => { it('calls the process emails API when "Process Now" button is clicked and shows success toast', async () => {
mockedApi.processEmails.mockResolvedValue({ message: "Success" }) mockedApi.processEmails.mockResolvedValue({ message: "Success" })
renderWithAuthProvider( render(
<> <>
<Header <Header
onOpenAddNewsletter={onOpenAddNewsletter} onOpenAddNewsletter={onOpenAddNewsletter}
@@ -107,7 +131,7 @@ describe("Header", () => {
it("shows an error toast if the process emails API call fails", async () => { it("shows an error toast if the process emails API call fails", async () => {
mockedApi.processEmails.mockRejectedValue(new Error("Failed to process")) mockedApi.processEmails.mockRejectedValue(new Error("Failed to process"))
renderWithAuthProvider( render(
<> <>
<Header <Header
onOpenAddNewsletter={onOpenAddNewsletter} onOpenAddNewsletter={onOpenAddNewsletter}

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"
interface AuthContextType { interface AuthContextType {
isAuthenticated: boolean isAuthenticated: boolean
isAuthEnabled: boolean
login: (username: string, password: string) => Promise<void> login: (username: string, password: string) => Promise<void>
logout: () => void logout: () => void
isLoading: boolean isLoading: boolean
@@ -15,6 +16,7 @@ export const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthProvider = ({ children }: { children: ReactNode }) => { export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isAuthEnabled, setIsAuthEnabled] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const router = useRouter() const router = useRouter()
@@ -23,6 +25,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
setIsLoading(true); setIsLoading(true);
try { try {
const { auth_enabled } = await getAuthStatus(); const { auth_enabled } = await getAuthStatus();
setIsAuthEnabled(auth_enabled);
if (!auth_enabled) { if (!auth_enabled) {
setIsAuthenticated(true); setIsAuthenticated(true);
} else { } else {
@@ -67,7 +70,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
} }
return ( return (
<AuthContext.Provider value={{ isAuthenticated, login, logout, isLoading }}> <AuthContext.Provider value={{ isAuthenticated, isAuthEnabled, login, logout, isLoading }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -28,21 +28,27 @@ describe("AuthContext", () => {
consoleError.mockRestore() consoleError.mockRestore()
}) })
it("authenticates if auth is not enabled", async () => { it("authenticates and sets auth enabled to false if auth is not enabled on server", async () => {
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: false }) mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: false })
render( render(
<AuthProvider> <AuthProvider>
<AuthContext.Consumer> <AuthContext.Consumer>
{(value) => ( {(value) => (
<span> <div>
Is Authenticated: {value?.isAuthenticated.toString()} <span>
</span> Is Authenticated: {value?.isAuthenticated.toString()}
</span>
<span>
Is Auth Enabled: {value?.isAuthEnabled.toString()}
</span>
</div>
)} )}
</AuthContext.Consumer> </AuthContext.Consumer>
</AuthProvider> </AuthProvider>
) )
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument() expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
expect(screen.getByText("Is Auth Enabled: false")).toBeInTheDocument()
}) })
}) })
@@ -54,15 +60,21 @@ describe("AuthContext", () => {
<AuthProvider> <AuthProvider>
<AuthContext.Consumer> <AuthContext.Consumer>
{(value) => ( {(value) => (
<span> <div>
Is Authenticated: {value?.isAuthenticated.toString()} <span>
</span> Is Authenticated: {value?.isAuthenticated.toString()}
</span>
<span>
Is Auth Enabled: {value?.isAuthEnabled.toString()}
</span>
</div>
)} )}
</AuthContext.Consumer> </AuthContext.Consumer>
</AuthProvider> </AuthProvider>
) )
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument() expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
expect(screen.getByText("Is Auth Enabled: true")).toBeInTheDocument()
}) })
}) })