Added user creation for the admin integrated with keycloak
This commit is contained in:
@@ -13,6 +13,8 @@ KEYCLOAK_EXTERNAL_URL=http://localhost:8081/
|
|||||||
KEYCLOAK_REALM=your-realm
|
KEYCLOAK_REALM=your-realm
|
||||||
KEYCLOAK_CLIENT_ID=your-client-id
|
KEYCLOAK_CLIENT_ID=your-client-id
|
||||||
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_ID=your-admin-client
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
# -------------------------------------------
|
# -------------------------------------------
|
||||||
# Frontend Configuration
|
# Frontend Configuration
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class Settings(BaseSettings):
|
|||||||
keycloak_client_id: str = Field(..., env="KEYCLOAK_CLIENT_ID")
|
keycloak_client_id: str = Field(..., env="KEYCLOAK_CLIENT_ID")
|
||||||
keycloak_client_secret: str = Field(..., env="KEYCLOAK_CLIENT_SECRET")
|
keycloak_client_secret: str = Field(..., env="KEYCLOAK_CLIENT_SECRET")
|
||||||
|
|
||||||
|
# Keycloak Admin API settings (for creating users directly)
|
||||||
|
keycloak_admin_client_id: str = Field(default="admin-cli", env="KEYCLOAK_ADMIN_CLIENT_ID")
|
||||||
|
keycloak_admin_client_secret: str = Field(default="", env="KEYCLOAK_ADMIN_CLIENT_SECRET")
|
||||||
|
|
||||||
# Frontend settings
|
# Frontend settings
|
||||||
frontend_url: str = Field(default="http://localhost:3000", env="FRONTEND_URL")
|
frontend_url: str = Field(default="http://localhost:3000", env="FRONTEND_URL")
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ from src.models import (
|
|||||||
RequirementLinkHistoryResponse, RequirementGroupHistoryResponse, CurrentRequirementGroupResponse,
|
RequirementLinkHistoryResponse, RequirementGroupHistoryResponse, CurrentRequirementGroupResponse,
|
||||||
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
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -25,8 +26,9 @@ from src.repositories import (
|
|||||||
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
||||||
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository,
|
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository,
|
||||||
RequirementStatusRepository
|
RequirementStatusRepository, UserRepository
|
||||||
)
|
)
|
||||||
|
from src.service import KeycloakAdminService
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -710,6 +712,83 @@ async def update_member_role(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/projects/{project_id}/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_project_user(
|
||||||
|
project_id: int,
|
||||||
|
request: Request,
|
||||||
|
user_data: UserCreateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new user directly from the admin panel and add them to the project.
|
||||||
|
Only project admins (role_id=3) can create users.
|
||||||
|
The user will be created in Keycloak with a temporary password that must be changed on first login.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to add the user to
|
||||||
|
user_data: The user data (username, email, password, first_name, last_name, role_id)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created user info.
|
||||||
|
"""
|
||||||
|
current_user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
# Only admins (role_id=3) can create users
|
||||||
|
_require_role(current_user, [3], "create users")
|
||||||
|
|
||||||
|
await _verify_project_membership(project_id, current_user.id, db)
|
||||||
|
|
||||||
|
# Validate role exists
|
||||||
|
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 from first_name and last_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 user 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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add user to the project
|
||||||
|
project_repo = ProjectRepository(db)
|
||||||
|
await project_repo.add_member(project_id, new_user.id)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Admin {current_user.id} created new user {new_user.id} ({user_data.username}) for project {project_id}")
|
||||||
|
|
||||||
|
return UserCreateResponse(
|
||||||
|
id=new_user.id,
|
||||||
|
username=user_data.username,
|
||||||
|
email=user_data.email,
|
||||||
|
full_name=full_name,
|
||||||
|
role_id=user_data.role_id,
|
||||||
|
role_name=role.role_name,
|
||||||
|
role_display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/projects/{project_id}/relationship-types/{type_id}", response_model=RelationshipTypeResponse)
|
@app.put("/api/projects/{project_id}/relationship-types/{type_id}", response_model=RelationshipTypeResponse)
|
||||||
async def update_relationship_type(
|
async def update_relationship_type(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|||||||
@@ -448,3 +448,28 @@ class CurrentRequirementGroupResponse(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# User Creation schemas (for admin to create users directly)
|
||||||
|
class UserCreateRequest(BaseModel):
|
||||||
|
"""Request schema for creating a user directly from admin panel."""
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
password: str # Temporary password - user must change on first login
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
role_id: int = 1 # Default to editor role
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateResponse(BaseModel):
|
||||||
|
"""Response schema for a created user."""
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
role_id: int
|
||||||
|
role_name: str
|
||||||
|
role_display_name: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from src.config import get_settings
|
|||||||
from src.models import UserInfo
|
from src.models import UserInfo
|
||||||
from src.repositories import UserRepository
|
from src.repositories import UserRepository
|
||||||
import logging
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -186,3 +187,144 @@ class UserService:
|
|||||||
logger.debug(f"Existing user logged in: {sub} -> user_id: {user.id}, username: {username}")
|
logger.debug(f"Existing user logged in: {sub} -> user_id: {user.id}, username: {username}")
|
||||||
|
|
||||||
return user.id, created
|
return user.id, created
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakAdminService:
|
||||||
|
"""Service for Keycloak Admin API operations (creating users directly)."""
|
||||||
|
|
||||||
|
_access_token: str | None = None
|
||||||
|
_token_expires_at: float = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _get_admin_token(cls) -> str:
|
||||||
|
"""
|
||||||
|
Get an admin access token using client credentials grant.
|
||||||
|
Caches the token until it expires.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Check if we have a valid cached token
|
||||||
|
if cls._access_token and time.time() < cls._token_expires_at - 30:
|
||||||
|
return cls._access_token
|
||||||
|
|
||||||
|
# Get new token using client credentials
|
||||||
|
token_url = f"{settings.keycloak_server_url}realms/{settings.keycloak_realm}/protocol/openid-connect/token"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
response = await client.post(
|
||||||
|
token_url,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": settings.keycloak_admin_client_id,
|
||||||
|
"client_secret": settings.keycloak_admin_client_secret,
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Failed to get admin token: {response.status_code} - {response.text}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to authenticate with Keycloak Admin API"
|
||||||
|
)
|
||||||
|
|
||||||
|
token_data = response.json()
|
||||||
|
cls._access_token = token_data["access_token"]
|
||||||
|
cls._token_expires_at = time.time() + token_data.get("expires_in", 300)
|
||||||
|
|
||||||
|
return cls._access_token
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create_user(
|
||||||
|
cls,
|
||||||
|
username: str,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
first_name: str | None = None,
|
||||||
|
last_name: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a new user in Keycloak.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: The username for the new user
|
||||||
|
email: The email address
|
||||||
|
password: The temporary password (user must change on first login)
|
||||||
|
first_name: Optional first name
|
||||||
|
last_name: Optional last name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Keycloak user ID (sub)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If user creation fails
|
||||||
|
"""
|
||||||
|
token = await cls._get_admin_token()
|
||||||
|
|
||||||
|
# Create user payload
|
||||||
|
user_payload = {
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"emailVerified": True, # Skip email verification since no email is configured
|
||||||
|
"enabled": True,
|
||||||
|
"firstName": first_name or "",
|
||||||
|
"lastName": last_name or "",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": password,
|
||||||
|
"temporary": True # Force password change on first login
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
users_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
# Create the user
|
||||||
|
response = await client.post(
|
||||||
|
users_url,
|
||||||
|
json=user_payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 409:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="A user with this username or email already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 201:
|
||||||
|
logger.error(f"Failed to create user in Keycloak: {response.status_code} - {response.text}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create user in Keycloak: {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the user ID from the Location header
|
||||||
|
location = response.headers.get("Location", "")
|
||||||
|
keycloak_user_id = location.split("/")[-1] if location else None
|
||||||
|
|
||||||
|
if not keycloak_user_id:
|
||||||
|
# Fetch user by username to get the ID
|
||||||
|
search_response = await client.get(
|
||||||
|
f"{users_url}?username={username}&exact=true",
|
||||||
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if search_response.status_code == 200:
|
||||||
|
users = search_response.json()
|
||||||
|
if users:
|
||||||
|
keycloak_user_id = users[0]["id"]
|
||||||
|
|
||||||
|
if not keycloak_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="User created but could not retrieve user ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Created user in Keycloak: {username} -> {keycloak_user_id}")
|
||||||
|
return keycloak_user_id
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"title": "Member Roles",
|
"title": "Member Roles",
|
||||||
"loadingMembers": "Loading members...",
|
"loadingMembers": "Loading members...",
|
||||||
"noMembers": "No members found",
|
"noMembers": "No members found",
|
||||||
|
"addUserButton": "+ Add User",
|
||||||
"tableHeaders": {
|
"tableHeaders": {
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
@@ -33,6 +34,22 @@
|
|||||||
"cannotDemoteSelf": "You cannot demote yourself. Ask another admin to change your role.",
|
"cannotDemoteSelf": "You cannot demote yourself. Ask another admin to change your role.",
|
||||||
"errorUpdating": "Failed to update member role"
|
"errorUpdating": "Failed to update member role"
|
||||||
},
|
},
|
||||||
|
"createUserModal": {
|
||||||
|
"title": "Create New User",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "Enter username",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "Enter email address",
|
||||||
|
"temporaryPassword": "Temporary Password",
|
||||||
|
"passwordPlaceholder": "Enter temporary password",
|
||||||
|
"passwordHint": "User will be required to change this password on first login.",
|
||||||
|
"firstName": "First Name",
|
||||||
|
"lastName": "Last Name",
|
||||||
|
"role": "Role",
|
||||||
|
"createButton": "Create User",
|
||||||
|
"successMessage": "User created successfully!",
|
||||||
|
"errorCreating": "Failed to create user"
|
||||||
|
},
|
||||||
"relationshipTypes": {
|
"relationshipTypes": {
|
||||||
"title": "Relationship Types",
|
"title": "Relationship Types",
|
||||||
"addButton": "+ Add Type",
|
"addButton": "+ Add Type",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"title": "Funções dos Membros",
|
"title": "Funções dos Membros",
|
||||||
"loadingMembers": "Carregando membros...",
|
"loadingMembers": "Carregando membros...",
|
||||||
"noMembers": "Nenhum membro encontrado",
|
"noMembers": "Nenhum membro encontrado",
|
||||||
|
"addUserButton": "+ Adicionar Usuário",
|
||||||
"tableHeaders": {
|
"tableHeaders": {
|
||||||
"user": "Usuário",
|
"user": "Usuário",
|
||||||
"role": "Função",
|
"role": "Função",
|
||||||
@@ -33,6 +34,22 @@
|
|||||||
"cannotDemoteSelf": "Você não pode rebaixar a si mesmo. Peça a outro administrador para alterar sua função.",
|
"cannotDemoteSelf": "Você não pode rebaixar a si mesmo. Peça a outro administrador para alterar sua função.",
|
||||||
"errorUpdating": "Falha ao atualizar função do membro"
|
"errorUpdating": "Falha ao atualizar função do membro"
|
||||||
},
|
},
|
||||||
|
"createUserModal": {
|
||||||
|
"title": "Criar Novo Usuário",
|
||||||
|
"username": "Nome de Usuário",
|
||||||
|
"usernamePlaceholder": "Digite o nome de usuário",
|
||||||
|
"email": "E-mail",
|
||||||
|
"emailPlaceholder": "Digite o endereço de e-mail",
|
||||||
|
"temporaryPassword": "Senha Temporária",
|
||||||
|
"passwordPlaceholder": "Digite a senha temporária",
|
||||||
|
"passwordHint": "O usuário será obrigado a alterar esta senha no primeiro login.",
|
||||||
|
"firstName": "Nome",
|
||||||
|
"lastName": "Sobrenome",
|
||||||
|
"role": "Função",
|
||||||
|
"createButton": "Criar Usuário",
|
||||||
|
"successMessage": "Usuário criado com sucesso!",
|
||||||
|
"errorCreating": "Falha ao criar usuário"
|
||||||
|
},
|
||||||
"relationshipTypes": {
|
"relationshipTypes": {
|
||||||
"title": "Tipos de Relacionamento",
|
"title": "Tipos de Relacionamento",
|
||||||
"addButton": "+ Adicionar Tipo",
|
"addButton": "+ Adicionar Tipo",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default function AdminPage() {
|
|||||||
const [showEditRelTypeModal, setShowEditRelTypeModal] = useState(false)
|
const [showEditRelTypeModal, setShowEditRelTypeModal] = useState(false)
|
||||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
|
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
|
||||||
const [selectedRelType, setSelectedRelType] = useState<RelationshipType | null>(null)
|
const [selectedRelType, setSelectedRelType] = useState<RelationshipType | null>(null)
|
||||||
|
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
|
||||||
|
|
||||||
// Relationship type form state
|
// Relationship type form state
|
||||||
const [relTypeName, setRelTypeName] = useState('')
|
const [relTypeName, setRelTypeName] = useState('')
|
||||||
@@ -55,6 +56,17 @@ export default function AdminPage() {
|
|||||||
const [relTypeFormLoading, setRelTypeFormLoading] = useState(false)
|
const [relTypeFormLoading, setRelTypeFormLoading] = useState(false)
|
||||||
const [relTypeFormError, setRelTypeFormError] = useState<string | null>(null)
|
const [relTypeFormError, setRelTypeFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Create user form state
|
||||||
|
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)
|
||||||
|
const [createUserSuccess, setCreateUserSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
// Check if user is admin
|
// Check if user is admin
|
||||||
const isAdmin = user?.role_id === 3
|
const isAdmin = user?.role_id === 3
|
||||||
|
|
||||||
@@ -286,6 +298,56 @@ export default function AdminPage() {
|
|||||||
setShowDeleteConfirmModal(true)
|
setShowDeleteConfirmModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset create user form
|
||||||
|
const resetCreateUserForm = () => {
|
||||||
|
setNewUsername('')
|
||||||
|
setNewEmail('')
|
||||||
|
setNewPassword('')
|
||||||
|
setNewFirstName('')
|
||||||
|
setNewLastName('')
|
||||||
|
setNewRoleId(1)
|
||||||
|
setCreateUserError(null)
|
||||||
|
setCreateUserSuccess(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle create user
|
||||||
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!currentProject) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreateUserLoading(true)
|
||||||
|
setCreateUserError(null)
|
||||||
|
setCreateUserSuccess(null)
|
||||||
|
|
||||||
|
await userService.createUser(currentProject.id, {
|
||||||
|
username: newUsername.trim(),
|
||||||
|
email: newEmail.trim(),
|
||||||
|
password: newPassword,
|
||||||
|
first_name: newFirstName.trim() || undefined,
|
||||||
|
last_name: newLastName.trim() || undefined,
|
||||||
|
role_id: newRoleId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh members list
|
||||||
|
const fetchedMembers = await userService.getProjectMembers(currentProject.id)
|
||||||
|
setMembers(fetchedMembers)
|
||||||
|
|
||||||
|
setCreateUserSuccess(t('createUserModal.successMessage'))
|
||||||
|
|
||||||
|
// Close modal after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowCreateUserModal(false)
|
||||||
|
resetCreateUserForm()
|
||||||
|
}, 1500)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to create user:', err)
|
||||||
|
setCreateUserError(err.message || t('createUserModal.errorCreating'))
|
||||||
|
} finally {
|
||||||
|
setCreateUserLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -429,7 +491,18 @@ export default function AdminPage() {
|
|||||||
{/* Members Tab */}
|
{/* Members Tab */}
|
||||||
{activeTab === 'members' && (
|
{activeTab === 'members' && (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-6">{t('memberRoles.title')}</h2>
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">{t('memberRoles.title')}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
resetCreateUserForm()
|
||||||
|
setShowCreateUserModal(true)
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
{t('memberRoles.addUserButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{membersError && (
|
{membersError && (
|
||||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||||
@@ -812,6 +885,153 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createUserSuccess && (
|
||||||
|
<div className="p-3 bg-green-100 border border-green-400 text-green-700 rounded text-sm">
|
||||||
|
{createUserSuccess}
|
||||||
|
</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 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{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 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{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 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<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 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
</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 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
</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 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
|
>
|
||||||
|
{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-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
|
||||||
|
disabled={createUserLoading}
|
||||||
|
>
|
||||||
|
{createUserLoading ? tCommon('creating') : t('createUserModal.createButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ export interface UserRoleUpdateRequest {
|
|||||||
role_id: number
|
role_id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserCreateRequest {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
role_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCreateResponse {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string | null
|
||||||
|
role_id: number
|
||||||
|
role_name: string
|
||||||
|
role_display_name: string
|
||||||
|
}
|
||||||
|
|
||||||
class UserService {
|
class UserService {
|
||||||
/**
|
/**
|
||||||
* Get all available roles.
|
* Get all available roles.
|
||||||
@@ -86,6 +105,34 @@ class UserService {
|
|||||||
|
|
||||||
return await response.json()
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user and add them to a project.
|
||||||
|
* Only admins can call this endpoint.
|
||||||
|
*/
|
||||||
|
async createUser(
|
||||||
|
projectId: number,
|
||||||
|
userData: UserCreateRequest
|
||||||
|
): Promise<UserCreateResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/projects/${projectId}/users`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(userData),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userService = new UserService()
|
export const userService = new UserService()
|
||||||
|
|||||||
Reference in New Issue
Block a user