diff --git a/src/Api/Controllers/OrganizationUsersController.cs b/src/Api/Controllers/OrganizationUsersController.cs new file mode 100644 index 0000000000..06cd544e08 --- /dev/null +++ b/src/Api/Controllers/OrganizationUsersController.cs @@ -0,0 +1,90 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Bit.Core.Repositories; +using Microsoft.AspNetCore.Authorization; +using Bit.Api.Models; +using Bit.Core.Exceptions; +using Bit.Core.Services; + +namespace Bit.Api.Controllers +{ + [Route("organizations/{orgId}/users")] + [Authorize("Application")] + public class OrganizationUsersController : Controller + { + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationService _organizationService; + private readonly IUserService _userService; + + public OrganizationUsersController( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + IUserService userService) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _organizationService = organizationService; + _userService = userService; + } + + [HttpGet("{id}")] + public async Task Get(string orgId, string id) + { + var organizationUser = await _organizationUserRepository.GetDetailsByIdAsync(new Guid(id)); + if(organizationUser == null) + { + throw new NotFoundException(); + } + + return new OrganizationUserResponseModel(organizationUser); + } + + [HttpGet("")] + public async Task> Get(string orgId) + { + var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationsAsync(new Guid(orgId)); + var responses = organizationUsers.Select(o => new OrganizationUserResponseModel(o)); + return new ListResponseModel(responses); + } + + [HttpPost("invite")] + public async Task Invite(string orgId, [FromBody]OrganizationUserInviteRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + var result = await _organizationService.InviteUserAsync(new Guid(orgId), model.Email); + } + + [HttpPut("accept")] + [HttpPost("{id}/accept")] + public async Task Accept(string orgId, string id, [FromBody]OrganizationUserAcceptRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + var result = await _organizationService.AcceptUserAsync(new Guid(id), user, model.Token); + } + + [HttpPost("confirm")] + [HttpPost("{id}/confirm")] + public async Task Confirm(string orgId, string id, [FromBody]OrganizationUserConfirmRequestModel model) + { + var result = await _organizationService.ConfirmUserAsync(new Guid(id), model.Key); + } + + [HttpDelete("{id}")] + [HttpPost("{id}/delete")] + public async Task Delete(string orgId, string id) + { + var organization = await _organizationRepository.GetByIdAsync(new Guid(id), + _userService.GetProperUserId(User).Value); + if(organization == null) + { + throw new NotFoundException(); + } + + await _organizationRepository.DeleteAsync(organization); + } + } +} diff --git a/src/Api/Controllers/UsersController.cs b/src/Api/Controllers/UsersController.cs new file mode 100644 index 0000000000..27c13140fc --- /dev/null +++ b/src/Api/Controllers/UsersController.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Bit.Core.Repositories; +using Microsoft.AspNetCore.Authorization; +using Bit.Core.Exceptions; +using Bit.Api.Models; + +namespace Bit.Api.Controllers +{ + [Route("users")] + [Authorize("Application")] + public class UsersController : Controller + { + private readonly IUserRepository _userRepository; + + public UsersController( + IUserRepository userRepository) + { + _userRepository = userRepository; + } + + [HttpGet("{id}/public-key")] + public async Task Get(string id) + { + var guidId = new Guid(id); + var key = await _userRepository.GetPublicKeyAsync(guidId); + if(key == null) + { + throw new NotFoundException(); + } + + return new UserKeyResponseModel(guidId, key); + } + } +} diff --git a/src/Api/Models/Request/Organizations/OrganizationUserCreateRequestModels.cs b/src/Api/Models/Request/Organizations/OrganizationUserCreateRequestModels.cs new file mode 100644 index 0000000000..1f6d2fcede --- /dev/null +++ b/src/Api/Models/Request/Organizations/OrganizationUserCreateRequestModels.cs @@ -0,0 +1,19 @@ +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class OrganizationUserInviteRequestModel + { + public string Email { get; set; } + } + + public class OrganizationUserAcceptRequestModel + { + public string Token { get; set; } + } + + public class OrganizationUserConfirmRequestModel + { + public string Key { get; set; } + } +} diff --git a/src/Api/Models/Response/OrganizationUserResponseModel.cs b/src/Api/Models/Response/OrganizationUserResponseModel.cs new file mode 100644 index 0000000000..ae6e326de0 --- /dev/null +++ b/src/Api/Models/Response/OrganizationUserResponseModel.cs @@ -0,0 +1,32 @@ +using System; +using Bit.Core.Enums; +using Bit.Core.Models.Data; + +namespace Bit.Api.Models +{ + public class OrganizationUserResponseModel : ResponseModel + { + public OrganizationUserResponseModel(OrganizationUserDetails organizationUser, string obj = "organizationUser") + : base(obj) + { + if(organizationUser == null) + { + throw new ArgumentNullException(nameof(organizationUser)); + } + + Id = organizationUser.Id.ToString(); + UserId = organizationUser.UserId?.ToString(); + Name = organizationUser.Name; + Email = organizationUser.Email; + Type = organizationUser.Type; + Status = organizationUser.Status; + } + + public string Id { get; set; } + public string UserId { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public OrganizationUserType Type { get; set; } + public OrganizationUserStatusType Status { get; set; } + } +} diff --git a/src/Api/Models/Response/UserKeyResponseModel.cs b/src/Api/Models/Response/UserKeyResponseModel.cs new file mode 100644 index 0000000000..07d26e9561 --- /dev/null +++ b/src/Api/Models/Response/UserKeyResponseModel.cs @@ -0,0 +1,19 @@ +using System; +using Bit.Core.Domains; +using Bit.Core.Enums; + +namespace Bit.Api.Models +{ + public class UserKeyResponseModel : ResponseModel + { + public UserKeyResponseModel(Guid id, string key) + : base("userKey") + { + UserId = id.ToString(); + PublicKey = key; + } + + public string UserId { get; set; } + public string PublicKey { get; set; } + } +} diff --git a/src/Core/Domains/OrganizationUser.cs b/src/Core/Domains/OrganizationUser.cs index 7bad83549d..0aa97fab12 100644 --- a/src/Core/Domains/OrganizationUser.cs +++ b/src/Core/Domains/OrganizationUser.cs @@ -8,7 +8,7 @@ namespace Bit.Core.Domains { public Guid Id { get; set; } public Guid OrganizationId { get; set; } - public Guid UserId { get; set; } + public Guid? UserId { get; set; } public string Email { get; set; } public string Key { get; set; } public OrganizationUserStatusType Status { get; set; } diff --git a/src/Core/Enums/OrganizationUserStatusType.cs b/src/Core/Enums/OrganizationUserStatusType.cs index 18c8e11af8..7d2246dc81 100644 --- a/src/Core/Enums/OrganizationUserStatusType.cs +++ b/src/Core/Enums/OrganizationUserStatusType.cs @@ -3,8 +3,7 @@ public enum OrganizationUserStatusType : byte { Invited = 0, - Pending = 1, - Accepted = 2, - Confirmed = 3 + Accepted = 1, + Confirmed = 2 } } diff --git a/src/Core/Enums/OrganizationUserType.cs b/src/Core/Enums/OrganizationUserType.cs index 91128959ad..92b1f91855 100644 --- a/src/Core/Enums/OrganizationUserType.cs +++ b/src/Core/Enums/OrganizationUserType.cs @@ -4,6 +4,6 @@ { Owner = 0, Admin = 1, - Regular = 2 + User = 2 } } diff --git a/src/Core/Models/Data/OrganizationUserDetails.cs b/src/Core/Models/Data/OrganizationUserDetails.cs new file mode 100644 index 0000000000..478fc34b91 --- /dev/null +++ b/src/Core/Models/Data/OrganizationUserDetails.cs @@ -0,0 +1,14 @@ +using System; + +namespace Bit.Core.Models.Data +{ + public class OrganizationUserDetails + { + public Guid Id { get; set; } + public Guid? UserId { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public Enums.OrganizationUserStatusType Status { get; set; } + public Enums.OrganizationUserType Type { get; set; } + } +} diff --git a/src/Core/Repositories/IOrganizationUserRepository.cs b/src/Core/Repositories/IOrganizationUserRepository.cs index 2f47d316a3..d152f6c480 100644 --- a/src/Core/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/Repositories/IOrganizationUserRepository.cs @@ -2,11 +2,14 @@ using System.Collections.Generic; using System.Threading.Tasks; using Bit.Core.Domains; +using Bit.Core.Models.Data; namespace Bit.Core.Repositories { public interface IOrganizationUserRepository : IRepository { Task GetByOrganizationAsync(Guid organizationId, Guid userId); + Task GetDetailsByIdAsync(Guid id); + Task> GetManyDetailsByOrganizationsAsync(Guid organizationId); } } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 7fd582ffe9..f5a1df875b 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -7,6 +7,7 @@ namespace Bit.Core.Repositories public interface IUserRepository : IRepository { Task GetByEmailAsync(string email); + Task GetPublicKeyAsync(Guid id); Task GetAccountRevisionDateAsync(Guid id); } } diff --git a/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs b/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs index 3bf78cde27..a882472489 100644 --- a/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs +++ b/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs @@ -5,6 +5,8 @@ using System.Data.SqlClient; using System.Threading.Tasks; using Dapper; using System.Linq; +using Bit.Core.Models.Data; +using System.Collections.Generic; namespace Bit.Core.Repositories.SqlServer { @@ -30,5 +32,31 @@ namespace Bit.Core.Repositories.SqlServer return results.SingleOrDefault(); } } + + public async Task GetDetailsByIdAsync(Guid id) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationUserDetails_ReadById]", + new { Id = id }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } + + public async Task> GetManyDetailsByOrganizationsAsync(Guid organizationId) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationUserDetails_ReadByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } } diff --git a/src/Core/Repositories/SqlServer/UserRepository.cs b/src/Core/Repositories/SqlServer/UserRepository.cs index 30c53eb5a1..5dd122c507 100644 --- a/src/Core/Repositories/SqlServer/UserRepository.cs +++ b/src/Core/Repositories/SqlServer/UserRepository.cs @@ -36,6 +36,19 @@ namespace Bit.Core.Repositories.SqlServer } } + public async Task GetPublicKeyAsync(Guid id) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadPublicKeyById]", + new { Id = id }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } + public async Task GetAccountRevisionDateAsync(Guid id) { using(var connection = new SqlConnection(ConnectionString)) diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 24b6ea6fc2..5d370f12b1 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -8,5 +8,8 @@ namespace Bit.Core.Services public interface IOrganizationService { Task> SignUpAsync(OrganizationSignup organizationSignup); + Task InviteUserAsync(Guid organizationId, string email); + Task AcceptUserAsync(Guid organizationUserId, User user, string token); + Task ConfirmUserAsync(Guid organizationUserId, string key); } } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index c00be0b85c..5d27db4f24 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -13,18 +13,21 @@ namespace Bit.Core.Services { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IUserRepository _userRepository; public OrganizationService( IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository) + IOrganizationUserRepository organizationUserRepository, + IUserRepository userRepository) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; + _userRepository = userRepository; } - public async Task> SignUpAsync(OrganizationSignup organizationSignup) + public async Task> SignUpAsync(OrganizationSignup signup) { - var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organizationSignup.Plan); + var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan); if(plan == null) { throw new BadRequestException("Plan not found."); @@ -32,8 +35,8 @@ namespace Bit.Core.Services var organization = new Organization { - Name = organizationSignup.Name, - UserId = organizationSignup.Owner.Id, + Name = signup.Name, + UserId = signup.Owner.Id, PlanType = plan.Type, MaxUsers = plan.MaxUsers, PlanTrial = plan.Trial.HasValue, @@ -60,9 +63,9 @@ namespace Bit.Core.Services var orgUser = new OrganizationUser { OrganizationId = organization.Id, - UserId = organizationSignup.Owner.Id, - Email = organizationSignup.Owner.Email, - Key = organizationSignup.OwnerKey, + UserId = signup.Owner.Id, + Email = signup.Owner.Email, + Key = signup.OwnerKey, Type = Enums.OrganizationUserType.Owner, Status = Enums.OrganizationUserStatusType.Confirmed, CreationDate = DateTime.UtcNow, @@ -79,5 +82,64 @@ namespace Bit.Core.Services throw; } } + + public async Task InviteUserAsync(Guid organizationId, string email) + { + var orgUser = new OrganizationUser + { + OrganizationId = organizationId, + UserId = null, + Email = email, + Key = null, + Type = Enums.OrganizationUserType.User, + Status = Enums.OrganizationUserStatusType.Invited, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + + await _organizationUserRepository.CreateAsync(orgUser); + + // TODO: send email + + return orgUser; + } + + public async Task AcceptUserAsync(Guid organizationUserId, User user, string token) + { + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if(orgUser.Email != user.Email) + { + throw new BadRequestException("User invalid."); + } + + // TODO: validate token + + orgUser.Status = Enums.OrganizationUserStatusType.Accepted; + orgUser.UserId = orgUser.Id; + orgUser.Email = null; + await _organizationUserRepository.ReplaceAsync(orgUser); + + // TODO: send email + + return orgUser; + } + + public async Task ConfirmUserAsync(Guid organizationUserId, string key) + { + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if(orgUser.Status != Enums.OrganizationUserStatusType.Accepted) + { + throw new BadRequestException("User not accepted."); + } + + orgUser.Status = Enums.OrganizationUserStatusType.Confirmed; + orgUser.Key = key; + orgUser.Email = null; + await _organizationUserRepository.ReplaceAsync(orgUser); + + // TODO: send email + + return orgUser; + } } } diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 08c7d3cfac..e459acc1a0 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -142,5 +142,9 @@ + + + + \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUserDetails_ReadById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUserDetails_ReadById.sql new file mode 100644 index 0000000000..3c706c22c1 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUserDetails_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationUserDetails_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OrganizationUserDetailsView] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUserDetails_ReadByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/OrganizationUserDetails_ReadByOrganizationId.sql new file mode 100644 index 0000000000..9f309cc79a --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUserDetails_ReadByOrganizationId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationUserDetails_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OrganizationUserDetailsView] + WHERE + [OrganizationId] = @OrganizationId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/User_ReadPublicKeyById.sql b/src/Sql/dbo/Stored Procedures/User_ReadPublicKeyById.sql new file mode 100644 index 0000000000..8f86c90cf8 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_ReadPublicKeyById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[User_ReadPublicKeyById] + @Id NVARCHAR(50) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [PublicKey] + FROM + [dbo].[User] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Views/OrganizationUserDetailsView.sql b/src/Sql/dbo/Views/OrganizationUserDetailsView.sql new file mode 100644 index 0000000000..dec38c4ce2 --- /dev/null +++ b/src/Sql/dbo/Views/OrganizationUserDetailsView.sql @@ -0,0 +1,14 @@ +CREATE VIEW [dbo].[OrganizationUserDetailsView] +AS +SELECT + OU.[Id], + OU.[UserId], + OU.[OrganizationId], + U.[Name], + ISNULL(U.[Email], OU.[Email]) Email, + OU.[Status], + OU.[Type] +FROM + [dbo].[OrganizationUser] OU +LEFT JOIN + [dbo].[User] U ON U.Id = OU.UserId \ No newline at end of file