Added admin page

This commit is contained in:
gulimabr
2025-12-01 12:39:13 -03:00
parent 74454c7b6b
commit a52a669521
13 changed files with 1430 additions and 65 deletions

View File

@@ -318,7 +318,12 @@ class RelationshipType(Base):
# Relationships # Relationships
project: Mapped["Project"] = relationship("Project", back_populates="relationship_types") project: Mapped["Project"] = relationship("Project", back_populates="relationship_types")
links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="relationship_type") links: Mapped[List["RequirementLink"]] = relationship(
"RequirementLink",
back_populates="relationship_type",
cascade="all, delete-orphan",
passive_deletes=True
)
# Constraints # Constraints
__table_args__ = ( __table_args__ = (

View File

@@ -11,8 +11,9 @@ from src.models import (
RequirementCreateRequest, RequirementUpdateRequest, RequirementCreateRequest, RequirementUpdateRequest,
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest, ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest, ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult,
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES
) )
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
@@ -521,6 +522,240 @@ async def remove_project_member(
await db.commit() await db.commit()
# ===========================================
# Admin Endpoints (Role Management, Project Admin)
# ===========================================
@app.get("/api/roles", response_model=List[RoleResponse])
async def get_all_roles(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get all available roles with their display names.
Returns:
List of roles with id, role_name, and display_name.
"""
# Ensure user is authenticated
await _get_current_user_db(request, db)
role_repo = RoleRepository(db)
roles = await role_repo.get_all()
return [RoleResponse.from_role(r) for r in roles]
@app.get("/api/projects/{project_id}/members", response_model=List[ProjectMemberResponse])
async def get_project_members(
project_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get all members of a project with their role information.
User must be a member of the project.
Args:
project_id: The project ID
Returns:
List of project members with role info.
"""
user = await _get_current_user_db(request, db)
await _verify_project_membership(project_id, user.id, db)
project_repo = ProjectRepository(db)
members = await project_repo.get_members(project_id)
return [
ProjectMemberResponse(
id=member.id,
sub=member.sub,
role_id=member.role_id,
role_name=member.role.role_name if member.role else "unknown",
role_display_name=ROLE_DISPLAY_NAMES.get(member.role.role_name, member.role.role_name.title()) if member.role else "Unknown",
created_at=member.created_at
)
for member in members
]
@app.put("/api/projects/{project_id}/members/{user_id}/role", response_model=ProjectMemberResponse)
async def update_member_role(
project_id: int,
user_id: int,
request: Request,
role_data: UserRoleUpdateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Update a project member's role.
Only project admins (role_id=3) can update roles.
Admin cannot demote themselves.
Args:
project_id: The project ID
user_id: The user ID to update
role_data: The new role ID
Returns:
The updated member info.
"""
current_user = await _get_current_user_db(request, db)
# Only admins (role_id=3) can update roles
_require_role(current_user, [3], "update member roles")
await _verify_project_membership(project_id, current_user.id, db)
# Check target user is a member of the project
project_repo = ProjectRepository(db)
if not await project_repo.is_member(project_id, user_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User is not a member of this project"
)
# Prevent self-demotion
if current_user.id == user_id and role_data.role_id != 3:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot demote yourself. Ask another admin to change your role."
)
# Verify role exists
role_repo = RoleRepository(db)
role = await role_repo.get_by_id(role_data.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role id {role_data.role_id}"
)
# Update the user's role
from src.repositories import UserRepository
user_repo = UserRepository(db)
updated_user = await user_repo.update_role(user_id, role_data.role_id)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
await db.commit()
return ProjectMemberResponse(
id=updated_user.id,
sub=updated_user.sub,
role_id=updated_user.role_id,
role_name=role.role_name,
role_display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title()),
created_at=updated_user.created_at
)
@app.put("/api/projects/{project_id}/relationship-types/{type_id}", response_model=RelationshipTypeResponse)
async def update_relationship_type(
project_id: int,
type_id: int,
request: Request,
type_data: RelationshipTypeUpdateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Update a relationship type.
Only project admins (role_id=3) can update relationship types.
Args:
project_id: The project ID
type_id: The relationship type ID to update
type_data: The updated relationship type data
Returns:
The updated relationship type.
"""
user = await _get_current_user_db(request, db)
# Only admins (role_id=3) can update relationship types
_require_role(user, [3], "update relationship types")
await _verify_project_membership(project_id, user.id, db)
rel_type_repo = RelationshipTypeRepository(db)
# Check if relationship type exists and belongs to the project
existing_type = await rel_type_repo.get_by_id(type_id)
if not existing_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Relationship type with id {type_id} not found"
)
if existing_type.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Relationship type does not belong to this project"
)
updated_type = await rel_type_repo.update(
relationship_type_id=type_id,
type_name=type_data.type_name,
type_description=type_data.type_description,
inverse_type_name=type_data.inverse_type_name
)
await db.commit()
return RelationshipTypeResponse.model_validate(updated_type)
@app.delete("/api/projects/{project_id}/relationship-types/{type_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_relationship_type(
project_id: int,
type_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Delete a relationship type.
Only project admins (role_id=3) can delete relationship types.
This will also delete all links using this relationship type.
Args:
project_id: The project ID
type_id: The relationship type ID to delete
"""
user = await _get_current_user_db(request, db)
# Only admins (role_id=3) can delete relationship types
_require_role(user, [3], "delete relationship types")
await _verify_project_membership(project_id, user.id, db)
rel_type_repo = RelationshipTypeRepository(db)
# Check if relationship type exists and belongs to the project
existing_type = await rel_type_repo.get_by_id(type_id)
if not existing_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Relationship type with id {type_id} not found"
)
if existing_type.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Relationship type does not belong to this project"
)
deleted = await rel_type_repo.delete(type_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Relationship type with id {type_id} not found"
)
await db.commit()
# =========================================== # ===========================================
# Requirements Endpoints # Requirements Endpoints
# =========================================== # ===========================================
@@ -968,7 +1203,7 @@ async def create_relationship_type(
): ):
""" """
Create a new relationship type for a project. Create a new relationship type for a project.
Only admins (role_id=1) can create relationship types. Only project admins (role_id=3) can create relationship types.
Args: Args:
project_id: The project ID project_id: The project ID
@@ -979,8 +1214,8 @@ async def create_relationship_type(
""" """
user = await _get_current_user_db(request, db) user = await _get_current_user_db(request, db)
# Only admins can create relationship types # Only admins (role_id=3) can create relationship types
_require_role(user, [1], "create relationship types") _require_role(user, [3], "create relationship types")
await _verify_project_membership(project_id, user.id, db) await _verify_project_membership(project_id, user.id, db)

View File

@@ -20,7 +20,52 @@ class UserInfo(BaseModel):
full_name: Optional[str] = None full_name: Optional[str] = None
db_user_id: Optional[int] = None # Database user ID (populated after login) db_user_id: Optional[int] = None # Database user ID (populated after login)
role: Optional[str] = None # User role name role: Optional[str] = None # User role name
role_id: Optional[int] = None # User role ID (1=admin, 2=auditor, 3=user, etc.) role_id: Optional[int] = None # User role ID (1=editor, 2=auditor, 3=admin)
# Role schemas
ROLE_DISPLAY_NAMES = {
"editor": "Editor",
"auditor": "Auditor",
"admin": "Project Admin"
}
class RoleResponse(BaseModel):
"""Response schema for a role."""
id: int
role_name: str
display_name: str
class Config:
from_attributes = True
@classmethod
def from_role(cls, role) -> "RoleResponse":
"""Create a RoleResponse from a Role model."""
return cls(
id=role.id,
role_name=role.role_name,
display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title())
)
class ProjectMemberResponse(BaseModel):
"""Response schema for a project member with role info."""
id: int
sub: str
role_id: int
role_name: str
role_display_name: str
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class UserRoleUpdateRequest(BaseModel):
"""Request schema for updating a user's role."""
role_id: int
# Project schemas # Project schemas
@@ -215,6 +260,13 @@ class RelationshipTypeCreateRequest(BaseModel):
inverse_type_name: Optional[str] = None inverse_type_name: Optional[str] = None
class RelationshipTypeUpdateRequest(BaseModel):
"""Request schema for updating a relationship type."""
type_name: Optional[str] = None
type_description: Optional[str] = None
inverse_type_name: Optional[str] = None
# Requirement Link schemas # Requirement Link schemas
class LinkedRequirementInfo(BaseModel): class LinkedRequirementInfo(BaseModel):
"""Brief info about a linked requirement.""" """Brief info about a linked requirement."""

View File

@@ -229,6 +229,7 @@ class ProjectRepository:
""" """
result = await self.session.execute( result = await self.session.execute(
select(User) select(User)
.options(selectinload(User.role))
.join(ProjectMember, User.id == ProjectMember.user_id) .join(ProjectMember, User.id == ProjectMember.user_id)
.where(ProjectMember.project_id == project_id) .where(ProjectMember.project_id == project_id)
) )

View File

@@ -76,6 +76,40 @@ class RelationshipTypeRepository:
await self.db.refresh(relationship_type) await self.db.refresh(relationship_type)
return relationship_type return relationship_type
async def update(
self,
relationship_type_id: int,
type_name: Optional[str] = None,
type_description: Optional[str] = None,
inverse_type_name: Optional[str] = None
) -> Optional[RelationshipType]:
"""
Update a relationship type.
Args:
relationship_type_id: The ID of the relationship type to update
type_name: New name (optional)
type_description: New description (optional)
inverse_type_name: New inverse name (optional)
Returns:
The updated relationship type or None if not found.
"""
relationship_type = await self.get_by_id(relationship_type_id)
if not relationship_type:
return None
if type_name is not None:
relationship_type.type_name = type_name
if type_description is not None:
relationship_type.type_description = type_description
if inverse_type_name is not None:
relationship_type.inverse_type_name = inverse_type_name
await self.db.flush()
await self.db.refresh(relationship_type)
return relationship_type
async def delete(self, relationship_type_id: int) -> bool: async def delete(self, relationship_type_id: int) -> bool:
""" """
Delete a relationship type by ID. Delete a relationship type by ID.

View File

@@ -2,7 +2,7 @@
Repository layer for User database operations. Repository layer for User database operations.
Handles CRUD operations and user provisioning on first login. Handles CRUD operations and user provisioning on first login.
""" """
from typing import Optional from typing import Optional, List
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -11,8 +11,64 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Default role for new users # Default role for new users (editor = role_id 1)
DEFAULT_ROLE_NAME = "user" DEFAULT_ROLE_NAME = "editor"
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 get_all(self) -> List[Role]:
"""
Get all roles.
Returns:
List of all roles
"""
result = await self.session.execute(
select(Role).order_by(Role.id)
)
return list(result.scalars().all())
async def ensure_default_roles_exist(self) -> None:
"""
Ensure default roles exist in the database.
Called during application startup.
Creates roles in order: editor (1), auditor (2), admin (3)
"""
# Order matters for role IDs: editor=1, auditor=2, admin=3
default_roles = ["editor", "auditor", "admin"]
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)
class UserRepository: class UserRepository:
@@ -72,6 +128,26 @@ class UserRepository:
await self.session.refresh(user) await self.session.refresh(user)
return user return user
async def update_role(self, user_id: int, role_id: int) -> Optional[User]:
"""
Update a user's role.
Args:
user_id: The user ID to update
role_id: The new role ID
Returns:
The updated User, or None if not found
"""
user = await self.get_by_id(user_id)
if not user:
return None
user.role_id = role_id
await self.session.flush()
await self.session.refresh(user)
return user
async def get_or_create_default_role(self) -> Role: async def get_or_create_default_role(self) -> Role:
""" """
Get the default user role, creating it if it doesn't exist. Get the default user role, creating it if it doesn't exist.
@@ -79,17 +155,12 @@ class UserRepository:
Returns: Returns:
The default Role The default Role
""" """
result = await self.session.execute( role_repo = RoleRepository(self.session)
select(Role).where(Role.role_name == DEFAULT_ROLE_NAME) role = await role_repo.get_by_name(DEFAULT_ROLE_NAME)
)
role = result.scalar_one_or_none()
if role is None: if role is None:
logger.info(f"Creating default role: {DEFAULT_ROLE_NAME}") logger.info(f"Creating default role: {DEFAULT_ROLE_NAME}")
role = Role(role_name=DEFAULT_ROLE_NAME) role = await role_repo.create(DEFAULT_ROLE_NAME)
self.session.add(role)
await self.session.flush()
await self.session.refresh(role)
return role return role
@@ -122,45 +193,3 @@ class UserRepository:
logger.info(f"Created new user with id: {user.id}, sub: {sub}") logger.info(f"Created new user with id: {user.id}, sub: {sub}")
return user, True 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

@@ -4,6 +4,7 @@ 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 RequirementDetailPage from '@/pages/RequirementDetailPage'
import AdminPage from '@/pages/AdminPage'
import ProtectedRoute from '@/components/ProtectedRoute' import ProtectedRoute from '@/components/ProtectedRoute'
function App() { function App() {
@@ -41,6 +42,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminPage />
</ProtectedRoute>
}
/>
</Routes> </Routes>
) )
} }

View File

@@ -0,0 +1,814 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth, useProject } from '@/hooks'
import {
projectService,
relationshipService,
userService,
RelationshipType,
ProjectMember,
Role,
} from '@/services'
type TabType = 'project' | 'members' | 'relationships'
export default function AdminPage() {
const { user } = useAuth()
const { currentProject, setCurrentProject } = useProject()
const navigate = useNavigate()
// Tab state
const [activeTab, setActiveTab] = useState<TabType>('project')
// Project settings state
const [projectName, setProjectName] = useState('')
const [projectDesc, setProjectDesc] = useState('')
const [projectLoading, setProjectLoading] = useState(false)
const [projectError, setProjectError] = useState<string | null>(null)
const [projectSuccess, setProjectSuccess] = useState<string | null>(null)
// Members state
const [members, setMembers] = useState<ProjectMember[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [membersLoading, setMembersLoading] = useState(true)
const [membersError, setMembersError] = useState<string | null>(null)
const [updatingMember, setUpdatingMember] = useState<number | null>(null)
// Relationship types state
const [relationshipTypes, setRelationshipTypes] = useState<RelationshipType[]>([])
const [relTypesLoading, setRelTypesLoading] = useState(true)
const [relTypesError, setRelTypesError] = useState<string | null>(null)
// Modal states
const [showCreateRelTypeModal, setShowCreateRelTypeModal] = useState(false)
const [showEditRelTypeModal, setShowEditRelTypeModal] = useState(false)
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
const [selectedRelType, setSelectedRelType] = useState<RelationshipType | null>(null)
// Relationship type form state
const [relTypeName, setRelTypeName] = useState('')
const [relTypeDesc, setRelTypeDesc] = useState('')
const [relTypeInverse, setRelTypeInverse] = useState('')
const [relTypeFormLoading, setRelTypeFormLoading] = useState(false)
const [relTypeFormError, setRelTypeFormError] = useState<string | null>(null)
// Check if user is admin
const isAdmin = user?.role_id === 3
// Redirect if not admin
useEffect(() => {
if (!isAdmin) {
navigate('/dashboard')
}
}, [isAdmin, navigate])
// Initialize project form
useEffect(() => {
if (currentProject) {
setProjectName(currentProject.project_name)
setProjectDesc(currentProject.project_desc || '')
}
}, [currentProject])
// Fetch members and roles
useEffect(() => {
const fetchMembersAndRoles = async () => {
if (!currentProject) return
try {
setMembersLoading(true)
setMembersError(null)
const [fetchedMembers, fetchedRoles] = await Promise.all([
userService.getProjectMembers(currentProject.id),
userService.getRoles(),
])
setMembers(fetchedMembers)
setRoles(fetchedRoles)
} catch (err) {
console.error('Failed to fetch members:', err)
setMembersError('Failed to load project members')
} finally {
setMembersLoading(false)
}
}
fetchMembersAndRoles()
}, [currentProject])
// Fetch relationship types
useEffect(() => {
const fetchRelTypes = async () => {
if (!currentProject) return
try {
setRelTypesLoading(true)
setRelTypesError(null)
const fetchedTypes = await relationshipService.getRelationshipTypes(currentProject.id)
setRelationshipTypes(fetchedTypes)
} catch (err) {
console.error('Failed to fetch relationship types:', err)
setRelTypesError('Failed to load relationship types')
} finally {
setRelTypesLoading(false)
}
}
fetchRelTypes()
}, [currentProject])
// Handle project update
const handleProjectUpdate = async (e: React.FormEvent) => {
e.preventDefault()
if (!currentProject) return
try {
setProjectLoading(true)
setProjectError(null)
setProjectSuccess(null)
const updatedProject = await projectService.updateProject(currentProject.id, {
project_name: projectName.trim(),
project_desc: projectDesc.trim() || undefined,
})
setCurrentProject(updatedProject)
setProjectSuccess('Project updated successfully!')
setTimeout(() => setProjectSuccess(null), 3000)
} catch (err) {
console.error('Failed to update project:', err)
setProjectError('Failed to update project')
} finally {
setProjectLoading(false)
}
}
// Handle member role update
const handleRoleUpdate = async (memberId: number, newRoleId: number) => {
if (!currentProject) return
// Prevent self-demotion
if (memberId === user?.db_user_id && newRoleId !== 3) {
setMembersError('You cannot demote yourself. Ask another admin to change your role.')
setTimeout(() => setMembersError(null), 5000)
return
}
try {
setUpdatingMember(memberId)
setMembersError(null)
const updatedMember = await userService.updateMemberRole(
currentProject.id,
memberId,
newRoleId
)
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? updatedMember : m))
)
} catch (err: any) {
console.error('Failed to update member role:', err)
setMembersError(err.message || 'Failed to update member role')
setTimeout(() => setMembersError(null), 5000)
} finally {
setUpdatingMember(null)
}
}
// Handle create relationship type
const handleCreateRelType = async (e: React.FormEvent) => {
e.preventDefault()
if (!currentProject) return
try {
setRelTypeFormLoading(true)
setRelTypeFormError(null)
const newType = await relationshipService.createRelationshipType(currentProject.id, {
type_name: relTypeName.trim(),
type_description: relTypeDesc.trim() || null,
inverse_type_name: relTypeInverse.trim() || null,
})
setRelationshipTypes((prev) => [...prev, newType])
setShowCreateRelTypeModal(false)
resetRelTypeForm()
} catch (err: any) {
console.error('Failed to create relationship type:', err)
setRelTypeFormError(err.message || 'Failed to create relationship type')
} finally {
setRelTypeFormLoading(false)
}
}
// Handle update relationship type
const handleUpdateRelType = async (e: React.FormEvent) => {
e.preventDefault()
if (!currentProject || !selectedRelType) return
try {
setRelTypeFormLoading(true)
setRelTypeFormError(null)
const updatedType = await relationshipService.updateRelationshipType(
currentProject.id,
selectedRelType.id,
{
type_name: relTypeName.trim() || null,
type_description: relTypeDesc.trim() || null,
inverse_type_name: relTypeInverse.trim() || null,
}
)
setRelationshipTypes((prev) =>
prev.map((t) => (t.id === selectedRelType.id ? updatedType : t))
)
setShowEditRelTypeModal(false)
setSelectedRelType(null)
resetRelTypeForm()
} catch (err: any) {
console.error('Failed to update relationship type:', err)
setRelTypeFormError(err.message || 'Failed to update relationship type')
} finally {
setRelTypeFormLoading(false)
}
}
// Handle delete relationship type
const handleDeleteRelType = async () => {
if (!currentProject || !selectedRelType) return
try {
setRelTypeFormLoading(true)
setRelTypeFormError(null)
await relationshipService.deleteRelationshipType(currentProject.id, selectedRelType.id)
setRelationshipTypes((prev) => prev.filter((t) => t.id !== selectedRelType.id))
setShowDeleteConfirmModal(false)
setSelectedRelType(null)
} catch (err: any) {
console.error('Failed to delete relationship type:', err)
setRelTypeFormError(err.message || 'Failed to delete relationship type')
} finally {
setRelTypeFormLoading(false)
}
}
// Reset relationship type form
const resetRelTypeForm = () => {
setRelTypeName('')
setRelTypeDesc('')
setRelTypeInverse('')
setRelTypeFormError(null)
}
// Open edit modal
const openEditModal = (relType: RelationshipType) => {
setSelectedRelType(relType)
setRelTypeName(relType.type_name)
setRelTypeDesc(relType.type_description || '')
setRelTypeInverse(relType.inverse_type_name || '')
setRelTypeFormError(null)
setShowEditRelTypeModal(true)
}
// Open delete modal
const openDeleteModal = (relType: RelationshipType) => {
setSelectedRelType(relType)
setRelTypeFormError(null)
setShowDeleteConfirmModal(true)
}
if (!isAdmin) {
return null
}
if (!currentProject) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-700 mb-2">No Project Selected</h2>
<p className="text-gray-500 mb-4">Please select a project from the dashboard first.</p>
<button
onClick={() => navigate('/dashboard')}
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
>
Go to Dashboard
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="py-6 text-center border-b border-gray-200">
<h1 className="text-3xl font-semibold text-teal-700">Admin Panel</h1>
<p className="text-gray-500 mt-1">
Managing: <span className="font-medium">{currentProject.project_name}</span>
</p>
</header>
{/* Navigation */}
<div className="border-b border-gray-200">
<div className="max-w-4xl mx-auto px-8">
<nav className="flex gap-8">
<button
onClick={() => setActiveTab('project')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'project'
? 'border-teal-600 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Project Settings
</button>
<button
onClick={() => setActiveTab('members')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'members'
? 'border-teal-600 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Member Roles
</button>
<button
onClick={() => setActiveTab('relationships')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'relationships'
? 'border-teal-600 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Relationship Types
</button>
</nav>
</div>
</div>
{/* Back Button */}
<div className="max-w-4xl mx-auto px-8 pt-6">
<button
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 text-gray-600 hover:text-gray-800"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Dashboard
</button>
</div>
{/* Content */}
<div className="max-w-4xl mx-auto px-8 py-8">
{/* Project Settings Tab */}
{activeTab === 'project' && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Project Settings</h2>
<form onSubmit={handleProjectUpdate} className="space-y-6">
{projectError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{projectError}
</div>
)}
{projectSuccess && (
<div className="p-3 bg-green-100 border border-green-400 text-green-700 rounded text-sm">
{projectSuccess}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Project Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={projectDesc}
onChange={(e) => setProjectDesc(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={projectLoading}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
>
{projectLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
)}
{/* Members Tab */}
{activeTab === 'members' && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Member Roles</h2>
{membersError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{membersError}
</div>
)}
{membersLoading ? (
<div className="text-center py-8 text-gray-500">Loading members...</div>
) : members.length === 0 ? (
<div className="text-center py-8 text-gray-500">No members found</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Role</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{members.map((member) => {
const isCurrentUser = member.id === user?.db_user_id
return (
<tr key={member.id} className="border-b border-gray-100">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-900">{member.sub}</span>
{isCurrentUser && (
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded">
You
</span>
)}
</div>
</td>
<td className="py-3 px-4">
<span
className={`text-xs px-2 py-1 rounded ${
member.role_name === 'admin'
? 'bg-purple-100 text-purple-700'
: member.role_name === 'auditor'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{member.role_display_name}
</span>
</td>
<td className="py-3 px-4">
<select
value={member.role_id}
onChange={(e) => handleRoleUpdate(member.id, parseInt(e.target.value))}
disabled={updatingMember === member.id || (isCurrentUser && member.role_id === 3)}
className={`px-3 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 ${
isCurrentUser && member.role_id === 3 ? 'bg-gray-100 cursor-not-allowed' : ''
}`}
title={isCurrentUser && member.role_id === 3 ? 'You cannot demote yourself' : ''}
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
{updatingMember === member.id && (
<span className="ml-2 text-xs text-gray-500">Saving...</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Relationship Types Tab */}
{activeTab === 'relationships' && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800">Relationship Types</h2>
<button
onClick={() => {
resetRelTypeForm()
setShowCreateRelTypeModal(true)
}}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700"
>
+ Add Type
</button>
</div>
{relTypesError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{relTypesError}
</div>
)}
{relTypesLoading ? (
<div className="text-center py-8 text-gray-500">Loading relationship types...</div>
) : relationshipTypes.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No relationship types defined yet. Create one to link requirements.
</div>
) : (
<div className="space-y-3">
{relationshipTypes.map((relType) => (
<div
key={relType.id}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div>
<div className="font-medium text-gray-900">{relType.type_name}</div>
{relType.inverse_type_name && (
<div className="text-sm text-gray-500">
Inverse: {relType.inverse_type_name}
</div>
)}
{relType.type_description && (
<div className="text-sm text-gray-400 mt-1">{relType.type_description}</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openEditModal(relType)}
className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100"
>
Edit
</button>
<button
onClick={() => openDeleteModal(relType)}
className="px-3 py-1.5 border border-red-300 rounded text-sm text-red-600 hover:bg-red-50"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Create Relationship Type Modal */}
{showCreateRelTypeModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Create Relationship Type</h2>
<button
onClick={() => {
setShowCreateRelTypeModal(false)
resetRelTypeForm()
}}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleCreateRelType}>
<div className="px-6 py-4 space-y-4">
{relTypeFormError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{relTypeFormError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Type Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={relTypeName}
onChange={(e) => setRelTypeName(e.target.value)}
placeholder="e.g., Depends On"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Inverse Name
</label>
<input
type="text"
value={relTypeInverse}
onChange={(e) => setRelTypeInverse(e.target.value)}
placeholder="e.g., Depended By"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
<p className="text-xs text-gray-500 mt-1">
Optional. The name shown when viewing from the target requirement.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={relTypeDesc}
onChange={(e) => setRelTypeDesc(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
type="button"
onClick={() => {
setShowCreateRelTypeModal(false)
resetRelTypeForm()
}}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
disabled={relTypeFormLoading}
>
{relTypeFormLoading ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Relationship Type Modal */}
{showEditRelTypeModal && selectedRelType && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Edit Relationship Type</h2>
<button
onClick={() => {
setShowEditRelTypeModal(false)
setSelectedRelType(null)
resetRelTypeForm()
}}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleUpdateRelType}>
<div className="px-6 py-4 space-y-4">
{relTypeFormError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{relTypeFormError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Type Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={relTypeName}
onChange={(e) => setRelTypeName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Inverse Name
</label>
<input
type="text"
value={relTypeInverse}
onChange={(e) => setRelTypeInverse(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={relTypeDesc}
onChange={(e) => setRelTypeDesc(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
type="button"
onClick={() => {
setShowEditRelTypeModal(false)
setSelectedRelType(null)
resetRelTypeForm()
}}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
disabled={relTypeFormLoading}
>
{relTypeFormLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirmModal && selectedRelType && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Delete Relationship Type</h2>
</div>
<div className="px-6 py-4">
{relTypeFormError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{relTypeFormError}
</div>
)}
<p className="text-gray-700">
Are you sure you want to delete the relationship type{' '}
<span className="font-semibold">"{selectedRelType.type_name}"</span>?
</p>
<p className="text-sm text-red-600 mt-2">
This will also delete all requirement links using this type.
</p>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
onClick={() => {
setShowDeleteConfirmModal(false)
setSelectedRelType(null)
setRelTypeFormError(null)
}}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading}
>
Cancel
</button>
<button
onClick={handleDeleteRelType}
className="px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 disabled:opacity-50"
disabled={relTypeFormLoading}
>
{relTypeFormLoading ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -233,10 +233,15 @@ export default function DashboardPage() {
<span>Portuguese</span> <span>Portuguese</span>
</div> </div>
{/* Admin Panel Button */} {/* Admin Panel Button - Only visible for admins (role_id=3) */}
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"> {user?.role_id === 3 && (
<button
onClick={() => navigate('/admin')}
className="px-4 py-1.5 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Admin Panel Admin Panel
</button> </button>
)}
{/* User Info */} {/* User Info */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -2,3 +2,4 @@ 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' export { default as RequirementDetailPage } from './RequirementDetailPage'
export { default as AdminPage } from './AdminPage'

View File

@@ -15,5 +15,9 @@ export type {
RelationshipType, RelationshipType,
RequirementLink, RequirementLink,
RequirementSearchResult, RequirementSearchResult,
RequirementLinkCreateRequest RequirementLinkCreateRequest,
RelationshipTypeCreateRequest,
RelationshipTypeUpdateRequest
} from './relationshipService' } from './relationshipService'
export { userService } from './userService'
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'

View File

@@ -38,6 +38,18 @@ export interface RequirementLinkCreateRequest {
target_requirement_id: number target_requirement_id: number
} }
export interface RelationshipTypeCreateRequest {
type_name: string
type_description?: string | null
inverse_type_name?: string | null
}
export interface RelationshipTypeUpdateRequest {
type_name?: string | null
type_description?: string | null
inverse_type_name?: string | null
}
class RelationshipService { class RelationshipService {
/** /**
* Get all relationship types for a project. * Get all relationship types for a project.
@@ -58,6 +70,79 @@ class RelationshipService {
return await response.json() return await response.json()
} }
/**
* Create a new relationship type for a project.
*/
async createRelationshipType(
projectId: number,
data: RelationshipTypeCreateRequest
): Promise<RelationshipType> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/relationship-types`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
}
return await response.json()
}
/**
* Update a relationship type.
*/
async updateRelationshipType(
projectId: number,
typeId: number,
data: RelationshipTypeUpdateRequest
): Promise<RelationshipType> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/relationship-types/${typeId}`,
{
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
}
return await response.json()
}
/**
* Delete a relationship type.
*/
async deleteRelationshipType(projectId: number, typeId: number): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/relationship-types/${typeId}`,
{
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
}
}
/** /**
* Search requirements by name or tag code (for autocomplete). * Search requirements by name or tag code (for autocomplete).
*/ */

View File

@@ -0,0 +1,91 @@
const API_BASE_URL = '/api'
// Types
export interface Role {
id: number
role_name: string
display_name: string
}
export interface ProjectMember {
id: number
sub: string
role_id: number
role_name: string
role_display_name: string
created_at: string | null
}
export interface UserRoleUpdateRequest {
role_id: number
}
class UserService {
/**
* Get all available roles.
*/
async getRoles(): Promise<Role[]> {
const response = await fetch(`${API_BASE_URL}/roles`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
}
/**
* Get all members of a project with their role info.
*/
async getProjectMembers(projectId: number): Promise<ProjectMember[]> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/members`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
}
/**
* Update a project member's role.
*/
async updateMemberRole(
projectId: number,
userId: number,
roleId: number
): Promise<ProjectMember> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/members/${userId}/role`,
{
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ role_id: roleId }),
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
}
return await response.json()
}
}
export const userService = new UserService()