initial commit
This commit is contained in:
40
.env.example
Normal file
40
.env.example
Normal 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
48
.gitignore
vendored
Normal 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
243
README.md
Normal 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
26
backend/Dockerfile
Normal 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
1336
backend/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/pyproject.toml
Normal file
18
backend/pyproject.toml
Normal 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
0
backend/src/__init__.py
Normal file
49
backend/src/config.py
Normal file
49
backend/src/config.py
Normal 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
169
backend/src/controller.py
Normal 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
118
backend/src/main.py
Normal 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
18
backend/src/models.py
Normal 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
91
backend/src/service.py
Normal 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
45
docker-compose.dev.yaml
Normal 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
65
docker-compose.yaml
Normal 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
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Vite API URL (used during build)
|
||||||
|
VITE_API_URL=/api
|
||||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
70
frontend/nginx.conf
Normal 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
33
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
30
frontend/src/App.tsx
Normal file
30
frontend/src/App.tsx
Normal 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
|
||||||
27
frontend/src/components/Layout.tsx
Normal file
27
frontend/src/components/Layout.tsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} Keycloak Auth App. All rights
|
||||||
|
reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
frontend/src/components/Navbar.tsx
Normal file
72
frontend/src/components/Navbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
frontend/src/components/ProtectedRoute.tsx
Normal file
28
frontend/src/components/ProtectedRoute.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
3
frontend/src/components/index.ts
Normal file
3
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as Layout } from './Layout'
|
||||||
|
export { default as Navbar } from './Navbar'
|
||||||
|
export { default as ProtectedRoute } from './ProtectedRoute'
|
||||||
64
frontend/src/context/AuthContext.tsx
Normal file
64
frontend/src/context/AuthContext.tsx
Normal 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>
|
||||||
|
}
|
||||||
1
frontend/src/context/index.ts
Normal file
1
frontend/src/context/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AuthContext, AuthProvider } from './AuthContext'
|
||||||
1
frontend/src/hooks/index.ts
Normal file
1
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useAuth } from './useAuth'
|
||||||
13
frontend/src/hooks/useAuth.ts
Normal file
13
frontend/src/hooks/useAuth.ts
Normal 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
7
frontend/src/index.css
Normal 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
16
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
245
frontend/src/pages/DashboardPage.tsx
Normal file
245
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
151
frontend/src/pages/HomePage.tsx
Normal file
151
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
frontend/src/pages/index.ts
Normal file
2
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as HomePage } from './HomePage'
|
||||||
|
export { default as DashboardPage } from './DashboardPage'
|
||||||
67
frontend/src/services/authService.ts
Normal file
67
frontend/src/services/authService.ts
Normal 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()
|
||||||
1
frontend/src/services/index.ts
Normal file
1
frontend/src/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { authService } from './authService'
|
||||||
14
frontend/src/types/auth.ts
Normal file
14
frontend/src/types/auth.ts
Normal 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>
|
||||||
|
}
|
||||||
1
frontend/src/types/index.ts
Normal file
1
frontend/src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './auth'
|
||||||
6
frontend/src/vite-env.d.ts
vendored
Normal file
6
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.css' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
27
frontend/tailwind.config.js
Normal file
27
frontend/tailwind.config.js
Normal 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
32
frontend/tsconfig.json
Normal 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
22
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user