diff --git a/.env.example b/.env.example index e55c0fb..0fd42e4 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,11 @@ DATABASE_PASSWORD=your-password # Set to true to log all SQL queries (useful for debugging) DATABASE_ECHO=false + +# ------------------------------------------- +# Super Admin Configuration +# ------------------------------------------- +# Credentials for the Super Admin user to be auto-provisioned at startup +SUPER_ADMIN_USERNAME=masteradmin +SUPER_ADMIN_EMAIL=masteradmin@example.com +SUPER_ADMIN_PASSWORD=YourSecurePassword123! diff --git a/backend/src/config.py b/backend/src/config.py index b2761fe..35a8bfb 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -33,6 +33,11 @@ class Settings(BaseSettings): database_password: str = Field(default="postgres", env="DATABASE_PASSWORD") database_echo: bool = Field(default=False, env="DATABASE_ECHO") + # Super Admin settings (for auto-provisioning master user at startup) + super_admin_username: str | None = Field(default=None, env="SUPER_ADMIN_USERNAME") + super_admin_email: str | None = Field(default=None, env="SUPER_ADMIN_EMAIL") + super_admin_password: str | None = Field(default=None, env="SUPER_ADMIN_PASSWORD") + @property def database_url(self) -> str: """Construct the async database URL for SQLAlchemy.""" diff --git a/backend/src/main.py b/backend/src/main.py index 78de6ce..e3613aa 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -17,7 +17,9 @@ from src.models import ( RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES, CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest, RequirementStatusResponse, DeletedRequirementResponse, - UserCreateRequest, UserCreateResponse + UserCreateRequest, UserCreateResponse, + SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse, + AssignUserToProjectRequest, SystemUserCreateRequest ) from src.controller import AuthController from src.config import get_openid, get_settings @@ -83,6 +85,45 @@ async def lifespan(app: FastAPI): await session.commit() logger.info("Default validation statuses ensured") + # Provision super admin if configured + if settings.super_admin_username and settings.super_admin_email and settings.super_admin_password: + logger.info(f"Super admin configuration found, provisioning user: {settings.super_admin_username}") + try: + # Check if super admin already exists in local DB + async with AsyncSessionLocal() as session: + user_repo = UserRepository(session) + existing_user = await user_repo.get_by_username(settings.super_admin_username) + + if existing_user: + logger.info(f"Super admin already exists in database: {existing_user.username} (id: {existing_user.id})") + # Ensure they have super_admin role (role_id=6) + if existing_user.role_id != 6: + logger.info(f"Updating existing super admin to role_id=6") + await user_repo.update_role(existing_user.id, 6) + await session.commit() + else: + # Provision in Keycloak first + keycloak_sub = await KeycloakAdminService.provision_super_admin( + username=settings.super_admin_username, + email=settings.super_admin_email, + password=settings.super_admin_password + ) + + # Create in local database with super_admin role (role_id=6) + new_user = await user_repo.create( + sub=keycloak_sub, + role_id=6, # super_admin role + username=settings.super_admin_username, + full_name="Super Admin" + ) + await session.commit() + logger.info(f"Super admin provisioned successfully: {new_user.username} (id: {new_user.id})") + except Exception as e: + logger.error(f"Failed to provision super admin: {e}") + # Don't fail startup if super admin provisioning fails + else: + logger.info("No super admin configuration found, skipping provisioning") + yield # Shutdown @@ -2064,3 +2105,488 @@ async def delete_reply( await reply_repo.soft_delete_reply(reply_id) await db.commit() + + +# =========================================== +# Super Admin System Endpoints (role_id=6 only) +# =========================================== + +def _require_super_admin(user): + """ + Helper to check if user is a super admin (role_id=6). + + Raises: + HTTPException: 403 Forbidden if user is not a super admin + """ + if user.role_id != 6: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only super admins can access this endpoint" + ) + + +@app.get("/api/admin/system/users", response_model=List[SystemUserResponse]) +async def get_all_system_users( + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Get all users in the system (super admin only). + Includes Keycloak enabled status. + + Returns: + List of all users with their roles and status. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + user_repo = UserRepository(db) + users = await user_repo.get_all() + + # Build response with Keycloak status + result = [] + for user in users: + # Get enabled status from Keycloak + is_enabled = True + try: + is_enabled = await KeycloakAdminService.get_user_status(user.sub) + except Exception as e: + logger.warning(f"Could not get Keycloak status for user {user.id}: {e}") + + result.append(SystemUserResponse( + id=user.id, + sub=user.sub, + username=user.username, + full_name=user.full_name, + role_id=user.role_id, + role_name=user.role.role_name if user.role else "unknown", + role_display_name=ROLE_DISPLAY_NAMES.get(user.role.role_name, user.role.role_name.title()) if user.role else "Unknown", + is_enabled=is_enabled, + created_at=user.created_at + )) + + return result + + +@app.get("/api/admin/system/projects", response_model=List[SystemProjectResponse]) +async def get_all_system_projects( + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Get all projects in the system (super admin only). + Includes member count for each project. + + Returns: + List of all projects with member counts. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + project_repo = ProjectRepository(db) + projects = await project_repo.get_all() + + result = [] + for project in projects: + member_count = await project_repo.get_member_count(project.id) + result.append(SystemProjectResponse( + id=project.id, + project_name=project.project_name, + project_desc=project.project_desc, + member_count=member_count, + created_at=project.created_at + )) + + return result + + +@app.post("/api/admin/system/projects", response_model=SystemProjectResponse, status_code=status.HTTP_201_CREATED) +async def create_system_project( + request: Request, + project_data: ProjectCreateRequest, + db: AsyncSession = Depends(get_db) +): + """ + Create a new project (super admin only). + Unlike regular project creation, this doesn't add the creator as a member. + + Args: + project_data: The project data + + Returns: + The created project. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + project_repo = ProjectRepository(db) + project = await project_repo.create( + project_name=project_data.project_name, + project_desc=project_data.project_desc, + creator_id=None, # Super admin creates without being a member + ) + + await db.commit() + + return SystemProjectResponse( + id=project.id, + project_name=project.project_name, + project_desc=project.project_desc, + member_count=0, + created_at=project.created_at + ) + + +@app.get("/api/admin/system/projects/{project_id}/members", response_model=List[SystemProjectMemberResponse]) +async def get_system_project_members( + project_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Get all members of a project (super admin only). + + Args: + project_id: The project ID + + Returns: + List of project members. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + project_repo = ProjectRepository(db) + + # Check if project exists + project = await project_repo.get_by_id(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project with id {project_id} not found" + ) + + members = await project_repo.get_members(project_id) + + return [ + SystemProjectMemberResponse( + user_id=member.id, + username=member.username, + full_name=member.full_name, + role_id=member.role_id, + role_name=member.role.role_name if member.role else "unknown", + joined_at=None # TODO: Add joined_at from project_members table + ) + for member in members + ] + + +@app.post("/api/admin/system/projects/{project_id}/members", status_code=status.HTTP_201_CREATED) +async def assign_user_to_project( + project_id: int, + request: Request, + data: AssignUserToProjectRequest, + db: AsyncSession = Depends(get_db) +): + """ + Assign a user to a project (super admin only). + + Args: + project_id: The project ID + data: The user to assign + + Returns: + Success message. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + project_repo = ProjectRepository(db) + user_repo = UserRepository(db) + + # Check if project exists + project = await project_repo.get_by_id(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project with id {project_id} not found" + ) + + # Check if user exists + user = await user_repo.get_by_id(data.user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with id {data.user_id} not found" + ) + + # Optionally update role if provided + if data.role_id is not None: + role_repo = RoleRepository(db) + role = await role_repo.get_by_id(data.role_id) + if not role: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid role id {data.role_id}" + ) + await user_repo.update_role(data.user_id, data.role_id) + + # Add user to project + added = await project_repo.add_member(project_id, data.user_id) + + if not added: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User is already a member of this project" + ) + + await db.commit() + return {"message": "User assigned to project successfully"} + + +@app.delete("/api/admin/system/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_user_from_project( + project_id: int, + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Remove a user from a project (super admin only). + + Args: + project_id: The project ID + user_id: The user ID to remove + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + project_repo = ProjectRepository(db) + + # Check if project exists + project = await project_repo.get_by_id(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project with id {project_id} not found" + ) + + removed = await project_repo.remove_member(project_id, user_id) + + if not removed: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User is not a member of this project" + ) + + await db.commit() + + +@app.put("/api/admin/system/users/{user_id}/block", status_code=status.HTTP_200_OK) +async def block_user( + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Block a user by disabling them in Keycloak (super admin only). + Blocked users cannot log in. + + Args: + user_id: The user ID to block + + Returns: + Success message. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + # Prevent blocking yourself + if current_user.id == user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot block yourself" + ) + + user_repo = UserRepository(db) + user = await user_repo.get_by_id(user_id) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with id {user_id} not found" + ) + + # Disable in Keycloak + await KeycloakAdminService.disable_user(user.sub) + + return {"message": f"User {user.username or user.sub} has been blocked"} + + +@app.put("/api/admin/system/users/{user_id}/unblock", status_code=status.HTTP_200_OK) +async def unblock_user( + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Unblock a user by enabling them in Keycloak (super admin only). + + Args: + user_id: The user ID to unblock + + Returns: + Success message. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + user_repo = UserRepository(db) + user = await user_repo.get_by_id(user_id) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with id {user_id} not found" + ) + + # Enable in Keycloak + await KeycloakAdminService.enable_user(user.sub) + + return {"message": f"User {user.username or user.sub} has been unblocked"} + + +@app.put("/api/admin/system/users/{user_id}/role", response_model=SystemUserResponse) +async def update_system_user_role( + user_id: int, + request: Request, + role_data: UserRoleUpdateRequest, + db: AsyncSession = Depends(get_db) +): + """ + Update a user's role (super admin only). + Can promote users to super_admin or demote them. + + Args: + user_id: The user ID to update + role_data: The new role ID + + Returns: + The updated user info. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + # Prevent self-demotion from super admin + if current_user.id == user_id and role_data.role_id != 6: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot demote yourself. Ask another super 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 + 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() + + # Get enabled status from Keycloak + is_enabled = True + try: + is_enabled = await KeycloakAdminService.get_user_status(updated_user.sub) + except Exception as e: + logger.warning(f"Could not get Keycloak status for user {user_id}: {e}") + + return SystemUserResponse( + id=updated_user.id, + sub=updated_user.sub, + username=updated_user.username, + full_name=updated_user.full_name, + 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()), + is_enabled=is_enabled, + created_at=updated_user.created_at + ) + + +@app.post("/api/admin/system/users", response_model=SystemUserResponse, status_code=status.HTTP_201_CREATED) +async def create_system_user( + request: Request, + user_data: SystemUserCreateRequest, + db: AsyncSession = Depends(get_db) +): + """ + Create a new user in the system (super admin only). + Creates the user in Keycloak with a temporary password. + + Args: + user_data: The user data + + Returns: + The created user info. + """ + current_user = await _get_current_user_db(request, db) + _require_super_admin(current_user) + + # Validate role exists and is not super_admin unless current user is super admin + role_repo = RoleRepository(db) + role = await role_repo.get_by_id(user_data.role_id) + if not role: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid role id {user_data.role_id}" + ) + + # Create user in Keycloak + keycloak_sub = await KeycloakAdminService.create_user( + username=user_data.username, + email=user_data.email, + password=user_data.password, + first_name=user_data.first_name, + last_name=user_data.last_name + ) + + # Build full name + full_name = None + if user_data.first_name or user_data.last_name: + full_name = f"{user_data.first_name or ''} {user_data.last_name or ''}".strip() + + # Create in local database + user_repo = UserRepository(db) + new_user = await user_repo.create( + sub=keycloak_sub, + role_id=user_data.role_id, + username=user_data.username, + full_name=full_name + ) + + await db.commit() + + logger.info(f"Super admin {current_user.id} created new user {new_user.id} ({user_data.username})") + + return SystemUserResponse( + id=new_user.id, + sub=new_user.sub, + username=new_user.username, + full_name=new_user.full_name, + role_id=new_user.role_id, + role_name=role.role_name, + role_display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title()), + is_enabled=True, + created_at=new_user.created_at + ) diff --git a/backend/src/models.py b/backend/src/models.py index e996b3b..8dee159 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -27,7 +27,10 @@ class UserInfo(BaseModel): ROLE_DISPLAY_NAMES = { "editor": "Editor", "auditor": "Auditor", - "admin": "Project Admin" + "admin": "Project Admin", + "user": "User", + "viewer": "Viewer", + "super_admin": "Super Admin" } @@ -473,3 +476,61 @@ class UserCreateResponse(BaseModel): class Config: from_attributes = True + + +# Super Admin schemas +class SystemUserResponse(BaseModel): + """Response schema for a user in the system admin view.""" + id: int + sub: str + username: Optional[str] = None + full_name: Optional[str] = None + role_id: int + role_name: str + role_display_name: str + is_enabled: bool = True # From Keycloak + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class SystemProjectResponse(BaseModel): + """Response schema for a project in the system admin view.""" + id: int + project_name: str + project_desc: Optional[str] = None + member_count: int = 0 + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class SystemProjectMemberResponse(BaseModel): + """Response schema for a project member in system admin view.""" + user_id: int + username: Optional[str] = None + full_name: Optional[str] = None + role_id: int + role_name: str + joined_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class AssignUserToProjectRequest(BaseModel): + """Request schema for assigning a user to a project.""" + user_id: int + role_id: Optional[int] = None # Optional: set role when assigning + + +class SystemUserCreateRequest(BaseModel): + """Request schema for creating a user from super admin panel.""" + username: str + email: str + password: str + first_name: Optional[str] = None + last_name: Optional[str] = None + role_id: int = 1 # Default to editor diff --git a/backend/src/repositories/project_repository.py b/backend/src/repositories/project_repository.py index 22ea825..dd31602 100644 --- a/backend/src/repositories/project_repository.py +++ b/backend/src/repositories/project_repository.py @@ -234,3 +234,21 @@ class ProjectRepository: .where(ProjectMember.project_id == project_id) ) return list(result.scalars().all()) + + async def get_member_count(self, project_id: int) -> int: + """ + Get the count of members in a project. + + Args: + project_id: The project ID + + Returns: + Number of members in the project + """ + from sqlalchemy import func + result = await self.session.execute( + select(func.count()) + .select_from(ProjectMember) + .where(ProjectMember.project_id == project_id) + ) + return result.scalar() or 0 diff --git a/backend/src/repositories/user_repository.py b/backend/src/repositories/user_repository.py index 1b4c619..3a9159a 100644 --- a/backend/src/repositories/user_repository.py +++ b/backend/src/repositories/user_repository.py @@ -59,10 +59,10 @@ class RoleRepository: """ Ensure default roles exist in the database. Called during application startup. - Creates roles in order: editor (1), auditor (2), admin (3) + Creates roles in order: editor (1), auditor (2), admin (3), user (4), viewer (5), super_admin (6) """ - # Order matters for role IDs: editor=1, auditor=2, admin=3 - default_roles = ["editor", "auditor", "admin"] + # Order matters for role IDs: editor=1, auditor=2, admin=3, user=4, viewer=5, super_admin=6 + default_roles = ["editor", "auditor", "admin", "user", "viewer", "super_admin"] for role_name in default_roles: existing = await self.get_by_name(role_name) @@ -111,6 +111,37 @@ class UserRepository: ) return result.scalar_one_or_none() + async def get_by_username(self, username: str) -> Optional[User]: + """ + Get a user by their username. + + Args: + username: The username to search for + + Returns: + User if found, None otherwise + """ + result = await self.session.execute( + select(User) + .options(selectinload(User.role)) + .where(User.username == username) + ) + return result.scalar_one_or_none() + + async def get_all(self) -> List[User]: + """ + Get all users in the system. + + Returns: + List of all users with their roles + """ + result = await self.session.execute( + select(User) + .options(selectinload(User.role)) + .order_by(User.id) + ) + return list(result.scalars().all()) + async def create(self, sub: str, role_id: int, username: str, full_name: str | None = None) -> User: """ Create a new user. diff --git a/backend/src/service.py b/backend/src/service.py index 4a14482..e6307e9 100644 --- a/backend/src/service.py +++ b/backend/src/service.py @@ -328,3 +328,189 @@ class KeycloakAdminService: logger.info(f"Created user in Keycloak: {username} -> {keycloak_user_id}") return keycloak_user_id + + @classmethod + async def get_user_by_username(cls, username: str) -> dict | None: + """ + Get a user from Keycloak by username. + + Args: + username: The username to search for + + Returns: + The user data if found, None otherwise + """ + token = await cls._get_admin_token() + users_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users" + + async with httpx.AsyncClient(verify=False) as client: + response = await client.get( + f"{users_url}?username={username}&exact=true", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + users = response.json() + if users: + return users[0] + return None + + @classmethod + async def provision_super_admin( + cls, + username: str, + email: str, + password: str, + ) -> str: + """ + Provision the super admin user in Keycloak. + If user already exists, returns their ID. Otherwise creates them. + + Args: + username: The super admin username + email: The super admin email + password: The temporary password + + Returns: + The Keycloak user ID (sub) + """ + # Check if user already exists + existing_user = await cls.get_user_by_username(username) + if existing_user: + logger.info(f"Super admin user already exists in Keycloak: {username} -> {existing_user['id']}") + return existing_user["id"] + + # Create the user + logger.info(f"Creating super admin user in Keycloak: {username}") + return await cls.create_user( + username=username, + email=email, + password=password, + first_name="Super", + last_name="Admin" + ) + + @classmethod + async def disable_user(cls, keycloak_user_id: str) -> bool: + """ + Disable a user in Keycloak (block them from logging in). + + Args: + keycloak_user_id: The Keycloak user ID (sub) + + Returns: + True if successful + + Raises: + HTTPException: If the operation fails + """ + token = await cls._get_admin_token() + user_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users/{keycloak_user_id}" + + async with httpx.AsyncClient(verify=False) as client: + response = await client.put( + user_url, + json={"enabled": False}, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + ) + + if response.status_code == 204: + logger.info(f"Disabled user in Keycloak: {keycloak_user_id}") + return True + + if response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found in Keycloak" + ) + + logger.error(f"Failed to disable user in Keycloak: {response.status_code} - {response.text}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to disable user in Keycloak: {response.text}" + ) + + @classmethod + async def enable_user(cls, keycloak_user_id: str) -> bool: + """ + Enable a user in Keycloak (unblock them). + + Args: + keycloak_user_id: The Keycloak user ID (sub) + + Returns: + True if successful + + Raises: + HTTPException: If the operation fails + """ + token = await cls._get_admin_token() + user_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users/{keycloak_user_id}" + + async with httpx.AsyncClient(verify=False) as client: + response = await client.put( + user_url, + json={"enabled": True}, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + ) + + if response.status_code == 204: + logger.info(f"Enabled user in Keycloak: {keycloak_user_id}") + return True + + if response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found in Keycloak" + ) + + logger.error(f"Failed to enable user in Keycloak: {response.status_code} - {response.text}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to enable user in Keycloak: {response.text}" + ) + + @classmethod + async def get_user_status(cls, keycloak_user_id: str) -> bool: + """ + Get a user's enabled status from Keycloak. + + Args: + keycloak_user_id: The Keycloak user ID (sub) + + Returns: + True if user is enabled, False if disabled + + Raises: + HTTPException: If the operation fails + """ + token = await cls._get_admin_token() + user_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users/{keycloak_user_id}" + + async with httpx.AsyncClient(verify=False) as client: + response = await client.get( + user_url, + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + user_data = response.json() + return user_data.get("enabled", True) + + if response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found in Keycloak" + ) + + logger.error(f"Failed to get user status from Keycloak: {response.status_code} - {response.text}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get user status from Keycloak: {response.text}" + ) diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 61d8185..d607691 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -36,6 +36,11 @@ services: - ./backend:/app env_file: - .env + environment: + # Super Admin credentials (optional - if set, creates master user at startup) + - SUPER_ADMIN_USERNAME=${SUPER_ADMIN_USERNAME:-} + - SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL:-} + - SUPER_ADMIN_PASSWORD=${SUPER_ADMIN_PASSWORD:-} depends_on: keycloak: condition: service_healthy diff --git a/docker-compose.yaml b/docker-compose.yaml index 2efa1f2..aa90172 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -38,6 +38,11 @@ services: - ./backend:/app env_file: - .env + environment: + # Super Admin credentials (optional - if set, creates master user at startup) + - SUPER_ADMIN_USERNAME=${SUPER_ADMIN_USERNAME:-} + - SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL:-} + - SUPER_ADMIN_PASSWORD=${SUPER_ADMIN_PASSWORD:-} extra_hosts: - "localhost:host-gateway" depends_on: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11c1c71..acbf26a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import DashboardPage from '@/pages/DashboardPage' import RequirementsPage from '@/pages/RequirementsPage' import RequirementDetailPage from '@/pages/RequirementDetailPage' import AdminPage from '@/pages/AdminPage' +import SuperAdminPage from '@/pages/SuperAdminPage' import ProtectedRoute from '@/components/ProtectedRoute' function App() { @@ -21,7 +22,7 @@ function App() { + } @@ -29,7 +30,7 @@ function App() { + } @@ -37,7 +38,7 @@ function App() { + } @@ -45,11 +46,19 @@ function App() { + } /> + + + + } + /> ) } diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 82251f0..8e3d937 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -4,10 +4,18 @@ import { useAuth } from '@/hooks' interface ProtectedRouteProps { children: ReactNode + /** If true, super admins (role_id=6) will be redirected to /super-admin */ + blockSuperAdmin?: boolean + /** If true, only super admins (role_id=6) can access this route */ + requireSuperAdmin?: boolean } -export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { isAuthenticated, isLoading } = useAuth() +export default function ProtectedRoute({ + children, + blockSuperAdmin = false, + requireSuperAdmin = false +}: ProtectedRouteProps) { + const { isAuthenticated, isLoading, user } = useAuth() if (isLoading) { return ( @@ -24,5 +32,18 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { return } + // Check if user is a super admin (role_id=6) + const isSuperAdmin = user?.role_id === 6 + + // If route requires super admin and user is not, redirect to dashboard + if (requireSuperAdmin && !isSuperAdmin) { + return + } + + // If route blocks super admins and user is one, redirect to super admin page + if (blockSuperAdmin && isSuperAdmin) { + return + } + return <>{children} } diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index f459d5a..488d409 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -81,5 +81,57 @@ "warningMessage": "⚠️ This will also delete all requirement links using this type.", "deleteButton": "Delete", "errorDeleting": "Failed to delete relationship type" + }, + "superAdmin": { + "title": "Super Admin", + "subtitle": "System Management", + "role": "Super Admin", + "systemManagement": "System Management", + "systemStats": "System Stats", + "totalUsers": "Users", + "totalProjects": "Projects", + "tabs": { + "users": "Users", + "projects": "Projects", + "assignments": "Assignments" + }, + "users": { + "title": "All Users", + "createUser": "Create User", + "username": "Username", + "role": "Role", + "status": "Status", + "actions": "Actions", + "active": "Active", + "blocked": "Blocked", + "block": "Block", + "unblock": "Unblock", + "you": "You" + }, + "projects": { + "title": "All Projects", + "createProject": "Create Project", + "projectName": "Project Name", + "projectNamePlaceholder": "Enter project name", + "description": "Description", + "descriptionPlaceholder": "Enter project description", + "members": "members", + "manageMembers": "Manage Members", + "noProjects": "No projects found", + "create": "Create" + }, + "assignments": { + "title": "User-Project Assignments", + "selectProject": "Select Project", + "chooseProject": "Choose a project...", + "addMember": "Add Member", + "selectUser": "Select User", + "chooseUser": "Choose a user...", + "noMembers": "No members in this project", + "remove": "Remove", + "assign": "Assign", + "confirmRemove": "Are you sure you want to remove this user from the project?", + "allUsersAssigned": "All users are already assigned to this project" + } } } diff --git a/frontend/src/i18n/locales/en/dashboard.json b/frontend/src/i18n/locales/en/dashboard.json index ccc5c2c..48b59ad 100644 --- a/frontend/src/i18n/locales/en/dashboard.json +++ b/frontend/src/i18n/locales/en/dashboard.json @@ -13,7 +13,8 @@ }, "header": { "projects": "Projects", - "admin": "Admin" + "admin": "Admin", + "searchPlaceholder": "Search requirements by code or name..." }, "projectDropdown": { "loading": "Loading...", diff --git a/frontend/src/i18n/locales/pt/admin.json b/frontend/src/i18n/locales/pt/admin.json index 17815af..77809bf 100644 --- a/frontend/src/i18n/locales/pt/admin.json +++ b/frontend/src/i18n/locales/pt/admin.json @@ -81,5 +81,57 @@ "warningMessage": "⚠️ Isso também excluirá todos os links de requisitos usando este tipo.", "deleteButton": "Excluir", "errorDeleting": "Falha ao excluir tipo de relacionamento" + }, + "superAdmin": { + "title": "Super Admin", + "subtitle": "Gerenciamento do Sistema", + "role": "Super Admin", + "systemManagement": "Gerenciamento do Sistema", + "systemStats": "Estatísticas do Sistema", + "totalUsers": "Usuários", + "totalProjects": "Projetos", + "tabs": { + "users": "Usuários", + "projects": "Projetos", + "assignments": "Atribuições" + }, + "users": { + "title": "Todos os Usuários", + "createUser": "Criar Usuário", + "username": "Usuário", + "role": "Função", + "status": "Status", + "actions": "Ações", + "active": "Ativo", + "blocked": "Bloqueado", + "block": "Bloquear", + "unblock": "Desbloquear", + "you": "Você" + }, + "projects": { + "title": "Todos os Projetos", + "createProject": "Criar Projeto", + "projectName": "Nome do Projeto", + "projectNamePlaceholder": "Digite o nome do projeto", + "description": "Descrição", + "descriptionPlaceholder": "Digite a descrição do projeto", + "members": "membros", + "manageMembers": "Gerenciar Membros", + "noProjects": "Nenhum projeto encontrado", + "create": "Criar" + }, + "assignments": { + "title": "Atribuições de Usuários em Projetos", + "selectProject": "Selecionar Projeto", + "chooseProject": "Escolha um projeto...", + "addMember": "Adicionar Membro", + "selectUser": "Selecionar Usuário", + "chooseUser": "Escolha um usuário...", + "noMembers": "Nenhum membro neste projeto", + "remove": "Remover", + "assign": "Atribuir", + "confirmRemove": "Tem certeza de que deseja remover este usuário do projeto?", + "allUsersAssigned": "Todos os usuários já estão atribuídos a este projeto" + } } } diff --git a/frontend/src/i18n/locales/pt/dashboard.json b/frontend/src/i18n/locales/pt/dashboard.json index 465a270..3de4ecb 100644 --- a/frontend/src/i18n/locales/pt/dashboard.json +++ b/frontend/src/i18n/locales/pt/dashboard.json @@ -13,7 +13,8 @@ }, "header": { "projects": "Projetos", - "admin": "Admin" + "admin": "Admin", + "searchPlaceholder": "Buscar requisitos por código ou nome..." }, "projectDropdown": { "loading": "Carregando...", diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0239edf..66d5fcb 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -83,6 +83,9 @@ export default function DashboardPage() { const [error, setError] = useState(null) const [hoveredGroup, setHoveredGroup] = useState(null) + // Search state + const [searchQuery, setSearchQuery] = useState('') + // Project dropdown state const [showProjectDropdown, setShowProjectDropdown] = useState(false) const [showCreateProjectModal, setShowCreateProjectModal] = useState(false) @@ -175,6 +178,15 @@ export default function DashboardPage() { navigate('/requirements') } + const handleSearch = (e: React.FormEvent) => { + e.preventDefault() + if (searchQuery.trim()) { + navigate('/requirements', { state: { searchQuery: searchQuery.trim() } }) + } else { + navigate('/requirements') + } + } + const handleCreateRequirementClick = () => { navigate('/requirements', { state: { openCreateModal: true } }) } @@ -261,17 +273,6 @@ export default function DashboardPage() {

{t('sidebar.navigation')}

- {/* Search Requirements */} - - {/* My Requirements */} + + + + + + + {/* Stats */} +
+

+ {t('superAdmin.systemStats')} +

+
+
+

{users.length}

+

{t('superAdmin.totalUsers')}

+
+
+

{projects.length}

+

{t('superAdmin.totalProjects')}

+
+
+
+ + + {/* Main Content */} +
+ {/* Top Bar */} +
+
+
+ {t('superAdmin.systemManagement')} +
+ + {/* Right side utilities */} +
+ +
+ + {/* User Info */} +
+
+
+ + + +
+
+

+ {user?.full_name || user?.preferred_username || 'Super Admin'} +

+

{t('superAdmin.role')}

+
+
+ +
+
+
+
+ + {/* Page Content */} +
+ {/* Users Tab */} + {activeTab === 'users' && ( +
+
+

+ {t('superAdmin.users.title')} +

+ +
+ + {usersLoading ? ( +
+
+
+ ) : ( +
+ + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
+ {t('superAdmin.users.username')} + + {t('superAdmin.users.role')} + + {t('superAdmin.users.status')} + + {t('superAdmin.users.actions')} +
+
+
+ + {(u.username || u.sub).charAt(0).toUpperCase()} + +
+
+
+ {u.username || u.sub} + {u.id === user?.db_user_id && ( + + {t('superAdmin.users.you')} + + )} +
+ {u.full_name && ( +
{u.full_name}
+ )} +
+
+
+ + + + {u.is_enabled ? t('superAdmin.users.active') : t('superAdmin.users.blocked')} + + + {u.id !== user?.db_user_id && ( + + )} +
+
+ )} +
+ )} + + {/* Projects Tab */} + {activeTab === 'projects' && ( +
+
+

+ {t('superAdmin.projects.title')} +

+ +
+ + {projectsLoading ? ( +
+
+
+ ) : projects.length === 0 ? ( +
+ + + +

{t('superAdmin.projects.noProjects')}

+
+ ) : ( +
+ {projects.map((project) => ( +
+
+
+

{project.project_name}

+ {project.project_desc && ( +

+ {project.project_desc} +

+ )} +
+ + {project.member_count} {t('superAdmin.projects.members')} + +
+ +
+ ))} +
+ )} +
+ )} + + {/* Assignments Tab */} + {activeTab === 'assignments' && ( +
+

+ {t('superAdmin.assignments.title')} +

+ + {/* Project Selector */} +
+ + +
+ + {selectedProject && ( +
+
+
+

{selectedProject.project_name}

+

+ {projectMembers.length} {t('superAdmin.projects.members')} +

+
+ +
+ + {membersLoading ? ( +
+
+
+ ) : projectMembers.length === 0 ? ( +
+

{t('superAdmin.assignments.noMembers')}

+
+ ) : ( + + + + + + + + + + {projectMembers.map((member) => ( + + + + + + ))} + +
+ {t('superAdmin.users.username')} + + {t('superAdmin.users.role')} + + {t('superAdmin.users.actions')} +
+
+ {member.username || `User ${member.user_id}`} +
+ {member.full_name && ( +
{member.full_name}
+ )} +
+ {member.role_name} + + +
+ )} +
+ )} +
+ )} +
+
+ + {/* Create User Modal */} + {showCreateUserModal && ( +
+
+
+

{t('createUserModal.title')}

+ +
+ +
+
+ {createUserError && ( +
+ {createUserError} +
+ )} + +
+ + setNewUsername(e.target.value)} + placeholder={t('createUserModal.usernamePlaceholder')} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + required + /> +
+ +
+ + setNewEmail(e.target.value)} + placeholder={t('createUserModal.emailPlaceholder')} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + required + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder={t('createUserModal.passwordPlaceholder')} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + required + /> +

{t('createUserModal.passwordHint')}

+
+ +
+
+ + setNewFirstName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + /> +
+
+ + setNewLastName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + /> +
+
+ +
+ + +
+
+ +
+ + +
+
+
+
+ )} + + {/* Create Project Modal */} + {showCreateProjectModal && ( +
+
+
+

{t('superAdmin.projects.createProject')}

+ +
+ +
+
+ {createProjectError && ( +
+ {createProjectError} +
+ )} + +
+ + setNewProjectName(e.target.value)} + placeholder={t('superAdmin.projects.projectNamePlaceholder')} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + required + /> +
+ +
+ +