diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-06 18:30:04 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-06 18:30:04 +0900 |
| commit | 3923eb2f86c304bbd90c4eae9a338f7bc21c9e90 (patch) | |
| tree | afa2a4053cc91eb8379e26a08538cfd58c1479bc /src/client | |
| parent | e367c698e03c41c292c3dd5c07bad0a870c3ebc4 (diff) | |
| download | kioku-3923eb2f86c304bbd90c4eae9a338f7bc21c9e90.tar.gz kioku-3923eb2f86c304bbd90c4eae9a338f7bc21c9e90.tar.zst kioku-3923eb2f86c304bbd90c4eae9a338f7bc21c9e90.zip | |
feat(client): add auth store with React context
Implements token management via AuthProvider context that wraps the app.
Provides useAuth hook for components to access auth state and actions
(login, register, logout). Includes comprehensive tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/main.tsx | 5 | ||||
| -rw-r--r-- | src/client/stores/auth.test.tsx | 205 | ||||
| -rw-r--r-- | src/client/stores/auth.tsx | 94 | ||||
| -rw-r--r-- | src/client/stores/index.ts | 7 |
4 files changed, 310 insertions, 1 deletions
diff --git a/src/client/main.tsx b/src/client/main.tsx index 1e185be..5a12f68 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { AuthProvider } from "./stores"; const rootElement = document.getElementById("root"); if (!rootElement) { @@ -9,6 +10,8 @@ if (!rootElement) { createRoot(rootElement).render( <StrictMode> - <App /> + <AuthProvider> + <App /> + </AuthProvider> </StrictMode>, ); diff --git a/src/client/stores/auth.test.tsx b/src/client/stores/auth.test.tsx new file mode 100644 index 0000000..ab6b554 --- /dev/null +++ b/src/client/stores/auth.test.tsx @@ -0,0 +1,205 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClient } from "../api/client"; +import { AuthProvider, useAuth } from "./auth"; + +// Mock the apiClient +vi.mock("../api/client", () => ({ + apiClient: { + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + isAuthenticated: vi.fn(), + getTokens: vi.fn(), + }, + ApiClientError: class ApiClientError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + ) { + super(message); + this.name = "ApiClientError"; + } + }, +})); + +const wrapper = ({ children }: { children: ReactNode }) => ( + <AuthProvider>{children}</AuthProvider> +); + +describe("useAuth", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.getTokens).mockReturnValue(null); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("throws error when used outside AuthProvider", () => { + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => { + renderHook(() => useAuth()); + }).toThrow("useAuth must be used within an AuthProvider"); + + consoleSpy.mockRestore(); + }); + + it("returns initial unauthenticated state", async () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.user).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + }); + + it("returns authenticated state when tokens exist", async () => { + vi.mocked(apiClient.getTokens).mockReturnValue({ + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isAuthenticated).toBe(true); + }); + + describe("login", () => { + it("logs in and sets user", async () => { + const mockUser = { id: "user-1", username: "testuser" }; + vi.mocked(apiClient.login).mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + user: mockUser, + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.login("testuser", "password123"); + }); + + expect(apiClient.login).toHaveBeenCalledWith("testuser", "password123"); + expect(result.current.user).toEqual(mockUser); + }); + + it("propagates login errors", async () => { + vi.mocked(apiClient.login).mockRejectedValue( + new Error("Invalid credentials"), + ); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect( + act(async () => { + await result.current.login("testuser", "wrongpassword"); + }), + ).rejects.toThrow("Invalid credentials"); + }); + }); + + describe("register", () => { + it("registers and logs in automatically", async () => { + const mockUser = { id: "user-1", username: "newuser" }; + vi.mocked(apiClient.register).mockResolvedValue({ user: mockUser }); + vi.mocked(apiClient.login).mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + user: mockUser, + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.register("newuser", "password123"); + }); + + expect(apiClient.register).toHaveBeenCalledWith("newuser", "password123"); + expect(apiClient.login).toHaveBeenCalledWith("newuser", "password123"); + expect(result.current.user).toEqual(mockUser); + }); + + it("propagates registration errors", async () => { + vi.mocked(apiClient.register).mockRejectedValue( + new Error("Username taken"), + ); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect( + act(async () => { + await result.current.register("existinguser", "password123"); + }), + ).rejects.toThrow("Username taken"); + }); + }); + + describe("logout", () => { + it("logs out and clears user", async () => { + const mockUser = { id: "user-1", username: "testuser" }; + vi.mocked(apiClient.login).mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + user: mockUser, + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Login first + await act(async () => { + await result.current.login("testuser", "password123"); + }); + + expect(result.current.user).toEqual(mockUser); + + // Now logout + vi.mocked(apiClient.isAuthenticated).mockReturnValue(false); + act(() => { + result.current.logout(); + }); + + expect(apiClient.logout).toHaveBeenCalled(); + expect(result.current.user).toBeNull(); + }); + }); +}); diff --git a/src/client/stores/auth.tsx b/src/client/stores/auth.tsx new file mode 100644 index 0000000..cca314a --- /dev/null +++ b/src/client/stores/auth.tsx @@ -0,0 +1,94 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { ApiClientError, apiClient } from "../api/client"; +import type { User } from "../api/types"; + +export interface AuthState { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +export interface AuthActions { + login: (username: string, password: string) => Promise<void>; + register: (username: string, password: string) => Promise<void>; + logout: () => void; +} + +export type AuthContextValue = AuthState & AuthActions; + +const AuthContext = createContext<AuthContextValue | null>(null); + +export interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState<User | null>(null); + const [isLoading, setIsLoading] = useState(true); + + // Check for existing auth on mount + useEffect(() => { + const tokens = apiClient.getTokens(); + if (tokens) { + // We have tokens stored, but we don't have user info cached + // For now, just set authenticated state. User info will be fetched when needed. + // In a full implementation, we'd decode the JWT or call an API endpoint + setIsLoading(false); + } else { + setIsLoading(false); + } + }, []); + + const login = useCallback(async (username: string, password: string) => { + const response = await apiClient.login(username, password); + setUser(response.user); + }, []); + + const register = useCallback( + async (username: string, password: string) => { + await apiClient.register(username, password); + // After registration, log in automatically + await login(username, password); + }, + [login], + ); + + const logout = useCallback(() => { + apiClient.logout(); + setUser(null); + }, []); + + const isAuthenticated = apiClient.isAuthenticated(); + + const value = useMemo<AuthContextValue>( + () => ({ + user, + isAuthenticated, + isLoading, + login, + register, + logout, + }), + [user, isAuthenticated, isLoading, login, register, logout], + ); + + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +export { ApiClientError }; diff --git a/src/client/stores/index.ts b/src/client/stores/index.ts new file mode 100644 index 0000000..7117bd6 --- /dev/null +++ b/src/client/stores/index.ts @@ -0,0 +1,7 @@ +export type { + AuthActions, + AuthContextValue, + AuthProviderProps, + AuthState, +} from "./auth"; +export { ApiClientError, AuthProvider, useAuth } from "./auth"; |
