feat: authentication

This commit is contained in:
Leon
2025-07-19 10:12:11 +02:00
parent 95170e7201
commit 6f7503039d
57 changed files with 1405 additions and 244 deletions

View 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>
)
}

View 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()
})
})
})