initial commit

This commit is contained in:
gulimabr
2025-11-28 12:33:37 -03:00
commit 5da54393ff
42 changed files with 3251 additions and 0 deletions

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Vite API URL (used during build)
VITE_API_URL=/api

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source files
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Keycloak Auth App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

70
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,70 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# Proxy API requests to backend
location /api {
proxy_pass http://fastapi-app:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
# Ensure cookies are passed through
proxy_pass_header Set-Cookie;
proxy_cookie_path / /;
}
# Proxy Swagger docs
location /docs {
proxy_pass http://fastapi-app:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy OpenAPI JSON
location /openapi.json {
proxy_pass http://fastapi-app:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Serve static files with caching
location /assets {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback - serve index.html for all other routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

33
frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "~5.6.2",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

30
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { Routes, Route } from 'react-router-dom'
import Layout from '@/components/Layout'
import HomePage from '@/pages/HomePage'
import DashboardPage from '@/pages/DashboardPage'
import ProtectedRoute from '@/components/ProtectedRoute'
function App() {
return (
<Routes>
<Route
path="/"
element={
<Layout>
<HomePage />
</Layout>
}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
</Routes>
)
}
export default App

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import Navbar from './Navbar'
interface LayoutProps {
children: ReactNode
}
export default function Layout({ children }: LayoutProps) {
return (
<div className="flex min-h-screen flex-col">
<Navbar />
<main className="flex-1">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{children}
</div>
</main>
<footer className="border-t border-gray-200 bg-white py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<p className="text-center text-sm text-gray-500">
&copy; {new Date().getFullYear()} Keycloak Auth App. All rights
reserved.
</p>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { Link } from 'react-router-dom'
import { useAuth } from '@/hooks'
export default function Navbar() {
const { user, isAuthenticated, login, logout } = useAuth()
return (
<nav className="border-b border-gray-200 bg-white shadow-sm">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
{/* Logo / Brand */}
<div className="flex items-center">
<Link
to="/"
className="flex items-center gap-2 text-xl font-bold text-primary-600 hover:text-primary-700"
>
<svg
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<span>Auth App</span>
</Link>
</div>
{/* Navigation Links */}
<div className="flex items-center gap-4">
{isAuthenticated ? (
<>
<Link
to="/dashboard"
className="rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900"
>
Dashboard
</Link>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600">
Hello,{' '}
<span className="font-medium text-gray-900">
{user?.full_name || user?.preferred_username}
</span>
</span>
<button
onClick={logout}
className="rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200"
>
Logout
</button>
</div>
</>
) : (
<button
onClick={login}
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-700"
>
Login
</button>
)}
</div>
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '@/hooks'
interface ProtectedRouteProps {
children: ReactNode
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary-200 border-t-primary-600" />
<p className="text-gray-600">Loading...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/" replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,3 @@
export { default as Layout } from './Layout'
export { default as Navbar } from './Navbar'
export { default as ProtectedRoute } from './ProtectedRoute'

View File

@@ -0,0 +1,64 @@
import {
createContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react'
import type { User, AuthContextType } from '@/types'
import { authService } from '@/services'
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const refreshUser = useCallback(async () => {
try {
setIsLoading(true)
const currentUser = await authService.getCurrentUser()
setUser(currentUser)
} catch (error) {
console.error('Failed to refresh user:', error)
setUser(null)
} finally {
setIsLoading(false)
}
}, [])
const login = useCallback(() => {
authService.login()
}, [])
const logout = useCallback(async () => {
try {
await authService.logout()
setUser(null)
} catch (error) {
console.error('Logout failed:', error)
// Still clear user on frontend even if backend logout fails
setUser(null)
}
}, [])
// Check authentication status on mount
useEffect(() => {
refreshUser()
}, [refreshUser])
const value: AuthContextType = {
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
refreshUser,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@@ -0,0 +1 @@
export { AuthContext, AuthProvider } from './AuthContext'

View File

@@ -0,0 +1 @@
export { useAuth } from './useAuth'

View File

@@ -0,0 +1,13 @@
import { useContext } from 'react'
import { AuthContext } from '@/context'
import type { AuthContextType } from '@/types'
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

7
frontend/src/index.css Normal file
View File

@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-50 text-gray-900 antialiased;
}

16
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from '@/context/AuthContext'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)

View File

@@ -0,0 +1,245 @@
import { useAuth } from '@/hooks'
// Icons as components for cleaner code
const DataServicesIcon = () => (
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="12" y="8" width="40" height="48" rx="2" />
<line x1="20" y1="20" x2="44" y2="20" />
<line x1="20" y1="28" x2="44" y2="28" />
<line x1="20" y1="36" x2="44" y2="36" />
<line x1="20" y1="44" x2="36" y2="44" />
<rect x="36" y="40" width="12" height="12" rx="1" />
<line x1="40" y1="44" x2="44" y2="44" />
<line x1="40" y1="48" x2="44" y2="48" />
</svg>
)
const IntegrationIcon = () => (
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="8" y="16" width="20" height="14" rx="2" />
<rect x="36" y="16" width="20" height="14" rx="2" />
<rect x="22" y="38" width="20" height="14" rx="2" />
<line x1="18" y1="30" x2="18" y2="38" />
<line x1="18" y1="38" x2="32" y2="38" />
<line x1="46" y1="30" x2="46" y2="38" />
<line x1="46" y1="38" x2="32" y2="38" />
</svg>
)
const IntelligenceIcon = () => (
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="28" cy="28" r="14" />
<line x1="38" y1="38" x2="52" y2="52" strokeWidth="4" strokeLinecap="round" />
<path d="M22 28 L26 32 L34 24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const UserExperienceIcon = () => (
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="32" cy="20" r="8" />
<circle cx="16" cy="36" r="4" />
<circle cx="48" cy="36" r="4" />
<circle cx="24" cy="52" r="4" />
<circle cx="40" cy="52" r="4" />
<line x1="32" y1="28" x2="32" y2="36" />
<line x1="32" y1="36" x2="16" y2="36" />
<line x1="32" y1="36" x2="48" y2="36" />
<line x1="20" y1="40" x2="24" y2="48" />
<line x1="44" y1="40" x2="40" y2="48" />
</svg>
)
const ManagementIcon = () => (
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="32" cy="32" r="12" />
<path d="M32 16 L32 20" />
<path d="M32 44 L32 48" />
<path d="M16 32 L20 32" />
<path d="M44 32 L48 32" />
<path d="M20.7 20.7 L23.5 23.5" />
<path d="M40.5 40.5 L43.3 43.3" />
<path d="M20.7 43.3 L23.5 40.5" />
<path d="M40.5 23.5 L43.3 20.7" />
<rect x="26" y="28" width="12" height="10" rx="1" />
<path d="M29 28 L29 26 L35 26 L35 28" />
<line x1="29" y1="32" x2="35" y2="32" />
</svg>
)
const TrustworthinessIcon = () => (
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 36 C12 36 20 28 32 36 C44 44 52 36 52 36" strokeLinecap="round" />
<path d="M12 36 L24 48 L32 36" strokeLinecap="round" strokeLinejoin="round" />
<path d="M52 36 L40 48 L32 36" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
export default function DashboardPage() {
const { user, logout } = useAuth()
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="py-6 text-center">
<h1 className="text-3xl font-semibold text-teal-700">
Digital Twin Requirements Tool
</h1>
</header>
{/* Top Bar */}
<div className="border-y border-gray-200 py-3 px-8">
<div className="flex items-center justify-between max-w-7xl mx-auto">
{/* Breadcrumb */}
<div className="text-sm">
<span className="text-gray-600">Projects</span>
<span className="mx-2 text-gray-400">»</span>
<span className="font-semibold text-gray-900">PeTWIN</span>
</div>
{/* Language Toggle */}
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>English</span>
<div className="relative inline-flex h-5 w-10 items-center rounded-full bg-gray-300">
<span className="inline-block h-4 w-4 transform rounded-full bg-white shadow-sm translate-x-0.5" />
</div>
<span>Portuguese</span>
</div>
{/* Admin Panel Button */}
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50">
Admin Panel
</button>
{/* User Info */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg>
<span className="text-sm text-gray-700">
{user?.full_name || user?.preferred_username || 'User'}{' '}
<span className="text-gray-500">(admin)</span>
</span>
</div>
<button
onClick={logout}
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
>
Logout
</button>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-8 py-8">
<div className="flex gap-12">
{/* Left Sidebar */}
<div className="w-64 flex-shrink-0">
{/* Requirements Search */}
<div className="mb-8">
<h2 className="text-lg font-bold text-gray-800 mb-1">Requirements Search</h2>
<p className="text-sm text-teal-600">
Search for specific elements by name or symbol.
</p>
</div>
{/* Create a Requirement */}
<div className="mb-8">
<h2 className="text-lg font-bold text-gray-800 mb-1">Create a Requirement</h2>
<p className="text-sm text-teal-600">
Register a new Requirement.
</p>
</div>
{/* Last Viewed Requirement */}
<div className="mb-8">
<h2 className="text-lg font-bold text-gray-800 mb-1">Last Viewed Requirement:</h2>
<p className="text-sm text-gray-600 mb-1">No requirement accessed yet</p>
<a href="#" className="text-sm text-teal-600 hover:underline">
View More
</a>
</div>
{/* My Requirements */}
<div className="mb-8">
<h2 className="text-lg font-bold text-gray-800 mb-1">My Requirements</h2>
<p className="text-sm text-teal-600">
View your requirements and their properties.
</p>
</div>
{/* Requirement Report */}
<div className="mb-8">
<h2 className="text-lg font-bold text-gray-800 mb-1">Requirement Report</h2>
<p className="text-sm text-teal-600">
Generate the current status of this projects requirements in PDF format
</p>
</div>
</div>
{/* Right Content - Quick Search Filters */}
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-700 mb-6 text-center">
Quick Search Filters
</h2>
{/* Grid Layout matching the screenshot */}
<div className="grid grid-cols-4 gap-0 max-w-2xl mx-auto">
{/* Row 1 */}
{/* Data Services - spans 2 rows */}
<div className="row-span-2 bg-blue-200 border border-blue-300 flex flex-col items-center justify-center p-4 cursor-pointer hover:bg-blue-300 transition-colors min-h-[200px]">
<div className="text-blue-800">
<DataServicesIcon />
</div>
<span className="mt-3 text-sm font-semibold text-blue-900">Data Services</span>
</div>
{/* Integration - 1 row */}
<div className="bg-amber-200 border border-amber-300 flex flex-col items-center justify-center p-4 cursor-pointer hover:bg-amber-300 transition-colors min-h-[100px]">
<div className="text-amber-800">
<IntegrationIcon />
</div>
<span className="mt-2 text-sm font-semibold text-amber-900">Integration</span>
</div>
{/* Intelligence - spans 2 rows */}
<div className="row-span-2 bg-purple-200 border border-purple-300 flex flex-col items-center justify-center p-4 cursor-pointer hover:bg-purple-300 transition-colors min-h-[200px]">
<div className="text-purple-800">
<IntelligenceIcon />
</div>
<span className="mt-3 text-sm font-semibold text-purple-900">Intelligence</span>
</div>
{/* User Experience - spans 2 rows */}
<div className="row-span-2 bg-green-200 border border-green-300 flex flex-col items-center justify-center p-4 cursor-pointer hover:bg-green-300 transition-colors min-h-[200px]">
<div className="text-green-800">
<UserExperienceIcon />
</div>
<span className="mt-3 text-sm font-semibold text-green-900">User Experience</span>
</div>
{/* Row 2 - Management and Trustworthiness */}
{/* Management - 1 row, spans 1 col */}
<div className="bg-red-200 border border-red-300 flex flex-col items-center justify-center p-4 cursor-pointer hover:bg-red-300 transition-colors min-h-[100px]">
<div className="text-red-800">
<ManagementIcon />
</div>
<span className="mt-2 text-sm font-semibold text-red-900">Management</span>
</div>
{/* Trustworthiness - 1 row */}
<div className="bg-green-100 border border-green-300 flex flex-col items-center justify-center p-4 cursor-pointer hover:bg-green-200 transition-colors min-h-[100px]">
<div className="text-red-400">
<TrustworthinessIcon />
</div>
<span className="mt-2 text-sm font-semibold text-red-600">Trustworthiness</span>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import { useAuth } from '@/hooks'
export default function HomePage() {
const { isAuthenticated, login, isLoading } = useAuth()
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary-200 border-t-primary-600" />
</div>
)
}
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl md:text-6xl">
Welcome to{' '}
<span className="text-primary-600">Keycloak Auth</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-gray-600">
A secure authentication system powered by Keycloak and FastAPI. Login
with your Keycloak credentials to access protected resources.
</p>
{!isAuthenticated && (
<div className="mt-10">
<button
onClick={login}
className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-8 py-4 text-lg font-semibold text-white shadow-lg transition-all hover:bg-primary-700 hover:shadow-xl focus:outline-none focus:ring-4 focus:ring-primary-300"
>
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
Login with Keycloak
</button>
</div>
)}
{isAuthenticated && (
<div className="mt-10">
<a
href="/dashboard"
className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-8 py-4 text-lg font-semibold text-white shadow-lg transition-all hover:bg-primary-700 hover:shadow-xl focus:outline-none focus:ring-4 focus:ring-primary-300"
>
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
Go to Dashboard
</a>
</div>
)}
</div>
{/* Features Section */}
<div className="mt-20 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-100">
<svg
className="h-6 w-6 text-primary-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900">
Secure Authentication
</h3>
<p className="text-gray-600">
HTTP-only cookies protect your tokens from XSS attacks.
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-100">
<svg
className="h-6 w-6 text-primary-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900">
Keycloak Integration
</h3>
<p className="text-gray-600">
Enterprise-grade identity management with Keycloak SSO.
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-100">
<svg
className="h-6 w-6 text-primary-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900">
FastAPI Backend
</h3>
<p className="text-gray-600">
High-performance Python backend with automatic API documentation.
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { default as HomePage } from './HomePage'
export { default as DashboardPage } from './DashboardPage'

View File

@@ -0,0 +1,67 @@
import type { User } from '@/types'
const API_BASE_URL = '/api'
class AuthService {
/**
* Get the current authenticated user from the session cookie.
* Returns null if not authenticated.
*/
async getCurrentUser(): Promise<User | null> {
try {
const response = await fetch(`${API_BASE_URL}/auth/me`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
if (response.status === 401) {
return null
}
throw new Error(`HTTP error! status: ${response.status}`)
}
const user: User = await response.json()
return user
} catch (error) {
console.error('Failed to get current user:', error)
return null
}
}
/**
* Logout the current user by clearing the session cookie.
*/
async logout(): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
} catch (error) {
console.error('Logout failed:', error)
throw error
}
}
/**
* Redirect to the login endpoint to initiate OAuth flow.
*/
login(): void {
// In development with Vite proxy, we can use relative URL
// In production, this will be proxied by nginx
window.location.href = `${API_BASE_URL}/login`
}
}
export const authService = new AuthService()

View File

@@ -0,0 +1 @@
export { authService } from './authService'

View File

@@ -0,0 +1,14 @@
export interface User {
preferred_username: string
email: string | null
full_name: string | null
}
export interface AuthContextType {
user: User | null
isLoading: boolean
isAuthenticated: boolean
login: () => void
logout: () => Promise<void>
refreshUser: () => Promise<void>
}

View File

@@ -0,0 +1 @@
export * from './auth'

6
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.css' {
const content: string
export default content
}

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
},
},
},
plugins: [],
}

32
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})