feat: refactor frontend routing and authentication
- Changed root div ID from 'root' to 'app' in index.html. - Updated package.json to include new dependencies for routing and state management. - Removed App component and replaced it with a router setup in main.tsx. - Added route definitions for login, about, and index pages. - Implemented authentication logic using Zustand for state management. - Created API client with Axios for handling requests and token management. - Added CORS support in the backend API. - Updated schema for login requests to use camelCase.
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center">
|
||||
<Button>Click me</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
46
libertas-frontend/src/domain/types.ts
Normal file
46
libertas-frontend/src/domain/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type PaginatedResponse<T> = {
|
||||
data: T[]
|
||||
page: number
|
||||
limit: number
|
||||
total_items: number
|
||||
total_pages: number
|
||||
has_next_page: boolean
|
||||
has_prev_page: boolean
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
storage_used: number
|
||||
storage_quota: number
|
||||
}
|
||||
|
||||
export type Media = {
|
||||
id: string
|
||||
original_filename: string
|
||||
mime_type: string
|
||||
hash: string
|
||||
file_url: string
|
||||
thumbnail_url: string | null
|
||||
}
|
||||
|
||||
export type Album = {
|
||||
id: string
|
||||
owner_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
is_public: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
thumbnail_media_id: string | null
|
||||
}
|
||||
|
||||
export type Person = {
|
||||
id: string
|
||||
owner_id: string
|
||||
name: string
|
||||
thumbnail_media_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
37
libertas-frontend/src/features/auth/use-auth.ts
Normal file
37
libertas-frontend/src/features/auth/use-auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { User } from "@/domain/types"
|
||||
import { useAuthStorage } from "@/hooks/use-auth-storage"
|
||||
import apiClient from "@/services/api-client"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
|
||||
type LoginCredentials = {
|
||||
usernameOrEmail: string
|
||||
password: string
|
||||
}
|
||||
|
||||
type LoginResponse = {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
||||
const { data } = await apiClient.post('/auth/login', credentials)
|
||||
return data
|
||||
}
|
||||
|
||||
export const useLogin = () => {
|
||||
const navigate = useNavigate()
|
||||
const { setToken } = useAuthStorage()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (data) => {
|
||||
setToken(data.token, data.user)
|
||||
navigate({ to: '/' })
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Login failed:', error)
|
||||
// TODO: Add user-facing error toast
|
||||
},
|
||||
})
|
||||
}
|
||||
29
libertas-frontend/src/hooks/use-auth-storage.ts
Normal file
29
libertas-frontend/src/hooks/use-auth-storage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
import type { User } from "@/domain/types"
|
||||
|
||||
type AuthState = {
|
||||
token: string | null
|
||||
user: User | null
|
||||
setToken: (token: string, user: User) => void
|
||||
clearToken: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Global store for authentication state (token and user).
|
||||
* Persisted to localStorage.
|
||||
*/
|
||||
export const useAuthStorage = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
setToken: (token, user) => set({ token, user }),
|
||||
clearToken: () => set({ token: null, user: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,10 +1,36 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
},
|
||||
defaultPreload: "intent",
|
||||
|
||||
defaultPreloadStaleTime: 0,
|
||||
scrollRestoration: true,
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("app")!;
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
95
libertas-frontend/src/routeTree.gen.ts
Normal file
95
libertas-frontend/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/about' | '/login'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/about' | '/login'
|
||||
id: '__root__' | '/' | '/about' | '/login'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
fullPath: '/about'
|
||||
preLoaderRoute: typeof AboutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
55
libertas-frontend/src/routes/__root.tsx
Normal file
55
libertas-frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
createRootRouteWithContext,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStorage } from "@/hooks/use-auth-storage";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Route = createRootRouteWithContext<{
|
||||
queryClient: QueryClient;
|
||||
}>()({
|
||||
component: RootComponent,
|
||||
notFoundComponent: () => {
|
||||
return (
|
||||
<div>
|
||||
<p>This is the notFoundComponent configured on root route</p>
|
||||
<Link to="/">Start Over</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
const token = useAuthStorage((s) => s.token);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate({
|
||||
to: "/login",
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [token, navigate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* <Sidebar /> */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* <Header /> */}
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<ReactQueryDevtools buttonPosition="top-right" />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
libertas-frontend/src/routes/about.tsx
Normal file
14
libertas-frontend/src/routes/about.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
export const Route = createFileRoute("/about")({
|
||||
component: AboutComponent,
|
||||
});
|
||||
|
||||
function AboutComponent() {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h3>About</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
libertas-frontend/src/routes/index.tsx
Normal file
14
libertas-frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Dashboard,
|
||||
});
|
||||
|
||||
function Dashboard() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold">Welcome to Libertas</h1>
|
||||
<p>This is your main dashboard.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
libertas-frontend/src/routes/login.tsx
Normal file
85
libertas-frontend/src/routes/login.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useLogin } from "@/features/auth/use-auth";
|
||||
import { useAuthStorage } from "@/hooks/use-auth-storage";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { mutate: login, isPending, error } = useLogin();
|
||||
const token = useAuthStorage((s) => s.token);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
}, [token, navigate]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const usernameOrEmail = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
login({ usernameOrEmail, password });
|
||||
};
|
||||
|
||||
console.log("LoginPage render, isPending:", isPending, "error:", error);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100">
|
||||
<div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold text-center">Login to Libertas</h1>
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">
|
||||
Login failed. Please check your credentials.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full px-4 py-2 font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Logging in..." : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
libertas-frontend/src/services/api-client.ts
Normal file
33
libertas-frontend/src/services/api-client.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStorage } from '@/hooks/use-auth-storage'
|
||||
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: 'http://localhost:8080/api/v1',
|
||||
})
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStorage.getState().token
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
useAuthStorage.getState().clearToken()
|
||||
window.location.reload()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
Reference in New Issue
Block a user