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() {