mirror of
https://github.com/Nezumi-2711/s3-drive-storage-manage.git
synced 2026-09-22 13:48:31 +00:00
feat: integrate the api for authentication
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Backend API URL (optional - leave blank to use mock auth in development)
|
# 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
|
# Mock password for local development
|
||||||
VITE_MOCK_PASSWORD=admin123
|
VITE_MOCK_PASSWORD=admin123
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "s3-drive-storage-manage",
|
"name": "s3-drive-storage-manage",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"@babel/core": "^7.29.7",
|
"@babel/core": "^7.29.7",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-query-devtools": "^5.101.4",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@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=="],
|
"@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__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=="],
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"deploy": "wrangler pages deploy dist"
|
"deploy": "wrangler pages deploy dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@babel/core": "^7.29.7",
|
"@babel/core": "^7.29.7",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-query-devtools": "^5.101.4",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
|
|||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
import { AuthProvider } from "@/contexts/AuthContext"
|
import { QueryProvider } from "@/providers/QueryProvider"
|
||||||
import { AppRouter } from "@/router"
|
import { AppRouter } from "@/router"
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<QueryProvider>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</AuthProvider>
|
</QueryProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Navigate } from "react-router"
|
import { Navigate } from "react-router"
|
||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/features/auth/hooks/useAuth"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -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<boolean>(false)
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check initial auth state from localStorage
|
|
||||||
setIsAuthenticated(checkIsAuthenticated())
|
|
||||||
setIsLoading(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const signIn = async (password: string): Promise<AuthResult> => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await authLogin(password)
|
|
||||||
if (result.success) {
|
|
||||||
setIsAuthenticated(true)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const signOut = () => {
|
|
||||||
authLogout()
|
|
||||||
setIsAuthenticated(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
isAuthenticated,
|
|
||||||
isLoading,
|
|
||||||
signIn,
|
|
||||||
signOut,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { AuthContextType } from "./auth-context-def"
|
|
||||||
export { AuthContext } from "./auth-context-def"
|
|
||||||
|
|
||||||
@@ -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<AuthResult>
|
|
||||||
signOut: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
|
||||||
@@ -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<LoginResponse> {
|
||||||
|
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<LoginResponse>("/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<SessionResponse> {
|
||||||
|
if (IS_MOCK_MODE) {
|
||||||
|
await mockVerifySession()
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest<SessionResponse>("/auth/session", {
|
||||||
|
method: "GET",
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform logout with the API (or mock).
|
||||||
|
*/
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
if (IS_MOCK_MODE) {
|
||||||
|
await mockLogout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest<void>("/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const authKeys = {
|
||||||
|
all: ["auth"] as const,
|
||||||
|
session: () => [...authKeys.all, "session"] as const,
|
||||||
|
}
|
||||||
@@ -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
|
// Default mock password if not set in .env
|
||||||
const MOCK_ENV_PASSWORD = import.meta.env.VITE_MOCK_PASSWORD || "admin123"
|
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.
|
* Mock login function for development when backend is not connected.
|
||||||
* Verifies the incoming password hash against the hash of VITE_MOCK_PASSWORD.
|
* Verifies the incoming password hash against the hash of VITE_MOCK_PASSWORD.
|
||||||
*/
|
*/
|
||||||
export async function mockLogin(incomingPasswordHash: string): Promise<AuthResult> {
|
export async function mockLogin(incomingPasswordHash: string): Promise<LoginResponse> {
|
||||||
// Simulate network latency
|
// Simulate network latency
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||||
|
|
||||||
@@ -15,12 +17,22 @@ export async function mockLogin(incomingPasswordHash: string): Promise<AuthResul
|
|||||||
|
|
||||||
if (incomingPasswordHash === expectedHash) {
|
if (incomingPasswordHash === expectedHash) {
|
||||||
const mockToken = `mock_token_${Date.now()}_${Math.random().toString(36).substring(2)}`
|
const mockToken = `mock_token_${Date.now()}_${Math.random().toString(36).substring(2)}`
|
||||||
setAuthToken(mockToken)
|
return { token: mockToken, expiresIn: 86400 }
|
||||||
return { success: true, token: mockToken }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
throw new ApiError(401, "Incorrect password. Please try again.")
|
||||||
success: false,
|
}
|
||||||
error: "Incorrect password. Please try again.",
|
|
||||||
}
|
/**
|
||||||
|
* Mock session verification when backend is not connected.
|
||||||
|
*/
|
||||||
|
export async function mockVerifySession(): Promise<void> {
|
||||||
|
// No-op for mock
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock logout when backend is not connected.
|
||||||
|
*/
|
||||||
|
export async function mockLogout(): Promise<void> {
|
||||||
|
// No-op for mock
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
expiresIn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionResponse {
|
||||||
|
valid: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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("")
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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<RequestInit, "body"> {
|
||||||
|
body?: unknown
|
||||||
|
auth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe fetch wrapper with error handling and automatic bearer authentication.
|
||||||
|
*/
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: ApiRequestOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<string> {
|
|
||||||
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<AuthResult> {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/features/auth/hooks/useAuth"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -30,8 +30,8 @@ export default function Dashboard() {
|
|||||||
const { signOut } = useAuth()
|
const { signOut } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = async () => {
|
||||||
signOut()
|
await signOut()
|
||||||
navigate("/sign-in", { replace: true })
|
navigate("/sign-in", { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-22
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { useNavigate, Navigate } from "react-router"
|
import { useNavigate, Navigate } from "react-router"
|
||||||
import { Eye, EyeOff, Lock, Database, ArrowRight, Loader2, AlertCircle, Sparkles, Shield, Cloud } from "lucide-react"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@@ -14,13 +15,31 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} 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() {
|
export default function SignIn() {
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
const { signIn, isAuthenticated, isLoading } = useAuth()
|
const { signIn, isAuthenticated, isLoading, isSigningIn, signInError, resetSignInError } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// If already authenticated and not in loading state, redirect to dashboard
|
// If already authenticated and not in loading state, redirect to dashboard
|
||||||
@@ -28,27 +47,19 @@ export default function SignIn() {
|
|||||||
return <Navigate to="/" replace />
|
return <Navigate to="/" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const error = getErrorMessage(signInError)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!password.trim()) {
|
if (!password.trim() || isSigningIn) {
|
||||||
setError("Please enter your password")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null)
|
|
||||||
setSubmitting(true)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await signIn(password)
|
await signIn(password)
|
||||||
if (result.success) {
|
navigate("/", { replace: true })
|
||||||
navigate("/", { replace: true })
|
|
||||||
} else {
|
|
||||||
setError(result.error || "Authentication failed")
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setError("An unexpected error occurred. Please try again.")
|
// Error handled by mutation state
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,11 +140,11 @@ export default function SignIn() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPassword(e.target.value)
|
setPassword(e.target.value)
|
||||||
if (error) setError(null)
|
if (signInError) resetSignInError()
|
||||||
}}
|
}}
|
||||||
placeholder="Enter management password"
|
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"
|
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"
|
autoComplete="current-password"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -158,9 +169,9 @@ export default function SignIn() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full h-11 font-semibold rounded-xl text-sm"
|
className="w-full h-11 font-semibold rounded-xl text-sm"
|
||||||
disabled={submitting || !password.trim()}
|
disabled={isSigningIn || !password.trim()}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{isSigningIn ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
Verifying Credentials...
|
Verifying Credentials...
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React, { useState, lazy, Suspense } from "react"
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query"
|
||||||
|
import { createQueryClient } from "@/lib/query-client"
|
||||||
|
|
||||||
|
const ReactQueryDevtools = import.meta.env.DEV
|
||||||
|
? lazy(() =>
|
||||||
|
import("@tanstack/react-query-devtools").then((m) => ({
|
||||||
|
default: m.ReactQueryDevtools,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
: () => null
|
||||||
|
|
||||||
|
export interface QueryProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: QueryProviderProps) {
|
||||||
|
const [queryClient] = useState(() => createQueryClient())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</Suspense>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user