From 18f44c0e85d792d7d658de543b084d04c5723b8c Mon Sep 17 00:00:00 2001 From: gulimabr Date: Thu, 4 Dec 2025 16:36:45 -0300 Subject: [PATCH] Added user creation for the admin integrated with keycloak --- .env.example | 2 + backend/src/config.py | 4 + backend/src/main.py | 83 ++++++++- backend/src/models.py | 25 +++ backend/src/service.py | 142 +++++++++++++++ frontend/src/i18n/locales/en/admin.json | 17 ++ frontend/src/i18n/locales/pt/admin.json | 17 ++ frontend/src/pages/AdminPage.tsx | 222 +++++++++++++++++++++++- frontend/src/services/userService.ts | 47 +++++ 9 files changed, 556 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 538b0b6..e55c0fb 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ KEYCLOAK_EXTERNAL_URL=http://localhost:8081/ KEYCLOAK_REALM=your-realm KEYCLOAK_CLIENT_ID=your-client-id KEYCLOAK_CLIENT_SECRET=your-client-secret +KEYCLOAK_ADMIN_CLIENT_ID=your-admin-client +KEYCLOAK_ADMIN_CLIENT_SECRET=your-client-secret # ------------------------------------------- # Frontend Configuration diff --git a/backend/src/config.py b/backend/src/config.py index 51e960d..b2761fe 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -10,6 +10,10 @@ class Settings(BaseSettings): keycloak_realm: str = Field(..., env="KEYCLOAK_REALM") keycloak_client_id: str = Field(..., env="KEYCLOAK_CLIENT_ID") 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_url: str = Field(default="http://localhost:3000", env="FRONTEND_URL") diff --git a/backend/src/main.py b/backend/src/main.py index 670fbd8..78de6ce 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -16,7 +16,8 @@ from src.models import ( RequirementLinkHistoryResponse, RequirementGroupHistoryResponse, CurrentRequirementGroupResponse, RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES, CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest, - RequirementStatusResponse, DeletedRequirementResponse + RequirementStatusResponse, DeletedRequirementResponse, + UserCreateRequest, UserCreateResponse ) from src.controller import AuthController from src.config import get_openid, get_settings @@ -25,8 +26,9 @@ from src.repositories import ( RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository, RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository, - RequirementStatusRepository + RequirementStatusRepository, UserRepository ) +from src.service import KeycloakAdminService import 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) async def update_relationship_type( project_id: int, diff --git a/backend/src/models.py b/backend/src/models.py index e634539..e996b3b 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -448,3 +448,28 @@ class CurrentRequirementGroupResponse(BaseModel): class Config: 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 diff --git a/backend/src/service.py b/backend/src/service.py index 8710e4f..4a14482 100644 --- a/backend/src/service.py +++ b/backend/src/service.py @@ -6,6 +6,7 @@ from src.config import get_settings from src.models import UserInfo from src.repositories import UserRepository import logging +import httpx logger = logging.getLogger(__name__) settings = get_settings() @@ -186,3 +187,144 @@ class UserService: logger.debug(f"Existing user logged in: {sub} -> user_id: {user.id}, username: {username}") 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 diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index b3e128e..f459d5a 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -24,6 +24,7 @@ "title": "Member Roles", "loadingMembers": "Loading members...", "noMembers": "No members found", + "addUserButton": "+ Add User", "tableHeaders": { "user": "User", "role": "Role", @@ -33,6 +34,22 @@ "cannotDemoteSelf": "You cannot demote yourself. Ask another admin to change your 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": { "title": "Relationship Types", "addButton": "+ Add Type", diff --git a/frontend/src/i18n/locales/pt/admin.json b/frontend/src/i18n/locales/pt/admin.json index eb1d3f0..17815af 100644 --- a/frontend/src/i18n/locales/pt/admin.json +++ b/frontend/src/i18n/locales/pt/admin.json @@ -24,6 +24,7 @@ "title": "Funções dos Membros", "loadingMembers": "Carregando membros...", "noMembers": "Nenhum membro encontrado", + "addUserButton": "+ Adicionar Usuário", "tableHeaders": { "user": "Usuário", "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.", "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": { "title": "Tipos de Relacionamento", "addButton": "+ Adicionar Tipo", diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 96c9a00..20d4200 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -47,6 +47,7 @@ export default function AdminPage() { const [showEditRelTypeModal, setShowEditRelTypeModal] = useState(false) const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false) const [selectedRelType, setSelectedRelType] = useState(null) + const [showCreateUserModal, setShowCreateUserModal] = useState(false) // Relationship type form state const [relTypeName, setRelTypeName] = useState('') @@ -55,6 +56,17 @@ export default function AdminPage() { const [relTypeFormLoading, setRelTypeFormLoading] = useState(false) const [relTypeFormError, setRelTypeFormError] = useState(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(null) + const [createUserSuccess, setCreateUserSuccess] = useState(null) + // Check if user is admin const isAdmin = user?.role_id === 3 @@ -286,6 +298,56 @@ export default function AdminPage() { 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) { return null } @@ -429,7 +491,18 @@ export default function AdminPage() { {/* Members Tab */} {activeTab === 'members' && (
-

{t('memberRoles.title')}

+
+

{t('memberRoles.title')}

+ +
{membersError && (
@@ -812,6 +885,153 @@ export default function AdminPage() {
)} + + {/* Create User Modal */} + {showCreateUserModal && ( +
+
+
+

{t('createUserModal.title')}

+ +
+ +
+
+ {createUserError && ( +
+ {createUserError} +
+ )} + + {createUserSuccess && ( +
+ {createUserSuccess} +
+ )} + +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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} + /> +

+ {t('createUserModal.passwordHint')} +

+
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + +
+
+ +
+ + +
+
+
+
+ )} ) } diff --git a/frontend/src/services/userService.ts b/frontend/src/services/userService.ts index b42e6e0..b13b0e7 100644 --- a/frontend/src/services/userService.ts +++ b/frontend/src/services/userService.ts @@ -20,6 +20,25 @@ export interface UserRoleUpdateRequest { 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 { /** * Get all available roles. @@ -86,6 +105,34 @@ class UserService { 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 { + 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()