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