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

40
.env.example Normal file
View File

@@ -0,0 +1,40 @@
# ===========================================
# Environment Configuration
# ===========================================
# Copy this file to .env and fill in the values
# -------------------------------------------
# Keycloak Configuration
# -------------------------------------------
# Internal URL for backend-to-Keycloak communication (inside Docker network)
KEYCLOAK_SERVER_URL=http://keycloak:8080/
# External URL for browser redirects (accessible from host machine)
KEYCLOAK_EXTERNAL_URL=http://localhost:8081/
KEYCLOAK_REALM=your-realm
KEYCLOAK_CLIENT_ID=your-client-id
KEYCLOAK_CLIENT_SECRET=your-client-secret
# -------------------------------------------
# Frontend Configuration
# -------------------------------------------
# URL where the React frontend is running
FRONTEND_URL=http://localhost:3000
# -------------------------------------------
# Cookie Configuration
# -------------------------------------------
# Name of the authentication cookie
COOKIE_NAME=access_token
# Set to true in production with HTTPS
COOKIE_SECURE=false
# Cookie SameSite policy: "lax", "strict", or "none"
# Use "lax" for most cases, "none" for cross-site (requires COOKIE_SECURE=true)
COOKIE_SAMESITE=lax
# Cookie domain (leave empty for current domain, or set to .yourdomain.com for subdomains)
COOKIE_DOMAIN=
# Cookie max age in seconds (default: 3600 = 1 hour)
COOKIE_MAX_AGE=3600

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Environment files
.env
*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.venv/
venv/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
frontend/dist/
*.tsbuildinfo
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

243
README.md Normal file
View File

@@ -0,0 +1,243 @@
# FastAPI Keycloak Integration with React Frontend
## Table of Contents
- [Introduction](#introduction)
- [Features](#features)
- [Project Structure](#project-structure)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [Authentication Flow](#authentication-flow)
- [Development](#development)
- [Contributing](#contributing)
- [License](#license)
## Introduction
This project provides a full-stack authentication solution using FastAPI as the backend, React (with TypeScript and Tailwind CSS) as the frontend, and Keycloak as the identity provider. Authentication is handled securely using HTTP-only cookies to protect tokens from XSS attacks.
## Features
- **FastAPI Backend**: A modern, fast web framework for building APIs with Python 3.12+
- **React Frontend**: TypeScript-based React application with Tailwind CSS for styling
- **Keycloak Integration**: Enterprise-grade identity management with OAuth2/OpenID Connect
- **Secure Cookie-Based Auth**: HTTP-only cookies protect access tokens from XSS attacks
- **Dockerized Setup**: Full Docker Compose setup including Keycloak, backend, and frontend
- **Poetry for Python Dependencies**: Simplified Python dependency management
## Project Structure
```
.
├── .env.example # Environment variables template
├── docker-compose.yaml # Production Docker Compose
├── docker-compose.dev.yaml # Development Docker Compose
├── README.md
├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── poetry.lock
│ └── src/
│ ├── __init__.py
│ ├── config.py # Keycloak & cookie settings
│ ├── controller.py # Request handlers
│ ├── main.py # FastAPI app with CORS
│ ├── models.py # Pydantic models
│ └── service.py # Keycloak auth service
└── frontend/
├── Dockerfile
├── nginx.conf # Production nginx config
├── package.json
├── tsconfig.json
├── tailwind.config.js
├── vite.config.ts
└── src/
├── App.tsx
├── main.tsx
├── index.css
├── components/ # Reusable UI components
├── context/ # React Context (AuthContext)
├── hooks/ # Custom hooks (useAuth)
├── pages/ # Page components
├── services/ # API service layer
└── types/ # TypeScript types
```
## Prerequisites
- [Docker](https://www.docker.com/get-started) and [Docker Compose](https://docs.docker.com/compose/install/)
- [Node.js 20+](https://nodejs.org/) (for local frontend development)
- [Python 3.12+](https://www.python.org/downloads/) (for local backend development)
- [Git](https://git-scm.com/)
## Installation
1. **Clone the Repository**
```bash
git clone https://github.com/your-repo/fastapi-keycloak.git
cd fastapi-keycloak
```
2. **Copy Environment Variables File**
```bash
cp .env.example .env
```
3. **Configure Environment Variables**
Open the `.env` file and update the following variables:
```env
# Keycloak Configuration
KEYCLOAK_SERVER_URL=http://localhost:8081/
KEYCLOAK_REALM=your-realm
KEYCLOAK_CLIENT_ID=your-client-id
KEYCLOAK_CLIENT_SECRET=your-client-secret
# Frontend URL (for CORS and redirects)
FRONTEND_URL=http://localhost:3000
# Cookie Configuration
COOKIE_SECURE=false # Set to true in production with HTTPS
COOKIE_SAMESITE=lax
COOKIE_MAX_AGE=3600
```
## Configuration
### Setting Up Keycloak
1. **Start the Services**
```bash
docker-compose up keycloak -d
```
2. **Access the Keycloak Admin Console**
Open `http://localhost:8081/` and login with:
- Username: `admin`
- Password: `admin`
3. **Create a New Realm**
- Click "Create Realm"
- Name it (e.g., `my-app`)
- Click "Create"
4. **Create a New Client**
- Go to Clients → Create client
- Client ID: e.g., `my-app-client`
- Client Protocol: `openid-connect`
- Click Next
- Enable "Client authentication" (confidential)
- Enable "Standard flow" and "Direct access grants"
- Click Next
- Valid redirect URIs: `http://localhost:8080/*`
- Web origins: `http://localhost:3000`
- Click Save
5. **Get Client Secret**
- Go to Credentials tab
- Copy the "Client secret"
- Update your `.env` file
6. **Create a Test User**
- Go to Users → Add user
- Username: `testuser`
- Email: `test@example.com`
- Click Create
- Go to Credentials tab → Set password
- Disable "Temporary"
## Usage
### Running with Docker (Production)
```bash
# Start all services
docker-compose up --build
# Services will be available at:
# - Frontend: http://localhost:3000
# - Backend API: http://localhost:8080
# - Keycloak: http://localhost:8081
```
### Running with Docker (Development)
For development with hot-reload on the backend:
```bash
# Start Keycloak and Backend only
docker-compose -f docker-compose.dev.yaml up --build
# In another terminal, start the frontend dev server
cd frontend
npm install
npm run dev
```
## Authentication Flow
1. **User clicks "Login"** → Frontend redirects to `/api/login`
2. **Backend redirects to Keycloak** → User authenticates with Keycloak
3. **Keycloak redirects back** → Backend receives authorization code at `/api/callback`
4. **Backend exchanges code for token** → Sets HTTP-only cookie with access token
5. **Backend redirects to frontend** → User lands on `/dashboard`
6. **Frontend calls `/api/auth/me`** → Backend validates cookie and returns user info
### API Endpoints
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api` | GET | No | Welcome message and docs link |
| `/api/login` | GET | No | Initiates OAuth2 login flow |
| `/api/callback` | GET | No | OAuth2 callback (sets cookie) |
| `/api/auth/me` | GET | Cookie | Get current user info |
| `/api/auth/logout` | POST | Cookie | Clear auth cookie |
| `/api/protected` | GET | Bearer | Protected endpoint (token in header) |
| `/docs` | GET | No | Swagger API documentation |
## Development
### Backend Development
```bash
cd backend
poetry install
poetry run uvicorn src.main:app --reload --port 8080
```
### Frontend Development
```bash
cd frontend
npm install
npm run dev
```
The frontend dev server runs on `http://localhost:3000` and proxies `/api` requests to the backend.
### Environment Variables
See `.env.example` for all available configuration options:
- **Keycloak settings**: Server URL, realm, client ID/secret
- **Frontend URL**: For CORS and OAuth redirects
- **Cookie settings**: Security options for the auth cookie
## Contributing
Contributions are welcome! Please feel free to submit a pull request or open an issue.
## License
This project is licensed under the MIT License.

26
backend/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.12-slim
ENV POETRY_VERSION=1.6.1 \
POETRY_NO_INTERACTION=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y curl build-essential \
&& apt-get clean
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
COPY pyproject.toml ./
RUN poetry lock --no-update && poetry install --no-root
COPY . .
EXPOSE 8080
CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"]

1336
backend/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,18 @@
[tool.poetry]
name = "periodic-table"
version = "0.1.0"
description = "FastAPI Keycloak Authentication"
authors = ["OntoKG"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
python-keycloak = "^5.8.1"
fastapi = "^0.115.0"
pydantic = "^2.12.4"
pydantic-settings = "^2.12.0"
uvicorn = {extras = ["standard"], version = "^0.32.0"}
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

0
backend/src/__init__.py Normal file
View File

49
backend/src/config.py Normal file
View File

@@ -0,0 +1,49 @@
from pydantic_settings import BaseSettings
from pydantic import Field
from keycloak import KeycloakOpenID
class Settings(BaseSettings):
# Keycloak settings
keycloak_server_url: str = Field(..., env="KEYCLOAK_SERVER_URL")
keycloak_external_url: str = Field(..., env="KEYCLOAK_EXTERNAL_URL")
keycloak_realm: str = Field(..., env="KEYCLOAK_REALM")
keycloak_client_id: str = Field(..., env="KEYCLOAK_CLIENT_ID")
keycloak_client_secret: str = Field(..., env="KEYCLOAK_CLIENT_SECRET")
# Frontend settings
frontend_url: str = Field(default="http://localhost:3000", env="FRONTEND_URL")
# Cookie settings
cookie_secure: bool = Field(default=False, env="COOKIE_SECURE")
cookie_samesite: str = Field(default="lax", env="COOKIE_SAMESITE")
cookie_domain: str | None = Field(default=None, env="COOKIE_DOMAIN")
cookie_max_age: int = Field(default=3600, env="COOKIE_MAX_AGE")
cookie_name: str = Field(default="access_token", env="COOKIE_NAME")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
keycloak_openid = KeycloakOpenID(
server_url=settings.keycloak_server_url,
realm_name=settings.keycloak_realm,
client_id=settings.keycloak_client_id,
client_secret_key=settings.keycloak_client_secret,
verify=False
)
def get_openid_config():
return keycloak_openid.well_known()
def get_openid():
return keycloak_openid
def get_settings():
return settings

169
backend/src/controller.py Normal file
View File

@@ -0,0 +1,169 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import RedirectResponse, JSONResponse
from src.models import TokenResponse, UserInfo
from src.service import AuthService
from src.config import get_settings
# Initialize HTTPBearer security dependency
bearer_scheme = HTTPBearer()
# Get settings
settings = get_settings()
class AuthController:
"""
Controller for handling authentication logic.
"""
@staticmethod
def read_root():
"""
Root endpoint providing basic information and documentation link.
Returns:
dict: A welcome message and link to the documentation.
"""
return {
"message": (
"Welcome to the Keycloak authentication system. "
"Use the /api/login endpoint to authenticate and /api/auth/me "
"endpoint to access the authenticated user information."
),
"documentation": "/docs",
}
@staticmethod
def login(keycode: str, request: Request) -> RedirectResponse:
"""
Authenticate user, set HTTP-only cookie, and redirect to frontend.
Args:
keycode (str): The authorization code from Keycloak.
request (Request): The FastAPI request object.
Raises:
HTTPException: If the authentication fails.
Returns:
RedirectResponse: Redirects to frontend with cookie set.
"""
# Authenticate the user using the AuthService
access_token = AuthService.authenticate_user(keycode, request)
if not access_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication failed",
)
# Create redirect response to frontend
response = RedirectResponse(
url=f"{settings.frontend_url}/dashboard",
status_code=status.HTTP_302_FOUND
)
# Set HTTP-only cookie with the access token
response.set_cookie(
key=settings.cookie_name,
value=access_token,
httponly=True,
secure=settings.cookie_secure,
samesite=settings.cookie_samesite,
max_age=settings.cookie_max_age,
domain=settings.cookie_domain,
path="/",
)
return response
@staticmethod
def get_current_user(request: Request) -> UserInfo:
"""
Get the current authenticated user from the session cookie.
Args:
request (Request): The FastAPI request object.
Raises:
HTTPException: If no valid session cookie exists.
Returns:
UserInfo: Information about the authenticated user.
"""
# Extract the token from the cookie
token = request.cookies.get(settings.cookie_name)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify the token and get user information
user_info = AuthService.verify_token(token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
headers={"WWW-Authenticate": "Bearer"},
)
return user_info
@staticmethod
def logout() -> JSONResponse:
"""
Logout the user by clearing the authentication cookie.
Returns:
JSONResponse: Success message with cookie cleared.
"""
response = JSONResponse(
content={"message": "Successfully logged out"},
status_code=status.HTTP_200_OK
)
# Clear the authentication cookie
response.delete_cookie(
key=settings.cookie_name,
path="/",
domain=settings.cookie_domain,
)
return response
@staticmethod
def protected_endpoint(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> UserInfo:
"""
Access a protected resource that requires valid token authentication.
Args:
credentials (HTTPAuthorizationCredentials): Bearer token provided
via HTTP Authorization header.
Raises:
HTTPException: If the token is invalid or not provided.
Returns:
UserInfo: Information about the authenticated user.
"""
# Extract the bearer token from the provided credentials
token = credentials.credentials
# Verify the token and get user information
user_info = AuthService.verify_token(token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
return user_info

118
backend/src/main.py Normal file
View File

@@ -0,0 +1,118 @@
from fastapi import FastAPI, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import RedirectResponse
from src.models import TokenResponse, UserInfo
from src.controller import AuthController
from src.config import get_openid, get_settings
# Initialize the FastAPI app
app = FastAPI(title="Keycloak Auth API", version="1.0.0")
# Get settings
settings = get_settings()
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize the HTTPBearer scheme for authentication
bearer_scheme = HTTPBearer()
# Configure client
keycloak_openid = get_openid()
# Define the root endpoint
@app.get("/api")
async def read_root():
"""
Root endpoint that provides a welcome message and documentation link.
"""
return AuthController.read_root()
# Define the login endpoint
@app.get("/api/login", response_class=RedirectResponse)
async def login(request: Request):
"""
Login endpoint to authenticate the user and return an access token.
Returns:
RedirectResponse: Contains the redirect URL upon successful authentication.
"""
# Build the callback URI using the frontend URL (accessible from browser)
# This ensures the redirect works correctly through nginx proxy
redirect_uri = f"{settings.frontend_url}/api/callback"
# Construct the authorization URL with external Keycloak URL
auth_url = (
f"{settings.keycloak_external_url}realms/{settings.keycloak_realm}"
f"/protocol/openid-connect/auth"
f"?client_id={settings.keycloak_client_id}"
f"&response_type=code"
f"&redirect_uri={redirect_uri}"
f"&scope=openid%20profile%20email"
)
return RedirectResponse(auth_url)
# Define the callback endpoint
@app.get("/api/callback", include_in_schema=False)
async def callback(request: Request):
"""
OAuth callback endpoint that exchanges the authorization code for a token
and sets it as an HTTP-only cookie.
"""
# Extract the code from the URL
keycode = request.query_params.get('code')
return AuthController.login(str(keycode), request)
# Define the auth/me endpoint to get current user from cookie
@app.get("/api/auth/me", response_model=UserInfo)
async def get_current_user(request: Request):
"""
Get the current authenticated user from the session cookie.
Returns:
UserInfo: Information about the authenticated user.
"""
return AuthController.get_current_user(request)
# Define the logout endpoint
@app.post("/api/auth/logout")
async def logout(request: Request):
"""
Logout endpoint that clears the authentication cookie.
Returns:
dict: Success message.
"""
return AuthController.logout()
# Define the protected endpoint (kept for API token-based access)
@app.get("/api/protected", response_model=UserInfo)
async def protected_endpoint(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
):
"""
Protected endpoint that requires a valid token for access.
Args:
credentials (HTTPAuthorizationCredentials):
Bearer token provided via HTTP Authorization header.
Returns:
UserInfo: Information about the authenticated user.
"""
return AuthController.protected_endpoint(credentials)

18
backend/src/models.py Normal file
View File

@@ -0,0 +1,18 @@
from typing import Optional
from pydantic import BaseModel, SecretStr
class TokenRequest(BaseModel):
username: str
password: SecretStr
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserInfo(BaseModel):
preferred_username: str
email: Optional[str] = None
full_name: Optional[str] = None

91
backend/src/service.py Normal file
View File

@@ -0,0 +1,91 @@
from fastapi import HTTPException, status, Request
from keycloak.exceptions import KeycloakAuthenticationError, KeycloakPostError
from keycloak import KeycloakOpenID
from src.config import get_settings
from src.models import UserInfo
import logging
logger = logging.getLogger(__name__)
settings = get_settings()
# Create a fresh KeycloakOpenID instance for token exchange
def get_keycloak_openid():
return KeycloakOpenID(
server_url=settings.keycloak_server_url,
realm_name=settings.keycloak_realm,
client_id=settings.keycloak_client_id,
client_secret_key=settings.keycloak_client_secret,
verify=False
)
class AuthService:
@staticmethod
def authenticate_user(keycode: str, request: Request) -> str:
"""
Authenticate the user using Keycloak and return an access token.
"""
try:
# Use the same redirect_uri that was used in the login endpoint
redirect_uri = f"{settings.frontend_url}/api/callback"
logger.info(f"=== Token Exchange Debug ===")
logger.info(f"Keycloak Server URL: {settings.keycloak_server_url}")
logger.info(f"Realm: {settings.keycloak_realm}")
logger.info(f"Client ID: {settings.keycloak_client_id}")
logger.info(f"Client Secret (first 5 chars): {settings.keycloak_client_secret[:5]}...")
logger.info(f"Redirect URI: {redirect_uri}")
logger.info(f"Auth Code (first 10 chars): {keycode[:10]}...")
# Get fresh KeycloakOpenID instance
keycloak_openid = get_keycloak_openid()
token = keycloak_openid.token(
grant_type='authorization_code',
code=keycode,
redirect_uri=redirect_uri,
)
logger.info("Token exchange successful")
return token["access_token"]
except KeycloakAuthenticationError as exc:
logger.error(f"KeycloakAuthenticationError: {exc}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid Login: {str(exc)}",
) from exc
except KeycloakPostError as exc:
logger.error(f"KeycloakPostError: {exc}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid Grant: {str(exc)}",
) from exc
except Exception as exc:
logger.error(f"Unexpected error during token exchange: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token exchange failed: {str(exc)}",
) from exc
@staticmethod
def verify_token(token: str) -> UserInfo:
"""
Verify the given token and return user information.
"""
try:
keycloak_openid = get_keycloak_openid()
user_info = keycloak_openid.userinfo(token)
print(user_info)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)
return UserInfo(
preferred_username=user_info["preferred_username"],
email=user_info.get("email"),
full_name=user_info.get("name"),
)
except KeycloakAuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
) from exc

45
docker-compose.dev.yaml Normal file
View File

@@ -0,0 +1,45 @@
# Development docker-compose
# Use this for local development with hot reload
# Run: docker-compose -f docker-compose.dev.yaml up
services:
# ===========================================
# Keycloak Identity Provider
# ===========================================
keycloak:
image: quay.io/keycloak/keycloak:latest
container_name: keycloak
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8081:8080"
command: start-dev
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# ===========================================
# FastAPI Backend (with hot reload)
# ===========================================
fastapi-app:
build:
context: ./backend
dockerfile: Dockerfile
container_name: fastapi-app
ports:
- "8080:8080"
volumes:
- ./backend:/app
env_file:
- .env
depends_on:
keycloak:
condition: service_healthy
networks:
default:
name: keycloak-auth-network

65
docker-compose.yaml Normal file
View File

@@ -0,0 +1,65 @@
services:
# ===========================================
# Keycloak Identity Provider
# ===========================================
keycloak:
image: quay.io/keycloak/keycloak:latest
container_name: keycloak
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
# Set hostname so tokens are issued with consistent issuer URL
KC_HOSTNAME: http://localhost:8081
KC_HOSTNAME_STRICT: false
KC_HTTP_ENABLED: true
ports:
- "8081:8080"
command: start-dev
volumes:
- keycloak_data:/opt/keycloak/data
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# ===========================================
# FastAPI Backend
# ===========================================
fastapi-app:
build:
context: ./backend
dockerfile: Dockerfile
container_name: fastapi-app
ports:
- "8080:8080"
volumes:
- ./backend:/app
env_file:
- .env
extra_hosts:
- "localhost:host-gateway"
depends_on:
keycloak:
condition: service_healthy
# ===========================================
# React Frontend
# ===========================================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: frontend
ports:
- "3000:80"
depends_on:
- fastapi-app
networks:
default:
name: keycloak-auth-network
volumes:
keycloak_data:

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,
},
},
},
})