Added super user for whole service management
This commit is contained in:
@@ -53,3 +53,11 @@ DATABASE_PASSWORD=your-password
|
|||||||
|
|
||||||
# Set to true to log all SQL queries (useful for debugging)
|
# Set to true to log all SQL queries (useful for debugging)
|
||||||
DATABASE_ECHO=false
|
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!
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class Settings(BaseSettings):
|
|||||||
database_password: str = Field(default="postgres", env="DATABASE_PASSWORD")
|
database_password: str = Field(default="postgres", env="DATABASE_PASSWORD")
|
||||||
database_echo: bool = Field(default=False, env="DATABASE_ECHO")
|
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
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Construct the async database URL for SQLAlchemy."""
|
"""Construct the async database URL for SQLAlchemy."""
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ from src.models import (
|
|||||||
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
||||||
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
||||||
RequirementStatusResponse, DeletedRequirementResponse,
|
RequirementStatusResponse, DeletedRequirementResponse,
|
||||||
UserCreateRequest, UserCreateResponse
|
UserCreateRequest, UserCreateResponse,
|
||||||
|
SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse,
|
||||||
|
AssignUserToProjectRequest, SystemUserCreateRequest
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -83,6 +85,45 @@ async def lifespan(app: FastAPI):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Default validation statuses ensured")
|
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
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
@@ -2064,3 +2105,488 @@ async def delete_reply(
|
|||||||
|
|
||||||
await reply_repo.soft_delete_reply(reply_id)
|
await reply_repo.soft_delete_reply(reply_id)
|
||||||
await db.commit()
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ class UserInfo(BaseModel):
|
|||||||
ROLE_DISPLAY_NAMES = {
|
ROLE_DISPLAY_NAMES = {
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"auditor": "Auditor",
|
"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:
|
class Config:
|
||||||
from_attributes = True
|
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
|
||||||
|
|||||||
@@ -234,3 +234,21 @@ class ProjectRepository:
|
|||||||
.where(ProjectMember.project_id == project_id)
|
.where(ProjectMember.project_id == project_id)
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
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
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ class RoleRepository:
|
|||||||
"""
|
"""
|
||||||
Ensure default roles exist in the database.
|
Ensure default roles exist in the database.
|
||||||
Called during application startup.
|
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
|
# Order matters for role IDs: editor=1, auditor=2, admin=3, user=4, viewer=5, super_admin=6
|
||||||
default_roles = ["editor", "auditor", "admin"]
|
default_roles = ["editor", "auditor", "admin", "user", "viewer", "super_admin"]
|
||||||
|
|
||||||
for role_name in default_roles:
|
for role_name in default_roles:
|
||||||
existing = await self.get_by_name(role_name)
|
existing = await self.get_by_name(role_name)
|
||||||
@@ -111,6 +111,37 @@ class UserRepository:
|
|||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
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:
|
async def create(self, sub: str, role_id: int, username: str, full_name: str | None = None) -> User:
|
||||||
"""
|
"""
|
||||||
Create a new user.
|
Create a new user.
|
||||||
|
|||||||
@@ -328,3 +328,189 @@ class KeycloakAdminService:
|
|||||||
|
|
||||||
logger.info(f"Created user in Keycloak: {username} -> {keycloak_user_id}")
|
logger.info(f"Created user in Keycloak: {username} -> {keycloak_user_id}")
|
||||||
return 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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ services:
|
|||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .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:
|
depends_on:
|
||||||
keycloak:
|
keycloak:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ services:
|
|||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .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:
|
extra_hosts:
|
||||||
- "localhost:host-gateway"
|
- "localhost:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 AdminPage from '@/pages/AdminPage'
|
||||||
|
import SuperAdminPage from '@/pages/SuperAdminPage'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -21,7 +22,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute blockSuperAdmin>
|
||||||
<DashboardPage />
|
<DashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/requirements"
|
path="/requirements"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute blockSuperAdmin>
|
||||||
<RequirementsPage />
|
<RequirementsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -37,7 +38,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/requirements/:id"
|
path="/requirements/:id"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute blockSuperAdmin>
|
||||||
<RequirementDetailPage />
|
<RequirementDetailPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -45,11 +46,19 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute blockSuperAdmin>
|
||||||
<AdminPage />
|
<AdminPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/super-admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireSuperAdmin>
|
||||||
|
<SuperAdminPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { useAuth } from '@/hooks'
|
|||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: ReactNode
|
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) {
|
export default function ProtectedRoute({
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
children,
|
||||||
|
blockSuperAdmin = false,
|
||||||
|
requireSuperAdmin = false
|
||||||
|
}: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated, isLoading, user } = useAuth()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -24,5 +32,18 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|||||||
return <Navigate to="/" replace />
|
return <Navigate to="/" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 <Navigate to="/dashboard" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
// If route blocks super admins and user is one, redirect to super admin page
|
||||||
|
if (blockSuperAdmin && isSuperAdmin) {
|
||||||
|
return <Navigate to="/super-admin" replace />
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,5 +81,57 @@
|
|||||||
"warningMessage": "⚠️ This will also delete all requirement links using this type.",
|
"warningMessage": "⚠️ This will also delete all requirement links using this type.",
|
||||||
"deleteButton": "Delete",
|
"deleteButton": "Delete",
|
||||||
"errorDeleting": "Failed to delete relationship type"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"admin": "Admin"
|
"admin": "Admin",
|
||||||
|
"searchPlaceholder": "Search requirements by code or name..."
|
||||||
},
|
},
|
||||||
"projectDropdown": {
|
"projectDropdown": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
|||||||
@@ -81,5 +81,57 @@
|
|||||||
"warningMessage": "⚠️ Isso também excluirá todos os links de requisitos usando este tipo.",
|
"warningMessage": "⚠️ Isso também excluirá todos os links de requisitos usando este tipo.",
|
||||||
"deleteButton": "Excluir",
|
"deleteButton": "Excluir",
|
||||||
"errorDeleting": "Falha ao excluir tipo de relacionamento"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"projects": "Projetos",
|
"projects": "Projetos",
|
||||||
"admin": "Admin"
|
"admin": "Admin",
|
||||||
|
"searchPlaceholder": "Buscar requisitos por código ou nome..."
|
||||||
},
|
},
|
||||||
"projectDropdown": {
|
"projectDropdown": {
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export default function DashboardPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [hoveredGroup, setHoveredGroup] = useState<number | null>(null)
|
const [hoveredGroup, setHoveredGroup] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
// Project dropdown state
|
// Project dropdown state
|
||||||
const [showProjectDropdown, setShowProjectDropdown] = useState(false)
|
const [showProjectDropdown, setShowProjectDropdown] = useState(false)
|
||||||
const [showCreateProjectModal, setShowCreateProjectModal] = useState(false)
|
const [showCreateProjectModal, setShowCreateProjectModal] = useState(false)
|
||||||
@@ -175,6 +178,15 @@ export default function DashboardPage() {
|
|||||||
navigate('/requirements')
|
navigate('/requirements')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
navigate('/requirements', { state: { searchQuery: searchQuery.trim() } })
|
||||||
|
} else {
|
||||||
|
navigate('/requirements')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateRequirementClick = () => {
|
const handleCreateRequirementClick = () => {
|
||||||
navigate('/requirements', { state: { openCreateModal: true } })
|
navigate('/requirements', { state: { openCreateModal: true } })
|
||||||
}
|
}
|
||||||
@@ -261,17 +273,6 @@ export default function DashboardPage() {
|
|||||||
<p className="px-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">{t('sidebar.navigation')}</p>
|
<p className="px-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">{t('sidebar.navigation')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Requirements */}
|
|
||||||
<button
|
|
||||||
onClick={handleMyRequirementsClick}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-slate-300 hover:bg-slate-700 hover:text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
{t('sidebar.searchRequirements')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* My Requirements */}
|
{/* My Requirements */}
|
||||||
<button
|
<button
|
||||||
onClick={handleMyRequirementsClick}
|
onClick={handleMyRequirementsClick}
|
||||||
@@ -388,6 +389,27 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<form onSubmit={handleSearch} className="flex-1 max-w-md mx-8">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('header.searchPlaceholder')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
{/* Right side utilities - grouped tighter */}
|
{/* Right side utilities - grouped tighter */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Language Selector */}
|
{/* Language Selector */}
|
||||||
|
|||||||
@@ -141,8 +141,13 @@ export default function RequirementsPage() {
|
|||||||
// Clear the state so it doesn't reopen on refresh
|
// Clear the state so it doesn't reopen on refresh
|
||||||
navigate(location.pathname, { replace: true, state: {} })
|
navigate(location.pathname, { replace: true, state: {} })
|
||||||
}
|
}
|
||||||
|
// Handle search query from navigation state (from dashboard search bar)
|
||||||
|
if (location.state?.searchQuery) {
|
||||||
|
setSearchQuery(location.state.searchQuery)
|
||||||
|
navigate(location.pathname, { replace: true, state: {} })
|
||||||
|
}
|
||||||
// Handle needs revalidation filter from navigation state
|
// Handle needs revalidation filter from navigation state
|
||||||
if (location.state?.needsRevalidation) {
|
else if (location.state?.needsRevalidation) {
|
||||||
setNeedsRevalidationFilter(true)
|
setNeedsRevalidationFilter(true)
|
||||||
navigate(location.pathname, { replace: true, state: {} })
|
navigate(location.pathname, { replace: true, state: {} })
|
||||||
}
|
}
|
||||||
|
|||||||
987
frontend/src/pages/SuperAdminPage.tsx
Normal file
987
frontend/src/pages/SuperAdminPage.tsx
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuth } from '@/hooks'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { superAdminService, userService } from '@/services'
|
||||||
|
import type { SystemUser, SystemProject, SystemProjectMember } from '@/services/superAdminService'
|
||||||
|
import type { Role } from '@/services/userService'
|
||||||
|
import { LanguageSelector } from '@/components'
|
||||||
|
|
||||||
|
type TabType = 'users' | 'projects' | 'assignments'
|
||||||
|
|
||||||
|
export default function SuperAdminPage() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { t } = useTranslation('admin')
|
||||||
|
const { t: tCommon } = useTranslation('common')
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('users')
|
||||||
|
|
||||||
|
// Users state
|
||||||
|
const [users, setUsers] = useState<SystemUser[]>([])
|
||||||
|
const [usersLoading, setUsersLoading] = useState(true)
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
|
||||||
|
// Projects state
|
||||||
|
const [projects, setProjects] = useState<SystemProject[]>([])
|
||||||
|
const [projectsLoading, setProjectsLoading] = useState(true)
|
||||||
|
|
||||||
|
// Assignments state
|
||||||
|
const [selectedProject, setSelectedProject] = useState<SystemProject | null>(null)
|
||||||
|
const [projectMembers, setProjectMembers] = useState<SystemProjectMember[]>([])
|
||||||
|
const [membersLoading, setMembersLoading] = useState(false)
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
|
||||||
|
const [showCreateProjectModal, setShowCreateProjectModal] = useState(false)
|
||||||
|
const [showAssignUserModal, setShowAssignUserModal] = useState(false)
|
||||||
|
|
||||||
|
// Form state for create user
|
||||||
|
const [newUsername, setNewUsername] = useState('')
|
||||||
|
const [newEmail, setNewEmail] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [newFirstName, setNewFirstName] = useState('')
|
||||||
|
const [newLastName, setNewLastName] = useState('')
|
||||||
|
const [newRoleId, setNewRoleId] = useState(1)
|
||||||
|
const [createUserLoading, setCreateUserLoading] = useState(false)
|
||||||
|
const [createUserError, setCreateUserError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Form state for create project
|
||||||
|
const [newProjectName, setNewProjectName] = useState('')
|
||||||
|
const [newProjectDesc, setNewProjectDesc] = useState('')
|
||||||
|
const [createProjectLoading, setCreateProjectLoading] = useState(false)
|
||||||
|
const [createProjectError, setCreateProjectError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Form state for assign user
|
||||||
|
const [assignUserId, setAssignUserId] = useState<number | null>(null)
|
||||||
|
const [assignLoading, setAssignLoading] = useState(false)
|
||||||
|
const [assignError, setAssignError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Check if user is super admin
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.role_id !== 6) {
|
||||||
|
navigate('/dashboard')
|
||||||
|
}
|
||||||
|
}, [user, navigate])
|
||||||
|
|
||||||
|
// Fetch users and roles
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUsersAndRoles = async () => {
|
||||||
|
try {
|
||||||
|
setUsersLoading(true)
|
||||||
|
const [fetchedUsers, fetchedRoles] = await Promise.all([
|
||||||
|
superAdminService.getAllUsers(),
|
||||||
|
userService.getRoles()
|
||||||
|
])
|
||||||
|
setUsers(fetchedUsers)
|
||||||
|
setRoles(fetchedRoles)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch users:', err)
|
||||||
|
} finally {
|
||||||
|
setUsersLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchUsersAndRoles()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fetch projects
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
setProjectsLoading(true)
|
||||||
|
const fetchedProjects = await superAdminService.getAllProjects()
|
||||||
|
setProjects(fetchedProjects)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch projects:', err)
|
||||||
|
} finally {
|
||||||
|
setProjectsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProjects()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fetch project members when project is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProject) {
|
||||||
|
const fetchMembers = async () => {
|
||||||
|
try {
|
||||||
|
setMembersLoading(true)
|
||||||
|
const members = await superAdminService.getProjectMembers(selectedProject.id)
|
||||||
|
setProjectMembers(members)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch project members:', err)
|
||||||
|
} finally {
|
||||||
|
setMembersLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchMembers()
|
||||||
|
} else {
|
||||||
|
setProjectMembers([])
|
||||||
|
}
|
||||||
|
}, [selectedProject])
|
||||||
|
|
||||||
|
const handleBlockUser = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
await superAdminService.blockUser(userId)
|
||||||
|
setUsers(users.map(u => u.id === userId ? { ...u, is_enabled: false } : u))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to block user:', err)
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to block user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnblockUser = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
await superAdminService.unblockUser(userId)
|
||||||
|
setUsers(users.map(u => u.id === userId ? { ...u, is_enabled: true } : u))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to unblock user:', err)
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to unblock user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRoleChange = async (userId: number, newRoleId: number) => {
|
||||||
|
try {
|
||||||
|
const updatedUser = await superAdminService.updateUserRole(userId, { role_id: newRoleId })
|
||||||
|
setUsers(users.map(u => u.id === userId ? updatedUser : u))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update role:', err)
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to update role')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!newUsername.trim() || !newEmail.trim() || !newPassword.trim()) {
|
||||||
|
setCreateUserError('Username, email, and password are required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreateUserLoading(true)
|
||||||
|
setCreateUserError(null)
|
||||||
|
|
||||||
|
const newUser = await superAdminService.createUser({
|
||||||
|
username: newUsername.trim(),
|
||||||
|
email: newEmail.trim(),
|
||||||
|
password: newPassword,
|
||||||
|
first_name: newFirstName.trim() || undefined,
|
||||||
|
last_name: newLastName.trim() || undefined,
|
||||||
|
role_id: newRoleId
|
||||||
|
})
|
||||||
|
|
||||||
|
setUsers([...users, newUser])
|
||||||
|
setShowCreateUserModal(false)
|
||||||
|
resetCreateUserForm()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create user:', err)
|
||||||
|
setCreateUserError(err instanceof Error ? err.message : 'Failed to create user')
|
||||||
|
} finally {
|
||||||
|
setCreateUserLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCreateUserForm = () => {
|
||||||
|
setNewUsername('')
|
||||||
|
setNewEmail('')
|
||||||
|
setNewPassword('')
|
||||||
|
setNewFirstName('')
|
||||||
|
setNewLastName('')
|
||||||
|
setNewRoleId(1)
|
||||||
|
setCreateUserError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateProject = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!newProjectName.trim()) {
|
||||||
|
setCreateProjectError('Project name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreateProjectLoading(true)
|
||||||
|
setCreateProjectError(null)
|
||||||
|
|
||||||
|
const newProject = await superAdminService.createProject({
|
||||||
|
project_name: newProjectName.trim(),
|
||||||
|
project_desc: newProjectDesc.trim() || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
setProjects([...projects, newProject])
|
||||||
|
setShowCreateProjectModal(false)
|
||||||
|
setNewProjectName('')
|
||||||
|
setNewProjectDesc('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create project:', err)
|
||||||
|
setCreateProjectError(err instanceof Error ? err.message : 'Failed to create project')
|
||||||
|
} finally {
|
||||||
|
setCreateProjectLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssignUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!selectedProject || !assignUserId) {
|
||||||
|
setAssignError('Please select a user')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAssignLoading(true)
|
||||||
|
setAssignError(null)
|
||||||
|
|
||||||
|
await superAdminService.assignUserToProject(selectedProject.id, {
|
||||||
|
user_id: assignUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh members
|
||||||
|
const members = await superAdminService.getProjectMembers(selectedProject.id)
|
||||||
|
setProjectMembers(members)
|
||||||
|
|
||||||
|
// Update project member count
|
||||||
|
setProjects(projects.map(p =>
|
||||||
|
p.id === selectedProject.id
|
||||||
|
? { ...p, member_count: p.member_count + 1 }
|
||||||
|
: p
|
||||||
|
))
|
||||||
|
|
||||||
|
setShowAssignUserModal(false)
|
||||||
|
setAssignUserId(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to assign user:', err)
|
||||||
|
setAssignError(err instanceof Error ? err.message : 'Failed to assign user')
|
||||||
|
} finally {
|
||||||
|
setAssignLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveFromProject = async (userId: number) => {
|
||||||
|
if (!selectedProject) return
|
||||||
|
|
||||||
|
if (!confirm(t('superAdmin.assignments.confirmRemove'))) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await superAdminService.removeUserFromProject(selectedProject.id, userId)
|
||||||
|
setProjectMembers(projectMembers.filter(m => m.user_id !== userId))
|
||||||
|
|
||||||
|
// Update project member count
|
||||||
|
setProjects(projects.map(p =>
|
||||||
|
p.id === selectedProject.id
|
||||||
|
? { ...p, member_count: Math.max(0, p.member_count - 1) }
|
||||||
|
: p
|
||||||
|
))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove user from project:', err)
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to remove user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get users not in the selected project
|
||||||
|
const availableUsersForAssignment = users.filter(
|
||||||
|
u => !projectMembers.some(m => m.user_id === u.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-slate-800 text-white flex-shrink-0 min-h-screen">
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="p-6 border-b border-slate-700">
|
||||||
|
<h1 className="text-lg font-semibold text-amber-400">
|
||||||
|
{t('superAdmin.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400">{t('superAdmin.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="p-4 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
|
||||||
|
activeTab === 'users'
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
{t('superAdmin.tabs.users')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('projects')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
|
||||||
|
activeTab === 'projects'
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
{t('superAdmin.tabs.projects')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('assignments')}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
|
||||||
|
activeTab === 'assignments'
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
{t('superAdmin.tabs.assignments')}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="absolute bottom-0 left-0 w-64 p-4 border-t border-slate-700 bg-slate-900">
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||||
|
{t('superAdmin.systemStats')}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-center">
|
||||||
|
<div className="bg-slate-800 rounded p-2">
|
||||||
|
<p className="text-xl font-bold text-white">{users.length}</p>
|
||||||
|
<p className="text-xs text-slate-400">{t('superAdmin.totalUsers')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-800 rounded p-2">
|
||||||
|
<p className="text-xl font-bold text-white">{projects.length}</p>
|
||||||
|
<p className="text-xs text-slate-400">{t('superAdmin.totalProjects')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Top Bar */}
|
||||||
|
<header className="bg-white border-b border-gray-200 px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500">{t('superAdmin.systemManagement')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side utilities */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<LanguageSelector compact />
|
||||||
|
<div className="h-6 w-px bg-gray-300"></div>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 text-amber-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>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-gray-700">
|
||||||
|
{user?.full_name || user?.preferred_username || 'Super Admin'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-600 font-medium">{t('superAdmin.role')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title={tCommon('logout')}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page Content */}
|
||||||
|
<main className="flex-1 p-8">
|
||||||
|
{/* Users Tab */}
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">
|
||||||
|
{t('superAdmin.users.title')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateUserModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{t('superAdmin.users.createUser')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usersLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('superAdmin.users.username')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('superAdmin.users.role')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('superAdmin.users.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('superAdmin.users.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className={!u.is_enabled ? 'bg-red-50' : ''}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-gray-600">
|
||||||
|
{(u.username || u.sub).charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{u.username || u.sub}
|
||||||
|
{u.id === user?.db_user_id && (
|
||||||
|
<span className="ml-2 text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded">
|
||||||
|
{t('superAdmin.users.you')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{u.full_name && (
|
||||||
|
<div className="text-xs text-gray-500">{u.full_name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<select
|
||||||
|
value={u.role_id}
|
||||||
|
onChange={(e) => handleRoleChange(u.id, parseInt(e.target.value))}
|
||||||
|
disabled={u.id === user?.db_user_id}
|
||||||
|
className="text-sm border border-gray-300 rounded px-2 py-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.id} value={role.id}>
|
||||||
|
{role.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
||||||
|
u.is_enabled
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{u.is_enabled ? t('superAdmin.users.active') : t('superAdmin.users.blocked')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
{u.id !== user?.db_user_id && (
|
||||||
|
<button
|
||||||
|
onClick={() => u.is_enabled ? handleBlockUser(u.id) : handleUnblockUser(u.id)}
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
u.is_enabled
|
||||||
|
? 'text-red-600 hover:text-red-800'
|
||||||
|
: 'text-green-600 hover:text-green-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.is_enabled ? t('superAdmin.users.block') : t('superAdmin.users.unblock')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Projects Tab */}
|
||||||
|
{activeTab === 'projects' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">
|
||||||
|
{t('superAdmin.projects.title')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateProjectModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{t('superAdmin.projects.createProject')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projectsLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600"></div>
|
||||||
|
</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white rounded-lg shadow">
|
||||||
|
<svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-gray-500">{t('superAdmin.projects.noProjects')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div key={project.id} className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800">{project.project_name}</h3>
|
||||||
|
{project.project_desc && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||||
|
{project.project_desc}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs font-medium">
|
||||||
|
{project.member_count} {t('superAdmin.projects.members')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProject(project)
|
||||||
|
setActiveTab('assignments')
|
||||||
|
}}
|
||||||
|
className="text-sm text-amber-600 hover:text-amber-800 font-medium"
|
||||||
|
>
|
||||||
|
{t('superAdmin.projects.manageMembers')} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assignments Tab */}
|
||||||
|
{activeTab === 'assignments' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-6">
|
||||||
|
{t('superAdmin.assignments.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Project Selector */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('superAdmin.assignments.selectProject')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedProject?.id || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const projectId = parseInt(e.target.value)
|
||||||
|
setSelectedProject(projects.find(p => p.id === projectId) || null)
|
||||||
|
}}
|
||||||
|
className="w-full max-w-md border border-gray-300 rounded-lg px-4 py-2"
|
||||||
|
>
|
||||||
|
<option value="">{t('superAdmin.assignments.chooseProject')}</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.project_name} ({project.member_count} {t('superAdmin.projects.members')})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProject && (
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800">{selectedProject.project_name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{projectMembers.length} {t('superAdmin.projects.members')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAssignUserModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{t('superAdmin.assignments.addMember')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{membersLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600"></div>
|
||||||
|
</div>
|
||||||
|
) : projectMembers.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500">{t('superAdmin.assignments.noMembers')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('superAdmin.users.username')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('superAdmin.users.role')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('superAdmin.users.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{projectMembers.map((member) => (
|
||||||
|
<tr key={member.user_id}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{member.username || `User ${member.user_id}`}
|
||||||
|
</div>
|
||||||
|
{member.full_name && (
|
||||||
|
<div className="text-xs text-gray-500">{member.full_name}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{member.role_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveFromProject(member.user_id)}
|
||||||
|
className="text-sm font-medium text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
{t('superAdmin.assignments.remove')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
{showCreateUserModal && (
|
||||||
|
<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">{t('createUserModal.title')}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateUserModal(false)
|
||||||
|
resetCreateUserForm()
|
||||||
|
}}
|
||||||
|
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={handleCreateUser}>
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{createUserError && (
|
||||||
|
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||||
|
{createUserError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('createUserModal.username')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
|
placeholder={t('createUserModal.usernamePlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('createUserModal.email')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
placeholder={t('createUserModal.emailPlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('createUserModal.temporaryPassword')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder={t('createUserModal.passwordPlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{t('createUserModal.passwordHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('createUserModal.firstName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newFirstName}
|
||||||
|
onChange={(e) => setNewFirstName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('createUserModal.lastName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newLastName}
|
||||||
|
onChange={(e) => setNewLastName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('createUserModal.role')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newRoleId}
|
||||||
|
onChange={(e) => setNewRoleId(parseInt(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.id} value={role.id}>
|
||||||
|
{role.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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={() => {
|
||||||
|
setShowCreateUserModal(false)
|
||||||
|
resetCreateUserForm()
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||||
|
disabled={createUserLoading}
|
||||||
|
>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-amber-600 text-white rounded text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
disabled={createUserLoading}
|
||||||
|
>
|
||||||
|
{createUserLoading ? tCommon('loading') : t('createUserModal.createButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Project Modal */}
|
||||||
|
{showCreateProjectModal && (
|
||||||
|
<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">{t('superAdmin.projects.createProject')}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateProjectModal(false)
|
||||||
|
setNewProjectName('')
|
||||||
|
setNewProjectDesc('')
|
||||||
|
setCreateProjectError(null)
|
||||||
|
}}
|
||||||
|
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={handleCreateProject}>
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{createProjectError && (
|
||||||
|
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||||
|
{createProjectError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('superAdmin.projects.projectName')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newProjectName}
|
||||||
|
onChange={(e) => setNewProjectName(e.target.value)}
|
||||||
|
placeholder={t('superAdmin.projects.projectNamePlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('superAdmin.projects.description')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={newProjectDesc}
|
||||||
|
onChange={(e) => setNewProjectDesc(e.target.value)}
|
||||||
|
placeholder={t('superAdmin.projects.descriptionPlaceholder')}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm 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={() => {
|
||||||
|
setShowCreateProjectModal(false)
|
||||||
|
setNewProjectName('')
|
||||||
|
setNewProjectDesc('')
|
||||||
|
setCreateProjectError(null)
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||||
|
disabled={createProjectLoading}
|
||||||
|
>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-amber-600 text-white rounded text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
disabled={createProjectLoading}
|
||||||
|
>
|
||||||
|
{createProjectLoading ? tCommon('loading') : t('superAdmin.projects.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assign User Modal */}
|
||||||
|
{showAssignUserModal && selectedProject && (
|
||||||
|
<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">{t('superAdmin.assignments.addMember')}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowAssignUserModal(false)
|
||||||
|
setAssignUserId(null)
|
||||||
|
setAssignError(null)
|
||||||
|
}}
|
||||||
|
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={handleAssignUser}>
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{assignError && (
|
||||||
|
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||||
|
{assignError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('superAdmin.assignments.selectUser')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={assignUserId || ''}
|
||||||
|
onChange={(e) => setAssignUserId(parseInt(e.target.value) || null)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t('superAdmin.assignments.chooseUser')}</option>
|
||||||
|
{availableUsersForAssignment.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.username || u.sub} {u.full_name ? `(${u.full_name})` : ''} - {u.role_display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{availableUsersForAssignment.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{t('superAdmin.assignments.allUsersAssigned')}</p>
|
||||||
|
)}
|
||||||
|
</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={() => {
|
||||||
|
setShowAssignUserModal(false)
|
||||||
|
setAssignUserId(null)
|
||||||
|
setAssignError(null)
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||||
|
disabled={assignLoading}
|
||||||
|
>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-amber-600 text-white rounded text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
disabled={assignLoading || availableUsersForAssignment.length === 0}
|
||||||
|
>
|
||||||
|
{assignLoading ? tCommon('loading') : t('superAdmin.assignments.assign')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ 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'
|
export { default as AdminPage } from './AdminPage'
|
||||||
|
export { default as SuperAdminPage } from './SuperAdminPage'
|
||||||
|
|||||||
@@ -23,3 +23,11 @@ export type {
|
|||||||
export { userService } from './userService'
|
export { userService } from './userService'
|
||||||
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
|
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
|
||||||
export { commentService } from './commentService'
|
export { commentService } from './commentService'
|
||||||
|
export { superAdminService } from './superAdminService'
|
||||||
|
export type {
|
||||||
|
SystemUser,
|
||||||
|
SystemProject,
|
||||||
|
SystemProjectMember,
|
||||||
|
CreateUserRequest as SuperAdminCreateUserRequest,
|
||||||
|
CreateProjectRequest as SuperAdminCreateProjectRequest
|
||||||
|
} from './superAdminService'
|
||||||
|
|||||||
239
frontend/src/services/superAdminService.ts
Normal file
239
frontend/src/services/superAdminService.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Service for Super Admin API operations.
|
||||||
|
* These endpoints are restricted to users with role_id=6 (super_admin).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin/system'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface SystemUser {
|
||||||
|
id: number
|
||||||
|
sub: string
|
||||||
|
username: string | null
|
||||||
|
full_name: string | null
|
||||||
|
role_id: number
|
||||||
|
role_name: string
|
||||||
|
role_display_name: string
|
||||||
|
is_enabled: boolean
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemProject {
|
||||||
|
id: number
|
||||||
|
project_name: string
|
||||||
|
project_desc: string | null
|
||||||
|
member_count: number
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemProjectMember {
|
||||||
|
user_id: number
|
||||||
|
username: string | null
|
||||||
|
full_name: string | null
|
||||||
|
role_id: number
|
||||||
|
role_name: string
|
||||||
|
joined_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
role_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectRequest {
|
||||||
|
project_name: string
|
||||||
|
project_desc?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignUserRequest {
|
||||||
|
user_id: number
|
||||||
|
role_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleRequest {
|
||||||
|
role_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Functions
|
||||||
|
export const superAdminService = {
|
||||||
|
/**
|
||||||
|
* Get all users in the system
|
||||||
|
*/
|
||||||
|
async getAllUsers(): Promise<SystemUser[]> {
|
||||||
|
const response = await fetch(`${API_BASE}/users`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to fetch users' }))
|
||||||
|
throw new Error(error.detail || 'Failed to fetch users')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all projects in the system
|
||||||
|
*/
|
||||||
|
async getAllProjects(): Promise<SystemProject[]> {
|
||||||
|
const response = await fetch(`${API_BASE}/projects`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to fetch projects' }))
|
||||||
|
throw new Error(error.detail || 'Failed to fetch projects')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new project
|
||||||
|
*/
|
||||||
|
async createProject(data: CreateProjectRequest): Promise<SystemProject> {
|
||||||
|
const response = await fetch(`${API_BASE}/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to create project' }))
|
||||||
|
throw new Error(error.detail || 'Failed to create project')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get members of a project
|
||||||
|
*/
|
||||||
|
async getProjectMembers(projectId: number): Promise<SystemProjectMember[]> {
|
||||||
|
const response = await fetch(`${API_BASE}/projects/${projectId}/members`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to fetch project members' }))
|
||||||
|
throw new Error(error.detail || 'Failed to fetch project members')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a user to a project
|
||||||
|
*/
|
||||||
|
async assignUserToProject(projectId: number, data: AssignUserRequest): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/projects/${projectId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to assign user to project' }))
|
||||||
|
throw new Error(error.detail || 'Failed to assign user to project')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a user from a project
|
||||||
|
*/
|
||||||
|
async removeUserFromProject(projectId: number, userId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/projects/${projectId}/members/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to remove user from project' }))
|
||||||
|
throw new Error(error.detail || 'Failed to remove user from project')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block a user (disable in Keycloak)
|
||||||
|
*/
|
||||||
|
async blockUser(userId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/users/${userId}/block`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to block user' }))
|
||||||
|
throw new Error(error.detail || 'Failed to block user')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unblock a user (enable in Keycloak)
|
||||||
|
*/
|
||||||
|
async unblockUser(userId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/users/${userId}/unblock`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to unblock user' }))
|
||||||
|
throw new Error(error.detail || 'Failed to unblock user')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a user's role
|
||||||
|
*/
|
||||||
|
async updateUserRole(userId: number, data: UpdateRoleRequest): Promise<SystemUser> {
|
||||||
|
const response = await fetch(`${API_BASE}/users/${userId}/role`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to update user role' }))
|
||||||
|
throw new Error(error.detail || 'Failed to update user role')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user
|
||||||
|
*/
|
||||||
|
async createUser(data: CreateUserRequest): Promise<SystemUser> {
|
||||||
|
const response = await fetch(`${API_BASE}/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Failed to create user' }))
|
||||||
|
throw new Error(error.detail || 'Failed to create user')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default superAdminService
|
||||||
Reference in New Issue
Block a user