Added DB connection and started creating api calls for the pages

This commit is contained in:
gulimabr
2025-11-30 15:17:23 -03:00
parent b5381ae376
commit bbbe65067b
20 changed files with 1403 additions and 152 deletions

View File

@@ -38,3 +38,16 @@ COOKIE_DOMAIN=
# Cookie max age in seconds (default: 3600 = 1 hour)
COOKIE_MAX_AGE=3600
# -------------------------------------------
# Database Configuration (PostgreSQL)
# -------------------------------------------
# Database host (your PostgreSQL server address)
DATABASE_HOST=your-postgres-host
DATABASE_PORT=5432
DATABASE_NAME=db-name
DATABASE_USER=your-username
DATABASE_PASSWORD=your-password
# Set to true to log all SQL queries (useful for debugging)
DATABASE_ECHO=false

3
.gitignore vendored
View File

@@ -46,3 +46,6 @@ frontend/dist/
# OS
.DS_Store
Thumbs.db
# Other
*.sql

View File

@@ -12,6 +12,9 @@ fastapi = "^0.115.0"
pydantic = "^2.12.4"
pydantic-settings = "^2.12.0"
uvicorn = {extras = ["standard"], version = "^0.32.0"}
sqlalchemy = {extras = ["asyncio"], version = "^2.0.0"}
asyncpg = "^0.30.0"
greenlet = "^3.1.0"
[build-system]
requires = ["poetry-core>=1.0.0"]

View File

@@ -21,6 +21,22 @@ class Settings(BaseSettings):
cookie_max_age: int = Field(default=3600, env="COOKIE_MAX_AGE")
cookie_name: str = Field(default="access_token", env="COOKIE_NAME")
# Database settings
database_host: str = Field(default="postgres", env="DATABASE_HOST")
database_port: int = Field(default=5432, env="DATABASE_PORT")
database_name: str = Field(default="periodic_table", env="DATABASE_NAME")
database_user: str = Field(default="postgres", env="DATABASE_USER")
database_password: str = Field(default="postgres", env="DATABASE_PASSWORD")
database_echo: bool = Field(default=False, env="DATABASE_ECHO")
@property
def database_url(self) -> str:
"""Construct the async database URL for SQLAlchemy."""
return (
f"postgresql+asyncpg://{self.database_user}:{self.database_password}"
f"@{self.database_host}:{self.database_port}/{self.database_name}"
)
class Config:
env_file = ".env"
env_file_encoding = "utf-8"

View File

@@ -1,9 +1,11 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import TokenResponse, UserInfo
from src.service import AuthService
from src.service import AuthService, UserService
from src.config import get_settings
from src.database import get_db
# Initialize HTTPBearer security dependency
bearer_scheme = HTTPBearer()
@@ -35,13 +37,15 @@ class AuthController:
}
@staticmethod
def login(keycode: str, request: Request) -> RedirectResponse:
async def login(keycode: str, request: Request, db: AsyncSession) -> RedirectResponse:
"""
Authenticate user, set HTTP-only cookie, and redirect to frontend.
Authenticate user, provision in database if needed, set HTTP-only cookie,
and redirect to frontend.
Args:
keycode (str): The authorization code from Keycloak.
request (Request): The FastAPI request object.
db (AsyncSession): Database session for user provisioning.
Raises:
HTTPException: If the authentication fails.
@@ -50,7 +54,8 @@ class AuthController:
RedirectResponse: Redirects to frontend with cookie set.
"""
# Authenticate the user using the AuthService
access_token = AuthService.authenticate_user(keycode, request)
token_response = AuthService.authenticate_user(keycode, request)
access_token = token_response.get("access_token")
if not access_token:
raise HTTPException(
@@ -58,6 +63,10 @@ class AuthController:
detail="Authentication failed",
)
# Provision user in database (JIT provisioning)
# This creates the user if they don't exist
user_id, is_new_user = await UserService.provision_user_on_login(access_token, db)
# Create redirect response to frontend
response = RedirectResponse(
url=f"{settings.frontend_url}/dashboard",

61
backend/src/database.py Normal file
View File

@@ -0,0 +1,61 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from src.config import get_settings
settings = get_settings()
# Create async engine for PostgreSQL
engine = create_async_engine(
settings.database_url,
echo=settings.database_echo,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
# Base class for SQLAlchemy models
Base = declarative_base()
async def get_db() -> AsyncSession:
"""
Dependency that provides a database session.
Use with FastAPI's Depends() for automatic session management.
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""
Initialize the database by creating all tables.
This should be called on application startup.
"""
async with engine.begin() as conn:
# Import all models here to ensure they are registered
from src import db_models # noqa: F401
await conn.run_sync(Base.metadata.create_all)
async def close_db():
"""
Close the database connection pool.
This should be called on application shutdown.
"""
await engine.dispose()

230
backend/src/db_models.py Normal file
View File

@@ -0,0 +1,230 @@
"""
SQLAlchemy ORM models for the Periodic Table Requirements application.
Based on the database schema defined in periodic-table.sql
"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import (
Integer, String, Text, ForeignKey, DateTime, Boolean,
UniqueConstraint, Index
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.database import Base
class Role(Base):
"""User roles for access control."""
__tablename__ = "roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
role_name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
# Relationships
users: Mapped[List["User"]] = relationship("User", back_populates="role")
class User(Base):
"""
Users table - populated on first login via Keycloak.
The 'sub' field is the Keycloak subject ID (unique identifier).
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sub: Mapped[str] = mapped_column(Text, nullable=False, unique=True) # Keycloak subject ID
role_id: Mapped[int] = mapped_column(Integer, ForeignKey("roles.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=True
)
# Relationships
role: Mapped["Role"] = relationship("Role", back_populates="users")
requirements: Mapped[List["Requirement"]] = relationship(
"Requirement",
back_populates="user",
foreign_keys="Requirement.user_id"
)
edited_requirements: Mapped[List["Requirement"]] = relationship(
"Requirement",
back_populates="last_editor",
foreign_keys="Requirement.last_editor_id"
)
validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="user")
class Tag(Base):
"""Requirement tags (e.g., GSR, SFR)."""
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tag_code: Mapped[str] = mapped_column(String(10), nullable=False, unique=True)
tag_description: Mapped[str] = mapped_column(Text, nullable=False)
# Relationships
requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="tag")
class Group(Base):
"""Requirement groups for categorization."""
__tablename__ = "groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
group_name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
hex_color: Mapped[str] = mapped_column(String(7), nullable=False) # e.g., #FF5733
# Relationships
requirements: Mapped[List["Requirement"]] = relationship(
"Requirement",
secondary="requirements_groups",
back_populates="groups"
)
class Priority(Base):
"""Priority levels for requirements."""
__tablename__ = "priorities"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
priority_name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
priority_num: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships
requirements: Mapped[List["Requirement"]] = relationship("Requirement", back_populates="priority")
class ValidationStatus(Base):
"""Validation status options."""
__tablename__ = "validation_statuses"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
status_name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
# Relationships
validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="status")
class Requirement(Base):
"""Main requirements table."""
__tablename__ = "requirements"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
tag_id: Mapped[int] = mapped_column(Integer, ForeignKey("tags.id"), nullable=False)
last_editor_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("users.id"),
nullable=True
)
req_name: Mapped[str] = mapped_column(Text, nullable=False)
req_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("priorities.id"),
nullable=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=True
)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="requirements",
foreign_keys=[user_id]
)
last_editor: Mapped[Optional["User"]] = relationship(
"User",
back_populates="edited_requirements",
foreign_keys=[last_editor_id]
)
tag: Mapped["Tag"] = relationship("Tag", back_populates="requirements")
priority: Mapped[Optional["Priority"]] = relationship("Priority", back_populates="requirements")
groups: Mapped[List["Group"]] = relationship(
"Group",
secondary="requirements_groups",
back_populates="requirements"
)
validations: Mapped[List["Validation"]] = relationship("Validation", back_populates="requirement")
# Indexes
__table_args__ = (
Index("idx_req_tag", "tag_id"),
Index("idx_req_priority", "priority_id"),
Index("idx_req_user", "user_id"),
)
class RequirementGroup(Base):
"""Join table for many-to-many relationship between requirements and groups."""
__tablename__ = "requirements_groups"
requirement_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("requirements.id", ondelete="CASCADE"),
primary_key=True
)
group_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("groups.id", ondelete="CASCADE"),
primary_key=True
)
class Validation(Base):
"""Validation records for requirements."""
__tablename__ = "validations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
requirement_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("requirements.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
status_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("validation_statuses.id"),
nullable=False
)
req_version_snapshot: Mapped[int] = mapped_column(Integer, nullable=False)
comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=True
)
# Relationships
requirement: Mapped["Requirement"] = relationship("Requirement", back_populates="validations")
user: Mapped["User"] = relationship("User", back_populates="validations")
status: Mapped["ValidationStatus"] = relationship("ValidationStatus", back_populates="validations")
class RequirementHistory(Base):
"""
Historical records of requirement changes.
Note: This is populated by a database trigger, not by the application.
"""
__tablename__ = "requirements_history"
history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
original_req_id: Mapped[int] = mapped_column(Integer, nullable=False)
req_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
req_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tag_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
version: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
valid_to: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)

View File

@@ -1,17 +1,60 @@
from contextlib import asynccontextmanager
from typing import List
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 sqlalchemy.ext.asyncio import AsyncSession
from src.models import TokenResponse, UserInfo, GroupResponse
from src.controller import AuthController
from src.config import get_openid, get_settings
from src.database import init_db, close_db, get_db
from src.repositories import RoleRepository, GroupRepository
import logging
# Initialize the FastAPI app
app = FastAPI(title="Keycloak Auth API", version="1.0.0")
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Get settings
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan manager.
Handles startup and shutdown events.
"""
# Startup
logger.info("Starting up application...")
logger.info("Initializing database...")
await init_db()
logger.info("Database initialized successfully")
# Ensure default roles exist
from src.database import AsyncSessionLocal
async with AsyncSessionLocal() as session:
role_repo = RoleRepository(session)
await role_repo.ensure_default_roles_exist()
await session.commit()
logger.info("Default roles ensured")
yield
# Shutdown
logger.info("Shutting down application...")
await close_db()
logger.info("Database connection closed")
# Initialize the FastAPI app
app = FastAPI(
title="Keycloak Auth API",
version="1.0.0",
lifespan=lifespan
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
@@ -65,15 +108,15 @@ async def login(request: Request):
# Define the callback endpoint
@app.get("/api/callback", include_in_schema=False)
async def callback(request: Request):
async def callback(request: Request, db: AsyncSession = Depends(get_db)):
"""
OAuth callback endpoint that exchanges the authorization code for a token
and sets it as an HTTP-only cookie.
OAuth callback endpoint that exchanges the authorization code for a token,
provisions the user in the database if needed, 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)
return await AuthController.login(str(keycode), request, db)
# Define the auth/me endpoint to get current user from cookie
@@ -116,3 +159,20 @@ async def protected_endpoint(
UserInfo: Information about the authenticated user.
"""
return AuthController.protected_endpoint(credentials)
# ===========================================
# Groups Endpoints
# ===========================================
@app.get("/api/groups", response_model=List[GroupResponse])
async def get_groups(db: AsyncSession = Depends(get_db)):
"""
Get all groups.
Returns:
List of all groups with their names and colors.
"""
group_repo = GroupRepository(db)
groups = await group_repo.get_all()
return [GroupResponse.model_validate(g) for g in groups]

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, List
from pydantic import BaseModel, SecretStr
@@ -13,6 +13,25 @@ class TokenResponse(BaseModel):
class UserInfo(BaseModel):
sub: Optional[str] = None # Keycloak subject ID
preferred_username: str
email: Optional[str] = None
full_name: Optional[str] = None
db_user_id: Optional[int] = None # Database user ID (populated after login)
role: Optional[str] = None # User role name
# Group schemas
class GroupResponse(BaseModel):
"""Response schema for a single group."""
id: int
group_name: str
hex_color: str
class Config:
from_attributes = True
class GroupListResponse(BaseModel):
"""Response schema for list of groups."""
groups: List[GroupResponse]

View File

@@ -0,0 +1,11 @@
"""
Repository layer for database operations.
"""
from src.repositories.user_repository import UserRepository, RoleRepository
from src.repositories.group_repository import GroupRepository
__all__ = [
"UserRepository",
"RoleRepository",
"GroupRepository",
]

View File

@@ -0,0 +1,76 @@
"""
Repository layer for Group database operations.
"""
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db_models import Group
import logging
logger = logging.getLogger(__name__)
class GroupRepository:
"""Repository for Group-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_all(self) -> List[Group]:
"""
Get all groups.
Returns:
List of all groups
"""
result = await self.session.execute(
select(Group).order_by(Group.group_name)
)
return list(result.scalars().all())
async def get_by_id(self, group_id: int) -> Optional[Group]:
"""
Get a group by ID.
Args:
group_id: The group ID
Returns:
Group if found, None otherwise
"""
result = await self.session.execute(
select(Group).where(Group.id == group_id)
)
return result.scalar_one_or_none()
async def get_by_name(self, group_name: str) -> Optional[Group]:
"""
Get a group by name.
Args:
group_name: The group name
Returns:
Group if found, None otherwise
"""
result = await self.session.execute(
select(Group).where(Group.group_name == group_name)
)
return result.scalar_one_or_none()
async def create(self, group_name: str, hex_color: str) -> Group:
"""
Create a new group.
Args:
group_name: The group name
hex_color: The hex color code (e.g., #FF5733)
Returns:
The created Group
"""
group = Group(group_name=group_name, hex_color=hex_color)
self.session.add(group)
await self.session.flush()
await self.session.refresh(group)
return group

View File

@@ -0,0 +1,166 @@
"""
Repository layer for User database operations.
Handles CRUD operations and user provisioning on first login.
"""
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.db_models import User, Role
import logging
logger = logging.getLogger(__name__)
# Default role for new users
DEFAULT_ROLE_NAME = "user"
class UserRepository:
"""Repository for User-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_sub(self, sub: str) -> Optional[User]:
"""
Get a user by their Keycloak subject ID.
Args:
sub: The Keycloak subject ID (unique identifier)
Returns:
User if found, None otherwise
"""
result = await self.session.execute(
select(User)
.options(selectinload(User.role))
.where(User.sub == sub)
)
return result.scalar_one_or_none()
async def get_by_id(self, user_id: int) -> Optional[User]:
"""
Get a user by their database ID.
Args:
user_id: The database user ID
Returns:
User if found, None otherwise
"""
result = await self.session.execute(
select(User)
.options(selectinload(User.role))
.where(User.id == user_id)
)
return result.scalar_one_or_none()
async def create(self, sub: str, role_id: int) -> User:
"""
Create a new user.
Args:
sub: The Keycloak subject ID
role_id: The role ID to assign
Returns:
The created User
"""
user = User(sub=sub, role_id=role_id)
self.session.add(user)
await self.session.flush()
await self.session.refresh(user)
return user
async def get_or_create_default_role(self) -> Role:
"""
Get the default user role, creating it if it doesn't exist.
Returns:
The default Role
"""
result = await self.session.execute(
select(Role).where(Role.role_name == DEFAULT_ROLE_NAME)
)
role = result.scalar_one_or_none()
if role is None:
logger.info(f"Creating default role: {DEFAULT_ROLE_NAME}")
role = Role(role_name=DEFAULT_ROLE_NAME)
self.session.add(role)
await self.session.flush()
await self.session.refresh(role)
return role
async def get_or_create_user(self, sub: str) -> tuple[User, bool]:
"""
Get an existing user or create a new one (Just-in-Time Provisioning).
This is the main method called during login.
Args:
sub: The Keycloak subject ID
Returns:
Tuple of (User, created) where created is True if a new user was created
"""
# Check if user already exists
user = await self.get_by_sub(sub)
if user is not None:
logger.debug(f"Found existing user with sub: {sub}")
return user, False
# User doesn't exist, create them with default role
logger.info(f"Creating new user with sub: {sub}")
# Get or create the default role
default_role = await self.get_or_create_default_role()
# Create the user
user = await self.create(sub=sub, role_id=default_role.id)
logger.info(f"Created new user with id: {user.id}, sub: {sub}")
return user, True
class RoleRepository:
"""Repository for Role-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_name(self, role_name: str) -> Optional[Role]:
"""Get a role by name."""
result = await self.session.execute(
select(Role).where(Role.role_name == role_name)
)
return result.scalar_one_or_none()
async def get_by_id(self, role_id: int) -> Optional[Role]:
"""Get a role by ID."""
result = await self.session.execute(
select(Role).where(Role.id == role_id)
)
return result.scalar_one_or_none()
async def create(self, role_name: str) -> Role:
"""Create a new role."""
role = Role(role_name=role_name)
self.session.add(role)
await self.session.flush()
await self.session.refresh(role)
return role
async def ensure_default_roles_exist(self) -> None:
"""
Ensure default roles exist in the database.
Called during application startup.
"""
default_roles = ["admin", "user", "viewer"]
for role_name in default_roles:
existing = await self.get_by_name(role_name)
if existing is None:
logger.info(f"Creating default role: {role_name}")
await self.create(role_name)

View File

@@ -1,8 +1,10 @@
from fastapi import HTTPException, status, Request
from keycloak.exceptions import KeycloakAuthenticationError, KeycloakPostError
from keycloak import KeycloakOpenID
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import get_settings
from src.models import UserInfo
from src.repositories import UserRepository
import logging
logger = logging.getLogger(__name__)
@@ -21,9 +23,10 @@ def get_keycloak_openid():
class AuthService:
@staticmethod
def authenticate_user(keycode: str, request: Request) -> str:
def authenticate_user(keycode: str, request: Request) -> dict:
"""
Authenticate the user using Keycloak and return an access token.
Authenticate the user using Keycloak and return the full token response.
Returns the full token dict to allow access to the access_token.
"""
try:
# Use the same redirect_uri that was used in the login endpoint
@@ -46,7 +49,7 @@ class AuthService:
redirect_uri=redirect_uri,
)
logger.info("Token exchange successful")
return token["access_token"]
return token
except KeycloakAuthenticationError as exc:
logger.error(f"KeycloakAuthenticationError: {exc}")
raise HTTPException(
@@ -80,6 +83,7 @@ class AuthService:
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)
return UserInfo(
sub=user_info.get("sub"),
preferred_username=user_info["preferred_username"],
email=user_info.get("email"),
full_name=user_info.get("name"),
@@ -89,3 +93,64 @@ class AuthService:
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
) from exc
@staticmethod
def decode_token(token: str) -> dict:
"""
Decode the access token to extract claims without full verification.
Used to get the 'sub' claim for user provisioning.
"""
try:
keycloak_openid = get_keycloak_openid()
# Decode token - this validates the signature
token_info = keycloak_openid.decode_token(
token,
validate=True
)
return token_info
except Exception as exc:
logger.error(f"Error decoding token: {exc}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not decode token",
) from exc
class UserService:
"""Service for user-related operations."""
@staticmethod
async def provision_user_on_login(
token: str,
db: AsyncSession
) -> tuple[int, bool]:
"""
Provision a user in the database on first login (JIT provisioning).
Args:
token: The access token from Keycloak
db: Database session
Returns:
Tuple of (user_id, is_new_user)
"""
# Decode the token to get the 'sub' claim
token_info = AuthService.decode_token(token)
sub = token_info.get("sub")
if not sub:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token does not contain 'sub' claim"
)
# Get or create the user
user_repo = UserRepository(db)
user, created = await user_repo.get_or_create_user(sub)
if created:
logger.info(f"New user provisioned: {sub} -> user_id: {user.id}")
else:
logger.debug(f"Existing user logged in: {sub} -> user_id: {user.id}")
return user.id, created

View File

@@ -3,6 +3,7 @@ import Layout from '@/components/Layout'
import HomePage from '@/pages/HomePage'
import DashboardPage from '@/pages/DashboardPage'
import RequirementsPage from '@/pages/RequirementsPage'
import RequirementDetailPage from '@/pages/RequirementDetailPage'
import ProtectedRoute from '@/components/ProtectedRoute'
function App() {
@@ -32,6 +33,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/requirements/:id"
element={
<ProtectedRoute>
<RequirementDetailPage />
</ProtectedRoute>
}
/>
</Routes>
)
}

View File

@@ -1,86 +1,94 @@
import { useState, useEffect } from 'react'
import { useAuth } from '@/hooks'
import { useNavigate } from 'react-router-dom'
import { groupService, Group } from '@/services'
// 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>
)
/**
* Helper function to convert hex color to RGB values
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null
}
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>
)
/**
* Helper function to determine if text should be light or dark based on background color
*/
function getContrastTextColor(hexColor: string): string {
const rgb = hexToRgb(hexColor)
if (!rgb) return '#000000'
// Calculate luminance
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
return luminance > 0.5 ? '#000000' : '#ffffff'
}
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>
)
/**
* Helper function to lighten a hex color for hover state
*/
function lightenColor(hex: string, percent: number): string {
const rgb = hexToRgb(hex)
if (!rgb) return hex
const lighten = (value: number) => Math.min(255, Math.floor(value + (255 - value) * percent))
const r = lighten(rgb.r).toString(16).padStart(2, '0')
const g = lighten(rgb.g).toString(16).padStart(2, '0')
const b = lighten(rgb.b).toString(16).padStart(2, '0')
return `#${r}${g}${b}`
}
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>
)
/**
* Helper function to darken a hex color for border
*/
function darkenColor(hex: string, percent: number): string {
const rgb = hexToRgb(hex)
if (!rgb) return hex
const darken = (value: number) => Math.max(0, Math.floor(value * (1 - percent)))
const r = darken(rgb.r).toString(16).padStart(2, '0')
const g = darken(rgb.g).toString(16).padStart(2, '0')
const b = darken(rgb.b).toString(16).padStart(2, '0')
return `#${r}${g}${b}`
}
export default function DashboardPage() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [groups, setGroups] = useState<Group[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [hoveredGroup, setHoveredGroup] = useState<number | null>(null)
const handleCategoryClick = (group: string) => {
navigate(`/requirements?group=${encodeURIComponent(group)}`)
useEffect(() => {
const fetchGroups = async () => {
try {
setLoading(true)
const fetchedGroups = await groupService.getGroups()
setGroups(fetchedGroups)
setError(null)
} catch (err) {
console.error('Failed to fetch groups:', err)
setError('Failed to load groups')
} finally {
setLoading(false)
}
}
fetchGroups()
}, [])
const handleCategoryClick = (groupName: string) => {
navigate(`/requirements?group=${encodeURIComponent(groupName)}`)
}
const handleMyRequirementsClick = () => {
@@ -194,76 +202,63 @@ export default function DashboardPage() {
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
onClick={() => handleCategoryClick('Data Services')}
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>
{/* Loading State */}
{loading && (
<div className="flex justify-center items-center min-h-[200px]">
<div className="text-gray-500">Loading groups...</div>
</div>
)}
{/* Integration - 1 row */}
<div
onClick={() => handleCategoryClick('Integration')}
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>
{/* Error State */}
{error && (
<div className="flex justify-center items-center min-h-[200px]">
<div className="text-red-500">{error}</div>
</div>
)}
{/* Intelligence - spans 2 rows */}
<div
onClick={() => handleCategoryClick('Intelligence')}
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>
{/* Groups Grid */}
{!loading && !error && groups.length > 0 && (
<div className="grid grid-cols-3 gap-4 max-w-2xl mx-auto">
{groups.map((group) => {
const isHovered = hoveredGroup === group.id
const bgColor = isHovered
? lightenColor(group.hex_color, 0.2)
: group.hex_color
const borderColor = darkenColor(group.hex_color, 0.2)
const textColor = getContrastTextColor(group.hex_color)
{/* User Experience - spans 2 rows */}
<div
onClick={() => handleCategoryClick('User Experience')}
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>
return (
<div
key={group.id}
onClick={() => handleCategoryClick(group.group_name)}
onMouseEnter={() => setHoveredGroup(group.id)}
onMouseLeave={() => setHoveredGroup(null)}
className="flex flex-col items-center justify-center p-6 cursor-pointer transition-colors min-h-[120px] rounded-lg"
style={{
backgroundColor: bgColor,
borderWidth: '2px',
borderStyle: 'solid',
borderColor: borderColor,
}}
>
<span
className="text-sm font-semibold text-center"
style={{ color: textColor }}
>
{group.group_name}
</span>
</div>
)
})}
</div>
)}
{/* Row 2 - Management and Trustworthiness */}
{/* Management - 1 row, spans 1 col */}
<div
onClick={() => handleCategoryClick('Management')}
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>
{/* Empty State */}
{!loading && !error && groups.length === 0 && (
<div className="flex justify-center items-center min-h-[200px]">
<div className="text-gray-500">No groups found</div>
</div>
{/* Trustworthiness - 1 row */}
<div
onClick={() => handleCategoryClick('Trustworthiness')}
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>

View File

@@ -0,0 +1,476 @@
import { useState } from 'react'
import { useAuth } from '@/hooks'
import { useParams, Link } from 'react-router-dom'
// Types for requirement details
interface RequirementDetail {
id: string
tag: string
title: string
priority: number
group: string
description: string
authors: string[]
subRequirements: SubRequirement[]
coRequirements: CoRequirement[]
acceptanceCriteria: AcceptanceCriterion[]
sharedComments: Comment[]
}
interface SubRequirement {
id: string
tag: string
title: string
}
interface CoRequirement {
id: string
tag: string
title: string
}
interface AcceptanceCriterion {
id: string
description: string
validated: boolean
}
interface Comment {
id: string
author: string
date: string
content: string
}
// Tab types
type TabType = 'description' | 'sub-requirements' | 'co-requirements' | 'acceptance-criteria' | 'shared-comments' | 'validate'
// Mock data for requirement details
const mockRequirementDetails: Record<string, RequirementDetail> = {
'1': {
id: '1',
tag: 'GSR#7',
title: 'Controle e monitoramento em right-time',
priority: 0,
group: 'userExperience',
description: '',
authors: ['Ricardo Belo'],
subRequirements: [],
coRequirements: [],
acceptanceCriteria: [],
sharedComments: [],
},
'2': {
id: '2',
tag: 'GSR#10',
title: 'Interface de software DT-externo bem definida.',
priority: 1,
group: 'Intelligence',
description: 'This requirement defines the need for a well-defined software interface between the Digital Twin and external systems.',
authors: ['Ricardo Belo', 'Maria Silva'],
subRequirements: [
{ id: 'sub1', tag: 'SFR#1', title: 'API Documentation' },
{ id: 'sub2', tag: 'SFR#2', title: 'Authentication Protocol' },
],
coRequirements: [
{ id: 'co1', tag: 'GSR#5', title: 'Sincronização de dados em tempo real' },
],
acceptanceCriteria: [
{ id: 'ac1', description: 'API endpoints documented with OpenAPI spec', validated: true },
{ id: 'ac2', description: 'Authentication tested with external systems', validated: false },
],
sharedComments: [
{ id: 'c1', author: 'Ricardo Belo', date: '2025-11-28', content: 'Initial draft created' },
],
},
'3': {
id: '3',
tag: 'GSR#12',
title: 'Visualizacao',
priority: 1,
group: 'User Experience',
description: 'Visualization requirements for the Digital Twin interface.',
authors: ['Ricardo Belo'],
subRequirements: [],
coRequirements: [],
acceptanceCriteria: [],
sharedComments: [],
},
'4': {
id: '4',
tag: 'GSR#1',
title: 'Estado corrente atualizado',
priority: 1,
group: 'Data Services',
description: 'The system must maintain an updated current state of all monitored entities.',
authors: ['Ricardo Belo'],
subRequirements: [],
coRequirements: [],
acceptanceCriteria: [],
sharedComments: [],
},
'5': {
id: '5',
tag: 'GSR#5',
title: 'Sincronização de dados em tempo real',
priority: 2,
group: 'Data Services',
description: 'Real-time data synchronization between physical and digital twin.',
authors: ['Ricardo Belo'],
subRequirements: [],
coRequirements: [],
acceptanceCriteria: [],
sharedComments: [],
},
'6': {
id: '6',
tag: 'GSR#8',
title: 'Integração com sistemas legados',
priority: 1,
group: 'Integration',
description: 'Integration capabilities with legacy systems.',
authors: ['Ricardo Belo'],
subRequirements: [],
coRequirements: [],
acceptanceCriteria: [],
sharedComments: [],
},
'7': {
id: '7',
tag: 'GSR#15',
title: 'Dashboard de gestão',
priority: 3,
group: 'Management',
description: 'Management dashboard for system monitoring and control.',
authors: ['Ricardo Belo'],
subRequirements: [],
coRequirements: [],
acceptanceCriteria: [],
sharedComments: [],
},
}
export default function RequirementDetailPage() {
const { user, logout } = useAuth()
const { id } = useParams<{ id: string }>()
const [activeTab, setActiveTab] = useState<TabType>('description')
const [priority, setPriority] = useState<number>(0)
// Get requirement details from mock data
const requirement = id ? mockRequirementDetails[id] : null
// Initialize priority when requirement loads
useState(() => {
if (requirement) {
setPriority(requirement.priority)
}
})
if (!requirement) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold text-gray-700 mb-4">Requirement not found</h2>
<Link to="/requirements" className="text-teal-600 hover:underline">
Back to Requirements
</Link>
</div>
</div>
)
}
const tabs: { id: TabType; label: string }[] = [
{ id: 'description', label: 'Description' },
{ id: 'sub-requirements', label: 'Sub-Requirements' },
{ id: 'co-requirements', label: 'Co-Requirements' },
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' },
{ id: 'shared-comments', label: 'Shared Comments' },
{ id: 'validate', label: 'Validate' },
]
const handlePriorityChange = (delta: number) => {
setPriority(prev => Math.max(0, prev + delta))
}
const renderTabContent = () => {
switch (activeTab) {
case 'description':
return (
<div>
<h3 className="text-xl font-bold text-gray-800 mb-2">Description</h3>
<p className="text-sm text-gray-700 mb-4">
<span className="font-semibold">Author(s):</span> {requirement.authors.join(', ')}
</p>
<div className="bg-teal-50 border border-gray-300 rounded min-h-[300px] p-4">
<p className="text-gray-700">{requirement.description || ''}</p>
</div>
<div className="mt-4">
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
Edit
</button>
</div>
</div>
)
case 'sub-requirements':
return (
<div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Sub-Requirements</h3>
{requirement.subRequirements.length > 0 ? (
<div className="space-y-2">
{requirement.subRequirements.map(sub => (
<div key={sub.id} className="p-3 border border-gray-300 rounded">
<span className="font-semibold">{sub.tag}</span> - {sub.title}
</div>
))}
</div>
) : (
<p className="text-gray-500">No sub-requirements defined.</p>
)}
<div className="mt-4">
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
Add Sub-Requirement
</button>
</div>
</div>
)
case 'co-requirements':
return (
<div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Co-Requirements</h3>
{requirement.coRequirements.length > 0 ? (
<div className="space-y-2">
{requirement.coRequirements.map(co => (
<div key={co.id} className="p-3 border border-gray-300 rounded">
<span className="font-semibold">{co.tag}</span> - {co.title}
</div>
))}
</div>
) : (
<p className="text-gray-500">No co-requirements defined.</p>
)}
<div className="mt-4">
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
Add Co-Requirement
</button>
</div>
</div>
)
case 'acceptance-criteria':
return (
<div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Acceptance Criteria</h3>
{requirement.acceptanceCriteria.length > 0 ? (
<div className="space-y-2">
{requirement.acceptanceCriteria.map(ac => (
<div key={ac.id} className="p-3 border border-gray-300 rounded flex items-center gap-3">
<input
type="checkbox"
checked={ac.validated}
readOnly
className="w-4 h-4 rounded border-gray-300 text-teal-600"
/>
<span className={ac.validated ? 'line-through text-gray-400' : ''}>
{ac.description}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500">No acceptance criteria defined.</p>
)}
<div className="mt-4">
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
Add Criterion
</button>
</div>
</div>
)
case 'shared-comments':
return (
<div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Shared Comments</h3>
{requirement.sharedComments.length > 0 ? (
<div className="space-y-3">
{requirement.sharedComments.map(comment => (
<div key={comment.id} className="p-3 border border-gray-300 rounded">
<div className="flex justify-between text-sm text-gray-500 mb-1">
<span className="font-semibold">{comment.author}</span>
<span>{comment.date}</span>
</div>
<p className="text-gray-700">{comment.content}</p>
</div>
))}
</div>
) : (
<p className="text-gray-500">No comments yet.</p>
)}
<div className="mt-4">
<textarea
placeholder="Add a comment..."
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 mb-2"
rows={3}
/>
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
Post Comment
</button>
</div>
</div>
)
case 'validate':
return (
<div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Validate Requirement</h3>
<div className="p-4 border border-gray-300 rounded bg-gray-50">
<p className="text-gray-700 mb-4">
Review all acceptance criteria and validate this requirement when ready.
</p>
<div className="space-y-2 mb-4">
<label className="flex items-center gap-2">
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
<span className="text-sm">All acceptance criteria have been met</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
<span className="text-sm">Documentation is complete</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
<span className="text-sm">Stakeholders have approved</span>
</label>
</div>
<button className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700">
Validate Requirement
</button>
</div>
</div>
)
default:
return null
}
}
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">
<Link to="/dashboard" className="text-gray-600 hover:underline">Projects</Link>
<span className="mx-2 text-gray-400">»</span>
<Link to="/dashboard" className="text-gray-600 hover:underline">PeTWIN</Link>
<span className="mx-2 text-gray-400">»</span>
<Link to="/requirements" className="text-gray-600 hover:underline">Search</Link>
<span className="mx-2 text-gray-400">»</span>
<span className="font-semibold text-gray-900">Details {requirement.tag}</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>
{/* 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 || 'Ricardo Belo'}{' '}
<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-5xl mx-auto px-8 py-8">
{/* Requirement Header */}
<div className="text-center mb-8">
{/* Tag */}
<h2 className="text-2xl font-bold text-gray-800 mb-2">{requirement.tag}</h2>
{/* Priority */}
<div className="flex items-center justify-center gap-2 mb-3">
<span className="text-gray-700 font-medium">Priority:</span>
<button
onClick={() => handlePriorityChange(-1)}
className="w-6 h-6 rounded-full border border-gray-400 text-gray-600 hover:bg-gray-100 flex items-center justify-center text-sm"
>
</button>
<span className="text-lg font-semibold text-gray-800 min-w-[20px] text-center">{priority}</span>
<button
onClick={() => handlePriorityChange(1)}
className="w-6 h-6 rounded-full border border-gray-400 text-gray-600 hover:bg-gray-100 flex items-center justify-center text-sm"
>
+
</button>
</div>
{/* Title */}
<h3 className="text-xl text-gray-700 mb-3">{requirement.title}</h3>
{/* Group Badge */}
<span className="inline-block px-3 py-1 border border-gray-400 rounded text-sm text-gray-700">
{requirement.group}
</span>
</div>
{/* Content Area */}
<div className="flex gap-6">
{/* Sidebar Tabs */}
<div className="w-48 flex-shrink-0">
<div className="border border-gray-300 rounded overflow-hidden">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full px-4 py-3 text-left text-sm border-b border-gray-300 last:border-b-0 transition-colors ${
activeTab === tab.id
? 'bg-gray-100 font-semibold text-gray-800'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Main Content Panel */}
<div className="flex-1">
<div className="border border-gray-300 rounded p-6">
{renderTabContent()}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useAuth } from '@/hooks'
import { useSearchParams, Link } from 'react-router-dom'
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
// Types for requirements
interface Requirement {
@@ -111,6 +111,7 @@ const captionItems = [
export default function RequirementsPage() {
const { user, logout } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
// State
const [searchQuery, setSearchQuery] = useState('')
@@ -174,8 +175,7 @@ export default function RequirementsPage() {
}
const handleDetails = (id: string) => {
// For now, just a placeholder - will connect to backend later
console.log('View details:', id)
navigate(`/requirements/${id}`)
}
return (

View File

@@ -1,3 +1,4 @@
export { default as HomePage } from './HomePage'
export { default as DashboardPage } from './DashboardPage'
export { default as RequirementsPage } from './RequirementsPage'
export { default as RequirementDetailPage } from './RequirementDetailPage'

View File

@@ -0,0 +1,36 @@
const API_BASE_URL = '/api'
export interface Group {
id: number
group_name: string
hex_color: string
}
class GroupService {
/**
* Get all groups from the API.
*/
async getGroups(): Promise<Group[]> {
try {
const response = await fetch(`${API_BASE_URL}/groups`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const groups: Group[] = await response.json()
return groups
} catch (error) {
console.error('Failed to fetch groups:', error)
throw error
}
}
}
export const groupService = new GroupService()

View File

@@ -1 +1,3 @@
export { authService } from './authService'
export { groupService } from './groupService'
export type { Group } from './groupService'