mirror of
https://github.com/khoaliber/LetterFeed.git
synced 2026-03-04 05:39:07 +00:00
feat: authentication
This commit is contained in:
74
frontend/src/contexts/AuthContext.tsx
Normal file
74
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useState, useEffect, ReactNode } from "react"
|
||||
import { getAuthStatus, login as apiLogin, getSettings } from "@/lib/api"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { auth_enabled } = await getAuthStatus();
|
||||
if (!auth_enabled) {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (token) {
|
||||
// If a token exists, verify it by making a protected API call.
|
||||
// getSettings is a good candidate. If it fails with a 401,
|
||||
// the fetcher will remove the token and throw, which we catch here.
|
||||
await getSettings();
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// This will catch errors from getAuthStatus or getSettings.
|
||||
// If it was a 401, the token is already removed by the fetcher.
|
||||
setIsAuthenticated(false);
|
||||
console.error("Authentication check failed", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
await apiLogin(username, password)
|
||||
setIsAuthenticated(true)
|
||||
router.push("/")
|
||||
} catch (error) {
|
||||
console.error("Login failed", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("authToken")
|
||||
setIsAuthenticated(false)
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, login, logout, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
163
frontend/src/contexts/__tests__/AuthContext.test.tsx
Normal file
163
frontend/src/contexts/__tests__/AuthContext.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from "react"
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import "@testing-library/jest-dom"
|
||||
import { AuthProvider, AuthContext } from "@/contexts/AuthContext"
|
||||
import * as api from "@/lib/api"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
jest.mock("@/lib/api")
|
||||
jest.mock("next/navigation", () => ({
|
||||
useRouter: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>
|
||||
const mockedUseRouter = useRouter as jest.Mock
|
||||
|
||||
describe("AuthContext", () => {
|
||||
const push = jest.fn()
|
||||
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockedUseRouter.mockReturnValue({ push })
|
||||
consoleError.mockClear()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it("authenticates if auth is not enabled", async () => {
|
||||
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: false })
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthContext.Consumer>
|
||||
{(value) => (
|
||||
<span>
|
||||
Is Authenticated: {value?.isAuthenticated.toString()}
|
||||
</span>
|
||||
)}
|
||||
</AuthContext.Consumer>
|
||||
</AuthProvider>
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("authenticates if auth is enabled and token is valid", async () => {
|
||||
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
|
||||
mockedApi.getSettings.mockResolvedValue({} as api.Settings) // Mock a successful protected call
|
||||
localStorage.setItem("authToken", "valid-token")
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthContext.Consumer>
|
||||
{(value) => (
|
||||
<span>
|
||||
Is Authenticated: {value?.isAuthenticated.toString()}
|
||||
</span>
|
||||
)}
|
||||
</AuthContext.Consumer>
|
||||
</AuthProvider>
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("does not authenticate if auth is enabled and no token", async () => {
|
||||
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthContext.Consumer>
|
||||
{(value) => (
|
||||
<span>
|
||||
Is Authenticated: {value?.isAuthenticated.toString()}
|
||||
</span>
|
||||
)}
|
||||
</AuthContext.Consumer>
|
||||
</AuthProvider>
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("does not authenticate if token is invalid", async () => {
|
||||
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
|
||||
mockedApi.getSettings.mockRejectedValue(new Error("Invalid token")) // Mock a failed protected call
|
||||
localStorage.setItem("authToken", "invalid-token")
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthContext.Consumer>
|
||||
{(value) => (
|
||||
<span>
|
||||
Is Authenticated: {value?.isAuthenticated.toString()}
|
||||
</span>
|
||||
)}
|
||||
</AuthContext.Consumer>
|
||||
</AuthProvider>
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("login works correctly", async () => {
|
||||
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
|
||||
mockedApi.login.mockResolvedValue()
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthContext.Consumer>
|
||||
{(value) => (
|
||||
<>
|
||||
<span>
|
||||
Is Authenticated: {value?.isAuthenticated.toString()}
|
||||
</span>
|
||||
<button onClick={() => value?.login("testuser", "password")}>Login</button>
|
||||
</>
|
||||
)}
|
||||
</AuthContext.Consumer>
|
||||
</AuthProvider>
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByText("Login"))
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.login).toHaveBeenCalledWith("testuser", "password")
|
||||
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
|
||||
expect(push).toHaveBeenCalledWith("/")
|
||||
})
|
||||
})
|
||||
|
||||
it("logout works correctly", async () => {
|
||||
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
|
||||
mockedApi.getSettings.mockResolvedValue({} as api.Settings)
|
||||
localStorage.setItem("authToken", "valid-token")
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthContext.Consumer>
|
||||
{(value) => (
|
||||
<>
|
||||
<span>
|
||||
Is Authenticated: {value?.isAuthenticated.toString()}
|
||||
</span>
|
||||
<button onClick={() => value?.logout()}>Logout</button>
|
||||
</>
|
||||
)}
|
||||
</AuthContext.Consumer>
|
||||
</AuthProvider>
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByText("Logout"))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument()
|
||||
expect(push).toHaveBeenCalledWith("/login")
|
||||
expect(localStorage.getItem("authToken")).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user