diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/exception/CatalogExceptionMessage.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/exception/CatalogExceptionMessage.java index 2cda8a23f9c..e1163ad04f4 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/exception/CatalogExceptionMessage.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/exception/CatalogExceptionMessage.java @@ -61,6 +61,10 @@ public final class CatalogExceptionMessage { return String.format("User %s is deactivated", id); } + public static String userAlreadyPartOfTeam(String userName, String teamName) { + return String.format("User '%s' is already part of the team '%s'", userName, teamName); + } + public static String invalidColumnFQN(String fqn) { return String.format("Invalid fully qualified column name %s", fqn); } diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/DashboardRepository.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/DashboardRepository.java index d154ca894a0..875bc3d1474 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/DashboardRepository.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/DashboardRepository.java @@ -87,8 +87,7 @@ public class DashboardRepository extends EntityRepository { .withId(original.getId()) .withFullyQualifiedName(original.getFullyQualifiedName()) .withName(original.getName()) - .withService(original.getService()) - .withId(original.getId()); + .withService(original.getService()); } private EntityReference getService(Dashboard dashboard) throws IOException { diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/UserRepository.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/UserRepository.java index 782ca1bae3c..744c3e016b1 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/UserRepository.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/UserRepository.java @@ -21,6 +21,7 @@ import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -29,6 +30,7 @@ import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.catalog.Entity; import org.openmetadata.catalog.entity.teams.Team; import org.openmetadata.catalog.entity.teams.User; +import org.openmetadata.catalog.exception.CatalogExceptionMessage; import org.openmetadata.catalog.resources.teams.UserResource; import org.openmetadata.catalog.type.ChangeDescription; import org.openmetadata.catalog.type.EntityReference; @@ -83,6 +85,12 @@ public class UserRepository extends EntityRepository { user.setRoles(rolesRef); } + @Override + public void restorePatchAttributes(User original, User updated) { + // Patch can't make changes to following fields. Ignore the changes + updated.withId(original.getId()).withName(original.getName()); + } + private List getTeamDefaultRoles(User user) throws IOException { List teamsRef = listOrEmpty(user.getTeams()); List defaultRoles = new ArrayList<>(); @@ -137,6 +145,22 @@ public class UserRepository extends EntityRepository { return user; } + public boolean isTeamJoinable(String teamId) throws IOException { + Team team = daoCollection.teamDAO().findEntityById(UUID.fromString(teamId), Include.NON_DELETED); + return team.getIsJoinable(); + } + + /* Validate if the user is already part of the given team */ + public void validateTeamAddition(String userId, String teamId) throws IOException { + User user = dao.findEntityById(UUID.fromString(userId)); + List teams = getTeams(user); + Optional team = teams.stream().filter(t -> t.getId().equals(UUID.fromString(teamId))).findFirst(); + if (team.isPresent()) { + throw new IllegalArgumentException( + CatalogExceptionMessage.userAlreadyPartOfTeam(user.getName(), team.get().getDisplayName())); + } + } + private List getOwns(User user) throws IOException { // Compile entities owned by the user List ownedEntities = @@ -287,6 +311,11 @@ public class UserRepository extends EntityRepository { entity.setName(name); } + @Override + public EntityReference getOwner() { + return Entity.getEntityReference(entity); + } + @Override public void setUpdateDetails(String updatedBy, long updatedAt) { entity.setUpdatedBy(updatedBy); diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/teams/UserResource.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/teams/UserResource.java index 6fab4bd7c4d..729fe349bf5 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/teams/UserResource.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/teams/UserResource.java @@ -372,6 +372,25 @@ public class UserResource extends EntityResource { if (path.equals("/isAdmin") || path.equals("/isBot")) { SecurityUtil.authorizeAdmin(authorizer, securityContext, ADMIN | BOT); } + // if path contains team, check if team is joinable by any user + if (patchOpObject.containsKey("op") + && patchOpObject.getString("op").equals("add") + && path.startsWith("/teams/")) { + JsonObject value = null; + try { + value = patchOpObject.getJsonObject("value"); + } catch (Exception ex) { + // ignore exception if value is not an object + } + if (value != null) { + String teamId = value.getString("id"); + dao.validateTeamAddition(id, teamId); + if (!dao.isTeamJoinable(teamId)) { + // Only admin can join closed teams + SecurityUtil.authorizeAdmin(authorizer, securityContext, ADMIN); + } + } + } } } return patchInternal(uriInfo, securityContext, id, patch); diff --git a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/EntityResourceTest.java b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/EntityResourceTest.java index 9c612884205..929286d5251 100644 --- a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/EntityResourceTest.java +++ b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/EntityResourceTest.java @@ -1015,7 +1015,10 @@ public abstract class EntityResourceTest extends CatalogApplicationTest { // Create entity without description, owner T entity = createEntity(createRequest(getEntityName(test), "", null, null), ADMIN_AUTH_HEADERS); EntityInterface entityInterface = getEntityInterface(entity); - assertListNull(entityInterface.getOwner()); + // user will always have the same user assigned as the owner + if (!entityInterface.getEntityType().equals(Entity.USER)) { + assertListNull(entityInterface.getOwner()); + } entity = getEntity(entityInterface.getId(), ADMIN_AUTH_HEADERS); entityInterface = getEntityInterface(entity); diff --git a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/teams/UserResourceTest.java b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/teams/UserResourceTest.java index 282eafbeba1..4f0ee503a6f 100644 --- a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/teams/UserResourceTest.java +++ b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/teams/UserResourceTest.java @@ -792,7 +792,10 @@ public class UserResourceTest extends EntityResourceTest { @Override public void validateCreatedEntity(User user, CreateUser createRequest, Map authHeaders) { validateCommonEntityFields( - getEntityInterface(user), createRequest.getDescription(), TestUtils.getPrincipal(authHeaders), null); + getEntityInterface(user), + createRequest.getDescription(), + TestUtils.getPrincipal(authHeaders), + Entity.getEntityReference(user)); assertEquals(createRequest.getName(), user.getName()); assertEquals(createRequest.getDisplayName(), user.getDisplayName()); @@ -825,7 +828,10 @@ public class UserResourceTest extends EntityResourceTest { @Override public void compareEntities(User expected, User updated, Map authHeaders) { validateCommonEntityFields( - getEntityInterface(expected), expected.getDescription(), TestUtils.getPrincipal(authHeaders), null); + getEntityInterface(expected), + expected.getDescription(), + TestUtils.getPrincipal(authHeaders), + Entity.getEntityReference(expected)); assertEquals(expected.getName(), expected.getName()); assertEquals(expected.getDisplayName(), expected.getDisplayName()); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TeamDetails/TeamDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/TeamDetails/TeamDetails.tsx index f7781fbd6c2..38656c1816e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/TeamDetails/TeamDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/TeamDetails/TeamDetails.tsx @@ -72,6 +72,7 @@ const TeamDetails = ({ handleTeamUsersSearchAction, teamUserPaginHandler, handleJoinTeamClick, + handleLeaveTeamClick, handleAddUser, removeUserFromTeam, }: TeamDetailsProp) => { @@ -164,12 +165,6 @@ const TeamDetails = ({ } }; - const handleRemoveUser = () => { - removeUserFromTeam(deletingUser.user?.id as string).then(() => { - setDeletingUser(DELETE_USER_INITIAL_STATE); - }); - }; - const isAlreadyJoinedTeam = (teamId: string) => { if (currentUser) { return currentUser.teams?.find((team) => team.id === teamId); @@ -196,6 +191,7 @@ const TeamDetails = ({ newTeams.push({ id: currentTeam.id, type: OwnerType.TEAM, + name: currentTeam.name, }); const updatedData: User = { @@ -209,6 +205,36 @@ const TeamDetails = ({ } }; + const leaveTeam = (): Promise => { + if (currentUser && currentTeam) { + let newTeams = cloneDeep(currentUser.teams ?? []); + newTeams = newTeams.filter((team) => team.id !== currentTeam.id); + + const updatedData: User = { + ...currentUser, + teams: newTeams, + }; + + const options = compare(currentUser, updatedData); + + return handleLeaveTeamClick(currentUser.id, options); + } + + return Promise.reject(); + }; + + const handleRemoveUser = () => { + if (deletingUser.leave) { + leaveTeam().then(() => { + setDeletingUser(DELETE_USER_INITIAL_STATE); + }); + } else { + removeUserFromTeam(deletingUser.user?.id as string).then(() => { + setDeletingUser(DELETE_USER_INITIAL_STATE); + }); + } + }; + const handleManageSave = (owner: TableDetail['owner']) => { if (currentTeam) { const updatedData: Team = { @@ -240,6 +266,7 @@ const TeamDetails = ({ /** * Take user id as input to find out the user data and set it for delete * @param id - user id + * @param leave - if "Leave Team" action is in progress */ const deleteUserHandler = (id: string, leave = false) => { const user = [...(currentTeam?.users as Array)].find( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TeamsAndUsers/TeamsAndUsers.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/TeamsAndUsers/TeamsAndUsers.component.tsx index 713a0be265b..384acc4550e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/TeamsAndUsers/TeamsAndUsers.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/TeamsAndUsers/TeamsAndUsers.component.tsx @@ -38,6 +38,7 @@ const TeamsAndUsers = ({ handleUserSearchTerm, handleDeleteUser, handleJoinTeamClick, + handleLeaveTeamClick, isRightPannelLoading, hasAccess, isTeamVisible, @@ -210,6 +211,7 @@ const TeamsAndUsers = ({ handleAddTeam={handleAddTeam} handleAddUser={handleAddUser} handleJoinTeamClick={handleJoinTeamClick} + handleLeaveTeamClick={handleLeaveTeamClick} handleTeamUsersSearchAction={handleTeamUsersSearchAction} hasAccess={hasAccess} isAddingTeam={isAddingTeam} diff --git a/openmetadata-ui/src/main/resources/ui/src/interface/teamsAndUsers.interface.ts b/openmetadata-ui/src/main/resources/ui/src/interface/teamsAndUsers.interface.ts index c5a673fd220..d12cc37633e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/interface/teamsAndUsers.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/interface/teamsAndUsers.interface.ts @@ -66,6 +66,7 @@ export interface TeamsAndUsersProps { descriptionHandler: (value: boolean) => void; onDescriptionUpdate: (value: string) => void; handleJoinTeamClick: (id: string, data: Operation[]) => void; + handleLeaveTeamClick: (id: string, data: Operation[]) => Promise; isAddingUsers: boolean; getUniqueUserList: () => Array; addUsersToTeam: (data: Array) => void; @@ -107,4 +108,5 @@ export interface TeamDetailsProp { handleAddUser: (data: boolean) => void; removeUserFromTeam: (id: string) => Promise; handleJoinTeamClick: (id: string, data: Operation[]) => void; + handleLeaveTeamClick: (id: string, data: Operation[]) => Promise; } diff --git a/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts b/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts index ec556b7a750..d680b7415b4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts +++ b/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts @@ -135,11 +135,13 @@ const jsonData = { 'feed-post-error': 'Error while posting the message!', 'join-team-error': 'Error while joining the team!', + 'leave-team-error': 'Error while leaving the team!', }, 'api-success-messages': { 'create-conversation': 'Conversation created successfully!', 'join-team-success': 'Team joined successfully!', + 'leave-team-success': 'Left the team successfully!', 'delete-test': 'Test deleted successfully!', 'delete-message': 'Message deleted successfully!', diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsAndUsersPage/TeamsAndUsersPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsAndUsersPage/TeamsAndUsersPage.component.tsx index e70db988143..cfb38f545d9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsAndUsersPage/TeamsAndUsersPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsAndUsersPage/TeamsAndUsersPage.component.tsx @@ -403,6 +403,31 @@ const TeamsAndUsersPage = () => { }); }; + const handleLeaveTeamClick = (id: string, data: Operation[]) => { + return new Promise((resolve, reject) => { + updateUserDetail(id, data) + .then((res: AxiosResponse) => { + if (res.data) { + AppState.updateUserDetails(res.data); + fetchCurrentTeam(teamAndUser); + showSuccessToast( + jsonData['api-success-messages']['leave-team-success'] + ); + resolve(); + } else { + throw jsonData['api-error-messages']['leave-team-error']; + } + }) + .catch((err: AxiosError) => { + showErrorToast( + err, + jsonData['api-error-messages']['leave-team-error'] + ); + reject(); + }); + }); + }; + /** * Handle current team route * @param name - team name @@ -620,6 +645,7 @@ const TeamsAndUsersPage = () => { handleAddUser={handleAddUser} handleDeleteUser={handleDeleteUser} handleJoinTeamClick={handleJoinTeamClick} + handleLeaveTeamClick={handleLeaveTeamClick} handleTeamUsersSearchAction={handleTeamUsersSearchAction} handleUserSearchTerm={handleUserSearchTerm} hasAccess={isAuthDisabled || isAdminUser}