diff --git a/src/Api/Controllers/OrganizationUsersController.cs b/src/Api/Controllers/OrganizationUsersController.cs index da642bf038..5734fe00fa 100644 --- a/src/Api/Controllers/OrganizationUsersController.cs +++ b/src/Api/Controllers/OrganizationUsersController.cs @@ -93,7 +93,7 @@ namespace Bit.Api.Controllers var userId = _userService.GetProperUserId(User); var result = await _organizationService.InviteUserAsync(orgGuidId, userId.Value, model.Email, model.Type.Value, - model.AccessAll, model.Collections?.Select(c => c.ToSelectionReadOnly())); + model.AccessAll, null, model.Collections?.Select(c => c.ToSelectionReadOnly())); } [HttpPut("{id}/reinvite")] diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 3cb8e6eee5..d47982360d 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -242,9 +242,9 @@ namespace Bit.Api.Controllers await _organizationService.ImportAsync( orgIdGuid, userId.Value, - model.Groups.Select(g => g.ToGroupTuple(orgIdGuid)), - model.Users.Where(u => !u.Disabled).Select(u => u.Email), - model.Users.Where(u => u.Disabled).Select(u => u.Email)); + model.Groups.Select(g => g.ToImportedGroup(orgIdGuid)), + model.Users.Where(u => !u.Disabled).Select(u => u.ToImportedOrganizationUser()), + model.Users.Where(u => u.Disabled).Select(u => u.ExternalId)); } } } diff --git a/src/Core/Models/Api/Request/Organizations/ImportOrganizationUsersRequestModel.cs b/src/Core/Models/Api/Request/Organizations/ImportOrganizationUsersRequestModel.cs index 6e892064d5..7f31900bc2 100644 --- a/src/Core/Models/Api/Request/Organizations/ImportOrganizationUsersRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/ImportOrganizationUsersRequestModel.cs @@ -1,4 +1,5 @@ -using Bit.Core.Models.Table; +using Bit.Core.Models.Business; +using Bit.Core.Models.Table; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -18,25 +19,49 @@ namespace Bit.Core.Models.Api public string ExternalId { get; set; } public IEnumerable Users { get; set; } - public Tuple> ToGroupTuple(Guid organizationId) + public ImportedGroup ToImportedGroup(Guid organizationId) { - var group = new Table.Group + var importedGroup = new ImportedGroup { - OrganizationId = organizationId, - Name = Name, - ExternalId = ExternalId + Group = new Table.Group + { + OrganizationId = organizationId, + Name = Name, + ExternalId = ExternalId + }, + ExternalUserIds = new HashSet(Users) }; - return new Tuple>(group, new HashSet(Users)); + return importedGroup; } } - public class User + public class User : IValidatableObject { - [Required] [EmailAddress] public string Email { get; set; } public bool Disabled { get; set; } + [Required] + public string ExternalId { get; set; } + + public ImportedOrganizationUser ToImportedOrganizationUser() + { + var importedUser = new ImportedOrganizationUser + { + Email = Email, + ExternalId = ExternalId + }; + + return importedUser; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if(string.IsNullOrWhiteSpace(Email) && !Disabled) + { + yield return new ValidationResult("Email is required for enabled users.", new string[] { nameof(Email) }); + } + } } } } diff --git a/src/Core/Models/Business/ImportedGroup.cs b/src/Core/Models/Business/ImportedGroup.cs new file mode 100644 index 0000000000..d6e9674781 --- /dev/null +++ b/src/Core/Models/Business/ImportedGroup.cs @@ -0,0 +1,11 @@ +using Bit.Core.Models.Table; +using System.Collections.Generic; + +namespace Bit.Core.Models.Business +{ + public class ImportedGroup + { + public Group Group { get; set; } + public HashSet ExternalUserIds { get; set; } + } +} diff --git a/src/Core/Models/Business/ImportedOrganizationUser.cs b/src/Core/Models/Business/ImportedOrganizationUser.cs new file mode 100644 index 0000000000..c57ce21230 --- /dev/null +++ b/src/Core/Models/Business/ImportedOrganizationUser.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Models.Business +{ + public class ImportedOrganizationUser + { + public string Email { get; set; } + public string ExternalId { get; set; } + } +} diff --git a/src/Core/Models/Data/OrganizationUserUserDetails.cs b/src/Core/Models/Data/OrganizationUserUserDetails.cs index 1f954c6f77..4811de4098 100644 --- a/src/Core/Models/Data/OrganizationUserUserDetails.cs +++ b/src/Core/Models/Data/OrganizationUserUserDetails.cs @@ -12,5 +12,6 @@ namespace Bit.Core.Models.Data public Enums.OrganizationUserStatusType Status { get; set; } public Enums.OrganizationUserType Type { get; set; } public bool AccessAll { get; set; } + public string ExternalId { get; set; } } } diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 6f459267e5..f3beac650f 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -22,14 +22,14 @@ namespace Bit.Core.Services Task EnableAsync(Guid organizationId); Task UpdateAsync(Organization organization, bool updateBilling = false); Task InviteUserAsync(Guid organizationId, Guid invitingUserId, string email, - OrganizationUserType type, bool accessAll, IEnumerable collections); + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections); Task ResendInviteAsync(Guid organizationId, Guid invitingUserId, Guid organizationUserId); Task AcceptUserAsync(Guid organizationUserId, User user, string token); Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); Task SaveUserAsync(OrganizationUser user, Guid savingUserId, IEnumerable collections); Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId); Task DeleteUserAsync(Guid organizationId, Guid userId); - Task ImportAsync(Guid organizationId, Guid importingUserId, IEnumerable>> groups, - IEnumerable newUsers, IEnumerable removeUsers); + Task ImportAsync(Guid organizationId, Guid importingUserId, IEnumerable groups, + IEnumerable newUsers, IEnumerable removeUserExternalIds); } } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 0d55f941d6..59c5b2f04c 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -684,7 +684,7 @@ namespace Bit.Core.Services } public async Task InviteUserAsync(Guid organizationId, Guid invitingUserId, string email, - OrganizationUserType type, bool accessAll, IEnumerable collections) + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections) { var organization = await _organizationRepository.GetByIdAsync(organizationId); if(organization == null) @@ -718,6 +718,7 @@ namespace Bit.Core.Services Type = type, Status = OrganizationUserStatusType.Invited, AccessAll = accessAll, + ExternalId = externalId, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow }; @@ -900,9 +901,9 @@ namespace Bit.Core.Services public async Task ImportAsync(Guid organizationId, Guid importingUserId, - IEnumerable>> groups, - IEnumerable newUsers, - IEnumerable removeUsers) + IEnumerable groups, + IEnumerable newUsers, + IEnumerable removeUserExternalIds) { var organization = await _organizationRepository.GetByIdAsync(organizationId); if(organization == null) @@ -915,16 +916,17 @@ namespace Bit.Core.Services throw new BadRequestException("Organization cannot use groups."); } - var newUsersSet = new HashSet(newUsers); - var existingUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - var existingUsersIdDict = existingUsers.ToDictionary(u => u.Email, u => u.Id); + var newUsersSet = new HashSet(newUsers.Select(u => u.ExternalId)); + var existingUsersOriginal = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var existingUsers = existingUsersOriginal.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + var existingUsersIdDict = existingUsers.ToDictionary(u => u.ExternalId, u => u.Id); // Users // Remove Users - if(removeUsers.Any()) + if(removeUserExternalIds.Any()) { - var removeUsersSet = new HashSet(removeUsers); - var existingUsersDict = existingUsers.ToDictionary(u => u.Email); + var removeUsersSet = new HashSet(removeUserExternalIds); + var existingUsersDict = existingUsers.ToDictionary(u => u.ExternalId); var usersToRemove = removeUsersSet .Except(newUsersSet) @@ -934,14 +936,14 @@ namespace Bit.Core.Services foreach(var user in usersToRemove) { await _organizationUserRepository.DeleteAsync(new OrganizationUser { Id = user.Id }); - existingUsersIdDict.Remove(user.Email); + existingUsersIdDict.Remove(user.ExternalId); } } // Add new users if(newUsers.Any()) { - var existingUsersSet = new HashSet(existingUsers.Select(u => u.Email)); + var existingUsersSet = new HashSet(existingUsers.Select(u => u.ExternalId)); var usersToAdd = newUsersSet.Except(existingUsersSet).ToList(); var seatsAvailable = int.MaxValue; @@ -956,38 +958,60 @@ namespace Bit.Core.Services } } - foreach(var user in usersToAdd) + foreach(var user in newUsers) { + if(!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) + { + continue; + } + try { - var newUser = await InviteUserAsync(organizationId, importingUserId, user, OrganizationUserType.User, - false, new List()); - existingUsersIdDict.Add(newUser.Email, newUser.Id); + var newUser = await InviteUserAsync(organizationId, importingUserId, user.Email, + OrganizationUserType.User, false, user.ExternalId, new List()); + existingUsersIdDict.Add(newUser.ExternalId, newUser.Id); } catch(BadRequestException) { continue; } } + + var existingUsersEmailsDict = existingUsersOriginal + .Where(u => string.IsNullOrWhiteSpace(u.ExternalId)) + .ToDictionary(u => u.Email); + var newUsersEmailsDict = newUsers.ToDictionary(u => u.Email); + var usersToAttach = existingUsersEmailsDict.Keys.Intersect(newUsersEmailsDict.Keys).ToList(); + foreach(var user in usersToAttach) + { + var orgUserDetails = existingUsersEmailsDict[user]; + var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserDetails.Id); + if(orgUser != null) + { + orgUser.ExternalId = newUsersEmailsDict[user].ExternalId; + await _organizationUserRepository.UpsertAsync(orgUser); + existingUsersIdDict.Add(orgUser.ExternalId, orgUser.Id); + } + } } // Groups if(groups?.Any() ?? false) { - var groupsDict = groups.ToDictionary(g => g.Item1.ExternalId); + var groupsDict = groups.ToDictionary(g => g.Group.ExternalId); var existingGroups = (await _groupRepository.GetManyByOrganizationIdAsync(organizationId)).ToList(); var existingGroupsDict = existingGroups.ToDictionary(g => g.ExternalId); var newGroups = groups - .Where(g => !existingGroupsDict.ContainsKey(g.Item1.ExternalId)) - .Select(g => g.Item1); + .Where(g => !existingGroupsDict.ContainsKey(g.Group.ExternalId)) + .Select(g => g.Group); foreach(var group in newGroups) { group.CreationDate = group.RevisionDate = DateTime.UtcNow; await _groupRepository.CreateAsync(group); - await UpdateUsersAsync(group, groupsDict[group.ExternalId].Item2, existingUsersIdDict); + await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds, existingUsersIdDict); } var updateGroups = existingGroups @@ -1003,7 +1027,7 @@ namespace Bit.Core.Services foreach(var group in updateGroups) { - var updatedGroup = groupsDict[group.ExternalId].Item1; + var updatedGroup = groupsDict[group.ExternalId].Group; if(group.Name != updatedGroup.Name) { group.RevisionDate = DateTime.UtcNow; @@ -1012,7 +1036,7 @@ namespace Bit.Core.Services await _groupRepository.ReplaceAsync(group); } - await UpdateUsersAsync(group, groupsDict[group.ExternalId].Item2, existingUsersIdDict, + await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds, existingUsersIdDict, existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); } } diff --git a/src/Sql/dbo/Stored Procedures/GroupUserDetails_ReadByGroupId.sql b/src/Sql/dbo/Stored Procedures/GroupUserDetails_ReadByGroupId.sql index 2e32c087c8..ead1eb26c5 100644 --- a/src/Sql/dbo/Stored Procedures/GroupUserDetails_ReadByGroupId.sql +++ b/src/Sql/dbo/Stored Procedures/GroupUserDetails_ReadByGroupId.sql @@ -15,7 +15,7 @@ BEGIN [dbo].[OrganizationUser] OU INNER JOIN [dbo].[GroupUser] GU ON GU.[OrganizationUserId] = OU.[Id] - INNER JOIN + LEFT JOIN [dbo].[User] U ON U.[Id] = OU.[UserId] WHERE GU.[GroupId] = @GroupId