diff --git a/.env.example b/.env.example
index ee502e6..ecda99c 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,5 @@
# Backend API URL (optional - leave blank to use mock auth in development)
-# VITE_API_URL=https://api.your-domain.com
+VITE_API_URL=http://localhost:8787
# Mock password for local development
VITE_MOCK_PASSWORD=admin123
diff --git a/bun.lock b/bun.lock
index 7a10985..7e80b57 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "s3-drive-storage-manage",
"dependencies": {
+ "@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^19.2.8",
@@ -16,6 +17,7 @@
"@babel/core": "^7.29.7",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.3",
+ "@tanstack/react-query-devtools": "^5.101.4",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
@@ -310,6 +312,14 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
+ "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="],
+
+ "@tanstack/query-devtools": ["@tanstack/query-devtools@5.101.4", "", {}, "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA=="],
+
+ "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="],
+
+ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.4", "", { "dependencies": { "@tanstack/query-devtools": "5.101.4" }, "peerDependencies": { "@tanstack/react-query": "^5.101.4", "react": "^18 || ^19" } }, "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA=="],
+
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
diff --git a/package.json b/package.json
index c6cf51e..dc2a26a 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"deploy": "wrangler pages deploy dist"
},
"dependencies": {
+ "@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^19.2.8",
@@ -22,6 +23,7 @@
"@babel/core": "^7.29.7",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.3",
+ "@tanstack/react-query-devtools": "^5.101.4",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
diff --git a/src/App.tsx b/src/App.tsx
index 1364c1f..df93e0d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,10 +1,10 @@
-import { AuthProvider } from "@/contexts/AuthContext"
+import { QueryProvider } from "@/providers/QueryProvider"
import { AppRouter } from "@/router"
export default function App() {
return (
-
+
-
+
)
}
diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx
index b9b7a10..6819f31 100644
--- a/src/components/ProtectedRoute.tsx
+++ b/src/components/ProtectedRoute.tsx
@@ -1,6 +1,6 @@
import React from "react"
import { Navigate } from "react-router"
-import { useAuth } from "@/hooks/useAuth"
+import { useAuth } from "@/features/auth/hooks/useAuth"
import { Loader2 } from "lucide-react"
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx
deleted file mode 100644
index b0d75b8..0000000
--- a/src/contexts/AuthContext.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import React, { useState, useEffect } from "react"
-import {
- isAuthenticated as checkIsAuthenticated,
- login as authLogin,
- logout as authLogout,
- type AuthResult,
-} from "@/lib/auth"
-import { AuthContext } from "./auth-context-def"
-
-export function AuthProvider({ children }: { children: React.ReactNode }) {
- const [isAuthenticated, setIsAuthenticated] = useState(false)
- const [isLoading, setIsLoading] = useState(true)
-
- useEffect(() => {
- // Check initial auth state from localStorage
- setIsAuthenticated(checkIsAuthenticated())
- setIsLoading(false)
- }, [])
-
- const signIn = async (password: string): Promise => {
- setIsLoading(true)
- try {
- const result = await authLogin(password)
- if (result.success) {
- setIsAuthenticated(true)
- }
- return result
- } finally {
- setIsLoading(false)
- }
- }
-
- const signOut = () => {
- authLogout()
- setIsAuthenticated(false)
- }
-
- return (
-
- {children}
-
- )
-}
-
-export type { AuthContextType } from "./auth-context-def"
-export { AuthContext } from "./auth-context-def"
-
diff --git a/src/contexts/auth-context-def.ts b/src/contexts/auth-context-def.ts
deleted file mode 100644
index 1a84a4f..0000000
--- a/src/contexts/auth-context-def.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { createContext } from "react"
-import type { AuthResult } from "@/lib/auth"
-
-export interface AuthContextType {
- isAuthenticated: boolean
- isLoading: boolean
- signIn: (password: string) => Promise
- signOut: () => void
-}
-
-export const AuthContext = createContext(undefined)
diff --git a/src/features/auth/api/auth.api.ts b/src/features/auth/api/auth.api.ts
new file mode 100644
index 0000000..b464e22
--- /dev/null
+++ b/src/features/auth/api/auth.api.ts
@@ -0,0 +1,54 @@
+import { apiRequest, ApiError, IS_MOCK_MODE } from "@/lib/api-client"
+import { hashPassword } from "../lib/hash-password"
+import { mockLogin, mockLogout, mockVerifySession } from "./auth.mock"
+import type { LoginResponse, SessionResponse } from "./auth.types"
+
+/**
+ * Perform login by hashing the password and validating it against the API (or mock).
+ */
+export async function login(password: string): Promise {
+ if (!password.trim()) {
+ throw new ApiError(400, "Password cannot be empty")
+ }
+
+ const hashedPassword = await hashPassword(password)
+
+ if (IS_MOCK_MODE) {
+ return mockLogin(hashedPassword)
+ }
+
+ return apiRequest("/auth/login", {
+ method: "POST",
+ body: { passwordHash: hashedPassword },
+ auth: false,
+ })
+}
+
+/**
+ * Fetch and verify the current session with the API (or mock).
+ */
+export async function fetchSession(signal?: AbortSignal): Promise {
+ if (IS_MOCK_MODE) {
+ await mockVerifySession()
+ return { valid: true }
+ }
+
+ return apiRequest("/auth/session", {
+ method: "GET",
+ signal,
+ })
+}
+
+/**
+ * Perform logout with the API (or mock).
+ */
+export async function logout(): Promise {
+ if (IS_MOCK_MODE) {
+ await mockLogout()
+ return
+ }
+
+ await apiRequest("/auth/logout", {
+ method: "POST",
+ })
+}
diff --git a/src/features/auth/api/auth.keys.ts b/src/features/auth/api/auth.keys.ts
new file mode 100644
index 0000000..9c8237c
--- /dev/null
+++ b/src/features/auth/api/auth.keys.ts
@@ -0,0 +1,4 @@
+export const authKeys = {
+ all: ["auth"] as const,
+ session: () => [...authKeys.all, "session"] as const,
+}
diff --git a/src/lib/auth-mock.ts b/src/features/auth/api/auth.mock.ts
similarity index 51%
rename from src/lib/auth-mock.ts
rename to src/features/auth/api/auth.mock.ts
index 82704c0..49a8d6f 100644
--- a/src/lib/auth-mock.ts
+++ b/src/features/auth/api/auth.mock.ts
@@ -1,4 +1,6 @@
-import { hashPassword, setAuthToken, type AuthResult } from "./auth"
+import { ApiError } from "@/lib/api-client"
+import { hashPassword } from "../lib/hash-password"
+import type { LoginResponse } from "./auth.types"
// Default mock password if not set in .env
const MOCK_ENV_PASSWORD = import.meta.env.VITE_MOCK_PASSWORD || "admin123"
@@ -7,7 +9,7 @@ const MOCK_ENV_PASSWORD = import.meta.env.VITE_MOCK_PASSWORD || "admin123"
* Mock login function for development when backend is not connected.
* Verifies the incoming password hash against the hash of VITE_MOCK_PASSWORD.
*/
-export async function mockLogin(incomingPasswordHash: string): Promise {
+export async function mockLogin(incomingPasswordHash: string): Promise {
// Simulate network latency
await new Promise((resolve) => setTimeout(resolve, 600))
@@ -15,12 +17,22 @@ export async function mockLogin(incomingPasswordHash: string): Promise {
+ // No-op for mock
+}
+
+/**
+ * Mock logout when backend is not connected.
+ */
+export async function mockLogout(): Promise {
+ // No-op for mock
}
diff --git a/src/features/auth/api/auth.types.ts b/src/features/auth/api/auth.types.ts
new file mode 100644
index 0000000..184011e
--- /dev/null
+++ b/src/features/auth/api/auth.types.ts
@@ -0,0 +1,12 @@
+export interface LoginResponse {
+ token: string
+ expiresIn: number
+}
+
+export interface SessionResponse {
+ valid: true
+}
+
+export interface ApiErrorBody {
+ message?: string
+}
diff --git a/src/features/auth/hooks/useAuth.ts b/src/features/auth/hooks/useAuth.ts
new file mode 100644
index 0000000..78fcb93
--- /dev/null
+++ b/src/features/auth/hooks/useAuth.ts
@@ -0,0 +1,25 @@
+import { useAuthToken } from "@/lib/auth-storage"
+import { useSession } from "./useSession"
+import { useLogin } from "./useLogin"
+import { useLogout } from "./useLogout"
+
+/**
+ * Facade hook providing authentication status and actions.
+ */
+export function useAuth() {
+ const token = useAuthToken()
+ const session = useSession()
+ const loginMutation = useLogin()
+ const logoutMutation = useLogout()
+
+ return {
+ token,
+ isAuthenticated: session.data?.valid === true,
+ isLoading: session.isLoading,
+ signIn: loginMutation.mutateAsync,
+ signOut: logoutMutation.mutateAsync,
+ isSigningIn: loginMutation.isPending,
+ signInError: loginMutation.error,
+ resetSignInError: loginMutation.reset,
+ }
+}
diff --git a/src/features/auth/hooks/useLogin.ts b/src/features/auth/hooks/useLogin.ts
new file mode 100644
index 0000000..c38c6cd
--- /dev/null
+++ b/src/features/auth/hooks/useLogin.ts
@@ -0,0 +1,20 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { setAuthToken } from "@/lib/auth-storage"
+import { login } from "../api/auth.api"
+import { authKeys } from "../api/auth.keys"
+import type { LoginResponse } from "../api/auth.types"
+
+/**
+ * Mutation hook for authenticating and logging in.
+ */
+export function useLogin() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (password: string) => login(password),
+ onSuccess: (data: LoginResponse) => {
+ setAuthToken(data.token)
+ queryClient.setQueryData(authKeys.session(), { valid: true })
+ },
+ })
+}
diff --git a/src/features/auth/hooks/useLogout.ts b/src/features/auth/hooks/useLogout.ts
new file mode 100644
index 0000000..0978f7b
--- /dev/null
+++ b/src/features/auth/hooks/useLogout.ts
@@ -0,0 +1,19 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { clearAuthToken } from "@/lib/auth-storage"
+import { logout } from "../api/auth.api"
+import { authKeys } from "../api/auth.keys"
+
+/**
+ * Mutation hook for logging out and clearing credentials.
+ */
+export function useLogout() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: () => logout(),
+ onSettled: () => {
+ clearAuthToken()
+ queryClient.removeQueries({ queryKey: authKeys.all })
+ },
+ })
+}
diff --git a/src/features/auth/hooks/useSession.ts b/src/features/auth/hooks/useSession.ts
new file mode 100644
index 0000000..4312be4
--- /dev/null
+++ b/src/features/auth/hooks/useSession.ts
@@ -0,0 +1,25 @@
+import { queryOptions, useQuery } from "@tanstack/react-query"
+import { useAuthToken } from "@/lib/auth-storage"
+import { fetchSession } from "../api/auth.api"
+import { authKeys } from "../api/auth.keys"
+
+/**
+ * Query options definition for session verification.
+ */
+export function sessionQueryOptions(token: string | null) {
+ return queryOptions({
+ queryKey: authKeys.session(),
+ queryFn: ({ signal }) => fetchSession(signal),
+ enabled: token !== null,
+ staleTime: 5 * 60_000,
+ retry: false,
+ })
+}
+
+/**
+ * Hook to retrieve and subscribe to the active session.
+ */
+export function useSession() {
+ const token = useAuthToken()
+ return useQuery(sessionQueryOptions(token))
+}
diff --git a/src/features/auth/lib/hash-password.ts b/src/features/auth/lib/hash-password.ts
new file mode 100644
index 0000000..94e7bb7
--- /dev/null
+++ b/src/features/auth/lib/hash-password.ts
@@ -0,0 +1,10 @@
+/**
+ * Hash a plain-text password to a SHA-256 hexadecimal string using the browser's Web Crypto API.
+ */
+export async function hashPassword(password: string): Promise {
+ const encoder = new TextEncoder()
+ const data = encoder.encode(password)
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data)
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
+}
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
deleted file mode 100644
index f7edb25..0000000
--- a/src/hooks/useAuth.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { useContext } from "react"
-import { AuthContext, type AuthContextType } from "@/contexts/auth-context-def"
-
-export function useAuth(): AuthContextType {
- const context = useContext(AuthContext)
- if (!context) {
- throw new Error("useAuth must be used within an AuthProvider")
- }
- return context
-}
-
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
new file mode 100644
index 0000000..fced629
--- /dev/null
+++ b/src/lib/api-client.ts
@@ -0,0 +1,93 @@
+import { getAuthToken } from "./auth-storage"
+
+export const API_BASE_URL: string = import.meta.env.VITE_API_URL || ""
+export const IS_MOCK_MODE: boolean = !API_BASE_URL
+
+export class ApiError extends Error {
+ status: number
+
+ constructor(status: number, message: string) {
+ super(message)
+ this.name = "ApiError"
+ this.status = status
+ }
+}
+
+export interface ApiRequestOptions extends Omit {
+ body?: unknown
+ auth?: boolean
+}
+
+/**
+ * Type-safe fetch wrapper with error handling and automatic bearer authentication.
+ */
+export async function apiRequest(
+ path: string,
+ options: ApiRequestOptions = {}
+): Promise {
+ const { body, auth = true, headers: customHeaders, ...restOptions } = options
+
+ const headers = new Headers(customHeaders)
+
+ if (auth) {
+ const token = getAuthToken()
+ if (token) {
+ headers.set("Authorization", `Bearer ${token}`)
+ }
+ }
+
+ let requestBody: BodyInit | null | undefined
+ if (body !== undefined) {
+ if (
+ typeof body === "string" ||
+ body instanceof FormData ||
+ body instanceof Blob ||
+ body instanceof ArrayBuffer ||
+ body instanceof URLSearchParams
+ ) {
+ requestBody = body as BodyInit
+ } else {
+ headers.set("Content-Type", "application/json")
+ requestBody = JSON.stringify(body)
+ }
+ }
+
+ let response: Response
+ try {
+ response = await fetch(`${API_BASE_URL}${path}`, {
+ ...restOptions,
+ headers,
+ body: requestBody,
+ })
+ } catch {
+ throw new ApiError(0, "Failed to connect to authentication service")
+ }
+
+ if (!response.ok) {
+ let errorMessage = "Request failed"
+ try {
+ const data = (await response.json()) as { message?: string }
+ if (data && typeof data.message === "string") {
+ errorMessage = data.message
+ }
+ } catch {
+ errorMessage = response.statusText || `Request failed with status ${response.status}`
+ }
+ throw new ApiError(response.status, errorMessage)
+ }
+
+ if (response.status === 204) {
+ return undefined as T
+ }
+
+ const text = await response.text()
+ if (!text) {
+ return undefined as T
+ }
+
+ try {
+ return JSON.parse(text) as T
+ } catch {
+ return text as unknown as T
+ }
+}
diff --git a/src/lib/auth-storage.ts b/src/lib/auth-storage.ts
new file mode 100644
index 0000000..0ec3832
--- /dev/null
+++ b/src/lib/auth-storage.ts
@@ -0,0 +1,67 @@
+import { useSyncExternalStore } from "react"
+
+export const AUTH_STORAGE_KEY = "s3_drive_storage_auth_token"
+
+const listeners = new Set<() => void>()
+
+function emitChange(): void {
+ for (const listener of listeners) {
+ listener()
+ }
+}
+
+/**
+ * Retrieve the auth token from localStorage.
+ */
+export function getAuthToken(): string | null {
+ if (typeof window === "undefined") {
+ return null
+ }
+ return localStorage.getItem(AUTH_STORAGE_KEY)
+}
+
+/**
+ * Store the auth token in localStorage and notify listeners.
+ */
+export function setAuthToken(token: string): void {
+ localStorage.setItem(AUTH_STORAGE_KEY, token)
+ emitChange()
+}
+
+/**
+ * Clear the auth token from localStorage and notify listeners.
+ */
+export function clearAuthToken(): void {
+ localStorage.removeItem(AUTH_STORAGE_KEY)
+ emitChange()
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+function getSnapshot(): string | null {
+ return getAuthToken()
+}
+
+function getServerSnapshot(): string | null {
+ return null
+}
+
+if (typeof window !== "undefined") {
+ window.addEventListener("storage", (event: StorageEvent) => {
+ if (event.key === AUTH_STORAGE_KEY) {
+ emitChange()
+ }
+ })
+}
+
+/**
+ * Hook to reactively subscribe to the auth token in localStorage.
+ */
+export function useAuthToken(): string | null {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
deleted file mode 100644
index ca0ca2a..0000000
--- a/src/lib/auth.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { mockLogin } from "./auth-mock"
-
-export const AUTH_STORAGE_KEY = "s3_drive_storage_auth_token"
-export const API_BASE_URL = import.meta.env.VITE_API_URL || ""
-
-export interface AuthResult {
- success: boolean
- token?: string
- error?: string
-}
-
-/**
- * Hash a plain-text password to a SHA-256 hexadecimal string using the browser's Web Crypto API.
- */
-export async function hashPassword(password: string): Promise {
- const encoder = new TextEncoder()
- const data = encoder.encode(password)
- const hashBuffer = await crypto.subtle.digest("SHA-256", data)
- const hashArray = Array.from(new Uint8Array(hashBuffer))
- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
-}
-
-/**
- * Perform login by hashing the password and validating it against the API (or mock).
- */
-export async function login(password: string): Promise {
- if (!password.trim()) {
- return { success: false, error: "Password cannot be empty" }
- }
-
- const hashedPassword = await hashPassword(password)
-
- // If no API_BASE_URL configured or in mock mode, use mock authentication
- if (!API_BASE_URL) {
- return mockLogin(hashedPassword)
- }
-
- try {
- const response = await fetch(`${API_BASE_URL}/auth/verify`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ passwordHash: hashedPassword }),
- })
-
- if (!response.ok) {
- const data = await response.json().catch(() => ({}))
- return {
- success: false,
- error: data.message || "Invalid password",
- }
- }
-
- const data = await response.json()
- const token = data.token || "authenticated"
- setAuthToken(token)
- return { success: true, token }
- } catch (error) {
- return {
- success: false,
- error: error instanceof Error ? error.message : "Failed to connect to authentication service",
- }
- }
-}
-
-/**
- * Store the auth token in localStorage.
- */
-export function setAuthToken(token: string): void {
- localStorage.setItem(AUTH_STORAGE_KEY, token)
-}
-
-/**
- * Retrieve the auth token from localStorage.
- */
-export function getAuthToken(): string | null {
- return localStorage.getItem(AUTH_STORAGE_KEY)
-}
-
-/**
- * Clear the auth token from localStorage.
- */
-export function logout(): void {
- localStorage.removeItem(AUTH_STORAGE_KEY)
-}
-
-/**
- * Check if the user is currently authenticated.
- */
-export function isAuthenticated(): boolean {
- return !!getAuthToken()
-}
diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts
new file mode 100644
index 0000000..9003637
--- /dev/null
+++ b/src/lib/query-client.ts
@@ -0,0 +1,41 @@
+import { QueryClient, QueryCache } from "@tanstack/react-query"
+import { ApiError } from "./api-client"
+import { clearAuthToken } from "./auth-storage"
+
+/**
+ * Factory function to create a new configured QueryClient instance.
+ */
+export function createQueryClient(): QueryClient {
+ let client: QueryClient
+
+ const queryCache = new QueryCache({
+ onError: (error) => {
+ if (error instanceof ApiError && error.status === 401) {
+ clearAuthToken()
+ client?.clear()
+ }
+ },
+ })
+
+ client = new QueryClient({
+ queryCache,
+ defaultOptions: {
+ queries: {
+ staleTime: 60_000,
+ gcTime: 5 * 60_000,
+ refetchOnWindowFocus: true,
+ retry: (failureCount, error) => {
+ if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
+ return false
+ }
+ return failureCount < 2
+ },
+ },
+ mutations: {
+ retry: false,
+ },
+ },
+ })
+
+ return client
+}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 80862e3..b57f92e 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -16,7 +16,7 @@ import {
Cpu,
Sparkles,
} from "lucide-react"
-import { useAuth } from "@/hooks/useAuth"
+import { useAuth } from "@/features/auth/hooks/useAuth"
import { Button } from "@/components/ui/button"
import {
Card,
@@ -30,8 +30,8 @@ export default function Dashboard() {
const { signOut } = useAuth()
const navigate = useNavigate()
- const handleSignOut = () => {
- signOut()
+ const handleSignOut = async () => {
+ await signOut()
navigate("/sign-in", { replace: true })
}
diff --git a/src/pages/SignIn.tsx b/src/pages/SignIn.tsx
index 0727569..90ec3c9 100644
--- a/src/pages/SignIn.tsx
+++ b/src/pages/SignIn.tsx
@@ -1,7 +1,8 @@
import React, { useState } from "react"
import { useNavigate, Navigate } from "react-router"
import { Eye, EyeOff, Lock, Database, ArrowRight, Loader2, AlertCircle, Sparkles, Shield, Cloud } from "lucide-react"
-import { useAuth } from "@/hooks/useAuth"
+import { useAuth } from "@/features/auth/hooks/useAuth"
+import { ApiError } from "@/lib/api-client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -14,13 +15,31 @@ import {
CardTitle,
} from "@/components/ui/card"
+function getErrorMessage(error: Error | null): string | null {
+ if (!error) return null
+ if (error instanceof ApiError) {
+ if (error.status === 429) {
+ return "Too many failed attempts. Account is temporarily locked out. Please try again later."
+ }
+ if (error.status === 503) {
+ return "Authentication service is unavailable or DASHBOARD_PASSWORD is not configured on backend."
+ }
+ if (error.status === 401) {
+ return error.message || "Incorrect password. Please try again."
+ }
+ if (error.status === 0) {
+ return error.message || "Failed to connect to authentication service"
+ }
+ return error.message
+ }
+ return error.message || "An unexpected error occurred. Please try again."
+}
+
export default function SignIn() {
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
- const [error, setError] = useState(null)
- const [submitting, setSubmitting] = useState(false)
- const { signIn, isAuthenticated, isLoading } = useAuth()
+ const { signIn, isAuthenticated, isLoading, isSigningIn, signInError, resetSignInError } = useAuth()
const navigate = useNavigate()
// If already authenticated and not in loading state, redirect to dashboard
@@ -28,27 +47,19 @@ export default function SignIn() {
return
}
+ const error = getErrorMessage(signInError)
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
- if (!password.trim()) {
- setError("Please enter your password")
+ if (!password.trim() || isSigningIn) {
return
}
- setError(null)
- setSubmitting(true)
-
try {
- const result = await signIn(password)
- if (result.success) {
- navigate("/", { replace: true })
- } else {
- setError(result.error || "Authentication failed")
- }
+ await signIn(password)
+ navigate("/", { replace: true })
} catch {
- setError("An unexpected error occurred. Please try again.")
- } finally {
- setSubmitting(false)
+ // Error handled by mutation state
}
}
@@ -129,11 +140,11 @@ export default function SignIn() {
value={password}
onChange={(e) => {
setPassword(e.target.value)
- if (error) setError(null)
+ if (signInError) resetSignInError()
}}
placeholder="Enter management password"
className="pl-10 pr-10 h-11 text-sm bg-background/80 border-border/80 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/40 focus-visible:border-blue-500 transition-all"
- disabled={submitting}
+ disabled={isSigningIn}
autoComplete="current-password"
autoFocus
/>
@@ -158,9 +169,9 @@ export default function SignIn() {