Added DB connection and started creating api calls for the pages
This commit is contained in:
13
.env.example
13
.env.example
@@ -38,3 +38,16 @@ COOKIE_DOMAIN=
|
|||||||
|
|
||||||
# Cookie max age in seconds (default: 3600 = 1 hour)
|
# Cookie max age in seconds (default: 3600 = 1 hour)
|
||||||
COOKIE_MAX_AGE=3600
|
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
3
.gitignore
vendored
@@ -46,3 +46,6 @@ frontend/dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Other
|
||||||
|
*.sql
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ fastapi = "^0.115.0"
|
|||||||
pydantic = "^2.12.4"
|
pydantic = "^2.12.4"
|
||||||
pydantic-settings = "^2.12.0"
|
pydantic-settings = "^2.12.0"
|
||||||
uvicorn = {extras = ["standard"], version = "^0.32.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]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ class Settings(BaseSettings):
|
|||||||
cookie_max_age: int = Field(default=3600, env="COOKIE_MAX_AGE")
|
cookie_max_age: int = Field(default=3600, env="COOKIE_MAX_AGE")
|
||||||
cookie_name: str = Field(default="access_token", env="COOKIE_NAME")
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from fastapi import Depends, HTTPException, status, Request
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from src.models import TokenResponse, UserInfo
|
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.config import get_settings
|
||||||
|
from src.database import get_db
|
||||||
|
|
||||||
# Initialize HTTPBearer security dependency
|
# Initialize HTTPBearer security dependency
|
||||||
bearer_scheme = HTTPBearer()
|
bearer_scheme = HTTPBearer()
|
||||||
@@ -35,13 +37,15 @@ class AuthController:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@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:
|
Args:
|
||||||
keycode (str): The authorization code from Keycloak.
|
keycode (str): The authorization code from Keycloak.
|
||||||
request (Request): The FastAPI request object.
|
request (Request): The FastAPI request object.
|
||||||
|
db (AsyncSession): Database session for user provisioning.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If the authentication fails.
|
HTTPException: If the authentication fails.
|
||||||
@@ -50,7 +54,8 @@ class AuthController:
|
|||||||
RedirectResponse: Redirects to frontend with cookie set.
|
RedirectResponse: Redirects to frontend with cookie set.
|
||||||
"""
|
"""
|
||||||
# Authenticate the user using the AuthService
|
# 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:
|
if not access_token:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -58,6 +63,10 @@ class AuthController:
|
|||||||
detail="Authentication failed",
|
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
|
# Create redirect response to frontend
|
||||||
response = RedirectResponse(
|
response = RedirectResponse(
|
||||||
url=f"{settings.frontend_url}/dashboard",
|
url=f"{settings.frontend_url}/dashboard",
|
||||||
|
|||||||
61
backend/src/database.py
Normal file
61
backend/src/database.py
Normal 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
230
backend/src/db_models.py
Normal 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)
|
||||||
@@ -1,17 +1,60 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import List
|
||||||
from fastapi import FastAPI, Depends, Request
|
from fastapi import FastAPI, Depends, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from fastapi.responses import RedirectResponse
|
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.controller import AuthController
|
||||||
from src.config import get_openid, get_settings
|
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
|
# Configure logging
|
||||||
app = FastAPI(title="Keycloak Auth API", version="1.0.0")
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Get settings
|
# Get settings
|
||||||
settings = 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
|
# Configure CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -65,15 +108,15 @@ async def login(request: Request):
|
|||||||
|
|
||||||
# Define the callback endpoint
|
# Define the callback endpoint
|
||||||
@app.get("/api/callback", include_in_schema=False)
|
@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
|
OAuth callback endpoint that exchanges the authorization code for a token,
|
||||||
and sets it as an HTTP-only cookie.
|
provisions the user in the database if needed, and sets it as an HTTP-only cookie.
|
||||||
"""
|
"""
|
||||||
# Extract the code from the URL
|
# Extract the code from the URL
|
||||||
keycode = request.query_params.get('code')
|
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
|
# 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.
|
UserInfo: Information about the authenticated user.
|
||||||
"""
|
"""
|
||||||
return AuthController.protected_endpoint(credentials)
|
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]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from pydantic import BaseModel, SecretStr
|
from pydantic import BaseModel, SecretStr
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,25 @@ class TokenResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserInfo(BaseModel):
|
class UserInfo(BaseModel):
|
||||||
|
sub: Optional[str] = None # Keycloak subject ID
|
||||||
preferred_username: str
|
preferred_username: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
full_name: 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]
|
||||||
|
|||||||
11
backend/src/repositories/__init__.py
Normal file
11
backend/src/repositories/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
76
backend/src/repositories/group_repository.py
Normal file
76
backend/src/repositories/group_repository.py
Normal 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
|
||||||
166
backend/src/repositories/user_repository.py
Normal file
166
backend/src/repositories/user_repository.py
Normal 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)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from fastapi import HTTPException, status, Request
|
from fastapi import HTTPException, status, Request
|
||||||
from keycloak.exceptions import KeycloakAuthenticationError, KeycloakPostError
|
from keycloak.exceptions import KeycloakAuthenticationError, KeycloakPostError
|
||||||
from keycloak import KeycloakOpenID
|
from keycloak import KeycloakOpenID
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from src.config import get_settings
|
from src.config import get_settings
|
||||||
from src.models import UserInfo
|
from src.models import UserInfo
|
||||||
|
from src.repositories import UserRepository
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,9 +23,10 @@ def get_keycloak_openid():
|
|||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
@staticmethod
|
@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:
|
try:
|
||||||
# Use the same redirect_uri that was used in the login endpoint
|
# Use the same redirect_uri that was used in the login endpoint
|
||||||
@@ -46,7 +49,7 @@ class AuthService:
|
|||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
logger.info("Token exchange successful")
|
logger.info("Token exchange successful")
|
||||||
return token["access_token"]
|
return token
|
||||||
except KeycloakAuthenticationError as exc:
|
except KeycloakAuthenticationError as exc:
|
||||||
logger.error(f"KeycloakAuthenticationError: {exc}")
|
logger.error(f"KeycloakAuthenticationError: {exc}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -80,6 +83,7 @@ class AuthService:
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||||
)
|
)
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
|
sub=user_info.get("sub"),
|
||||||
preferred_username=user_info["preferred_username"],
|
preferred_username=user_info["preferred_username"],
|
||||||
email=user_info.get("email"),
|
email=user_info.get("email"),
|
||||||
full_name=user_info.get("name"),
|
full_name=user_info.get("name"),
|
||||||
@@ -89,3 +93,64 @@ class AuthService:
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
) from exc
|
) 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Layout from '@/components/Layout'
|
|||||||
import HomePage from '@/pages/HomePage'
|
import HomePage from '@/pages/HomePage'
|
||||||
import DashboardPage from '@/pages/DashboardPage'
|
import DashboardPage from '@/pages/DashboardPage'
|
||||||
import RequirementsPage from '@/pages/RequirementsPage'
|
import RequirementsPage from '@/pages/RequirementsPage'
|
||||||
|
import RequirementDetailPage from '@/pages/RequirementDetailPage'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -32,6 +33,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/requirements/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<RequirementDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,94 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth } from '@/hooks'
|
import { useAuth } from '@/hooks'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { groupService, Group } from '@/services'
|
||||||
|
|
||||||
// Icons as components for cleaner code
|
/**
|
||||||
const DataServicesIcon = () => (
|
* Helper function to convert hex color to RGB values
|
||||||
<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" />
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
<line x1="20" y1="20" x2="44" y2="20" />
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||||
<line x1="20" y1="28" x2="44" y2="28" />
|
return result
|
||||||
<line x1="20" y1="36" x2="44" y2="36" />
|
? {
|
||||||
<line x1="20" y1="44" x2="36" y2="44" />
|
r: parseInt(result[1], 16),
|
||||||
<rect x="36" y="40" width="12" height="12" rx="1" />
|
g: parseInt(result[2], 16),
|
||||||
<line x1="40" y1="44" x2="44" y2="44" />
|
b: parseInt(result[3], 16),
|
||||||
<line x1="40" y1="48" x2="44" y2="48" />
|
}
|
||||||
</svg>
|
: null
|
||||||
)
|
}
|
||||||
|
|
||||||
const IntegrationIcon = () => (
|
/**
|
||||||
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
|
* Helper function to determine if text should be light or dark based on background color
|
||||||
<rect x="8" y="16" width="20" height="14" rx="2" />
|
*/
|
||||||
<rect x="36" y="16" width="20" height="14" rx="2" />
|
function getContrastTextColor(hexColor: string): string {
|
||||||
<rect x="22" y="38" width="20" height="14" rx="2" />
|
const rgb = hexToRgb(hexColor)
|
||||||
<line x1="18" y1="30" x2="18" y2="38" />
|
if (!rgb) return '#000000'
|
||||||
<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 = () => (
|
// Calculate luminance
|
||||||
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
|
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
|
||||||
<circle cx="28" cy="28" r="14" />
|
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||||
<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">
|
* Helper function to lighten a hex color for hover state
|
||||||
<circle cx="32" cy="20" r="8" />
|
*/
|
||||||
<circle cx="16" cy="36" r="4" />
|
function lightenColor(hex: string, percent: number): string {
|
||||||
<circle cx="48" cy="36" r="4" />
|
const rgb = hexToRgb(hex)
|
||||||
<circle cx="24" cy="52" r="4" />
|
if (!rgb) return hex
|
||||||
<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 = () => (
|
const lighten = (value: number) => Math.min(255, Math.floor(value + (255 - value) * percent))
|
||||||
<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 = () => (
|
const r = lighten(rgb.r).toString(16).padStart(2, '0')
|
||||||
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2">
|
const g = lighten(rgb.g).toString(16).padStart(2, '0')
|
||||||
<path d="M12 36 C12 36 20 28 32 36 C44 44 52 36 52 36" strokeLinecap="round" />
|
const b = lighten(rgb.b).toString(16).padStart(2, '0')
|
||||||
<path d="M12 36 L24 48 L32 36" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M52 36 L40 48 L32 36" strokeLinecap="round" strokeLinejoin="round" />
|
return `#${r}${g}${b}`
|
||||||
</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() {
|
export default function DashboardPage() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
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) => {
|
useEffect(() => {
|
||||||
navigate(`/requirements?group=${encodeURIComponent(group)}`)
|
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 = () => {
|
const handleMyRequirementsClick = () => {
|
||||||
@@ -194,76 +202,63 @@ export default function DashboardPage() {
|
|||||||
Quick Search Filters
|
Quick Search Filters
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Grid Layout matching the screenshot */}
|
{/* Loading State */}
|
||||||
<div className="grid grid-cols-4 gap-0 max-w-2xl mx-auto">
|
{loading && (
|
||||||
{/* Row 1 */}
|
<div className="flex justify-center items-center min-h-[200px]">
|
||||||
{/* Data Services - spans 2 rows */}
|
<div className="text-gray-500">Loading groups...</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Integration - 1 row */}
|
{/* Error State */}
|
||||||
<div
|
{error && (
|
||||||
onClick={() => handleCategoryClick('Integration')}
|
<div className="flex justify-center items-center min-h-[200px]">
|
||||||
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-red-500">{error}</div>
|
||||||
>
|
|
||||||
<div className="text-amber-800">
|
|
||||||
<IntegrationIcon />
|
|
||||||
</div>
|
|
||||||
<span className="mt-2 text-sm font-semibold text-amber-900">Integration</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Intelligence - spans 2 rows */}
|
{/* Groups Grid */}
|
||||||
<div
|
{!loading && !error && groups.length > 0 && (
|
||||||
onClick={() => handleCategoryClick('Intelligence')}
|
<div className="grid grid-cols-3 gap-4 max-w-2xl mx-auto">
|
||||||
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]"
|
{groups.map((group) => {
|
||||||
>
|
const isHovered = hoveredGroup === group.id
|
||||||
<div className="text-purple-800">
|
const bgColor = isHovered
|
||||||
<IntelligenceIcon />
|
? lightenColor(group.hex_color, 0.2)
|
||||||
</div>
|
: group.hex_color
|
||||||
<span className="mt-3 text-sm font-semibold text-purple-900">Intelligence</span>
|
const borderColor = darkenColor(group.hex_color, 0.2)
|
||||||
</div>
|
const textColor = getContrastTextColor(group.hex_color)
|
||||||
|
|
||||||
{/* User Experience - spans 2 rows */}
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => handleCategoryClick('User Experience')}
|
key={group.id}
|
||||||
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]"
|
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,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-green-800">
|
<span
|
||||||
<UserExperienceIcon />
|
className="text-sm font-semibold text-center"
|
||||||
|
style={{ color: textColor }}
|
||||||
|
>
|
||||||
|
{group.group_name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-3 text-sm font-semibold text-green-900">User Experience</span>
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Row 2 - Management and Trustworthiness */}
|
{/* Empty State */}
|
||||||
{/* Management - 1 row, spans 1 col */}
|
{!loading && !error && groups.length === 0 && (
|
||||||
<div
|
<div className="flex justify-center items-center min-h-[200px]">
|
||||||
onClick={() => handleCategoryClick('Management')}
|
<div className="text-gray-500">No groups found</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
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
476
frontend/src/pages/RequirementDetailPage.tsx
Normal file
476
frontend/src/pages/RequirementDetailPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth } from '@/hooks'
|
import { useAuth } from '@/hooks'
|
||||||
import { useSearchParams, Link } from 'react-router-dom'
|
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
// Types for requirements
|
// Types for requirements
|
||||||
interface Requirement {
|
interface Requirement {
|
||||||
@@ -111,6 +111,7 @@ const captionItems = [
|
|||||||
export default function RequirementsPage() {
|
export default function RequirementsPage() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
@@ -174,8 +175,7 @@ export default function RequirementsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDetails = (id: string) => {
|
const handleDetails = (id: string) => {
|
||||||
// For now, just a placeholder - will connect to backend later
|
navigate(`/requirements/${id}`)
|
||||||
console.log('View details:', id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as HomePage } from './HomePage'
|
export { default as HomePage } from './HomePage'
|
||||||
export { default as DashboardPage } from './DashboardPage'
|
export { default as DashboardPage } from './DashboardPage'
|
||||||
export { default as RequirementsPage } from './RequirementsPage'
|
export { default as RequirementsPage } from './RequirementsPage'
|
||||||
|
export { default as RequirementDetailPage } from './RequirementDetailPage'
|
||||||
|
|||||||
36
frontend/src/services/groupService.ts
Normal file
36
frontend/src/services/groupService.ts
Normal 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()
|
||||||
@@ -1 +1,3 @@
|
|||||||
export { authService } from './authService'
|
export { authService } from './authService'
|
||||||
|
export { groupService } from './groupService'
|
||||||
|
export type { Group } from './groupService'
|
||||||
|
|||||||
Reference in New Issue
Block a user