diff --git a/src/Api/Public/Controllers/MembersController.cs b/src/Api/Public/Controllers/MembersController.cs
new file mode 100644
index 0000000000..54f71dffe3
--- /dev/null
+++ b/src/Api/Public/Controllers/MembersController.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using Bit.Core;
+using Bit.Core.Models.Api.Public;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bit.Api.Public.Controllers
+{
+ [Route("public/members")]
+ [Authorize("Organization")]
+ public class MembersController : Controller
+ {
+ private readonly IOrganizationUserRepository _organizationUserRepository;
+ private readonly IOrganizationService _organizationService;
+ private readonly IUserService _userService;
+ private readonly CurrentContext _currentContext;
+
+ public MembersController(
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationService organizationService,
+ IUserService userService,
+ CurrentContext currentContext)
+ {
+ _organizationUserRepository = organizationUserRepository;
+ _organizationService = organizationService;
+ _userService = userService;
+ _currentContext = currentContext;
+ }
+
+ ///
+ /// Retrieve a member.
+ ///
+ ///
+ /// Retrieves the details of an existing member of the organization. You need only supply the
+ /// unique member identifier that was returned upon member creation.
+ ///
+ /// The identifier of the member to be retrieved.
+ [HttpGet("{id}")]
+ [ProducesResponseType(typeof(MemberResponseModel), (int)HttpStatusCode.OK)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public async Task Get(Guid id)
+ {
+ var userDetails = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
+ var orgUser = userDetails?.Item1;
+ if(orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId)
+ {
+ return new NotFoundResult();
+ }
+ var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser),
+ userDetails.Item2);
+ return new JsonResult(response);
+ }
+
+ ///
+ /// List all members.
+ ///
+ ///
+ /// Returns a list of your organization's members.
+ /// Member objects listed in this call do not include information about their associated collections.
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)]
+ public async Task List()
+ {
+ var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(
+ _currentContext.OrganizationId.Value);
+ // TODO: Get all CollectionUser associations for the organization and marry them up here for the response.
+ var memberResponsesTasks = users.Select(async u => new MemberResponseModel(u,
+ await _userService.TwoFactorIsEnabledAsync(u), null));
+ var memberResponses = await Task.WhenAll(memberResponsesTasks);
+ var response = new ListResponseModel(memberResponses);
+ return new JsonResult(response);
+ }
+
+ ///
+ /// Create a member.
+ ///
+ ///
+ /// Creates a new member object by inviting a user to the organization.
+ ///
+ /// The request model.
+ [HttpPost]
+ [ProducesResponseType(typeof(MemberResponseModel), (int)HttpStatusCode.OK)]
+ [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
+ public async Task Post([FromBody]MemberCreateRequestModel model)
+ {
+ var associations = model.Collections?.Select(c => c.ToSelectionReadOnly());
+ var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null,
+ model.Email, model.Type.Value, model.AccessAll.Value, model.ExternalId, associations);
+ var response = new MemberResponseModel(user, associations);
+ return new JsonResult(response);
+ }
+
+ ///
+ /// Update a member.
+ ///
+ ///
+ /// Updates the specified member object. If a property is not provided,
+ /// the value of the existing property will be reset.
+ ///
+ /// The identifier of the member to be updated.
+ /// The request model.
+ [HttpPut("{id}")]
+ [ProducesResponseType(typeof(MemberResponseModel), (int)HttpStatusCode.OK)]
+ [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public async Task Put(Guid id, [FromBody]MemberUpdateRequestModel model)
+ {
+ var existingUser = await _organizationUserRepository.GetByIdAsync(id);
+ if(existingUser == null || existingUser.OrganizationId != _currentContext.OrganizationId)
+ {
+ return new NotFoundResult();
+ }
+ var updatedUser = model.ToOrganizationUser(existingUser);
+ var associations = model.Collections?.Select(c => c.ToSelectionReadOnly());
+ await _organizationService.SaveUserAsync(updatedUser, null, associations);
+ MemberResponseModel response = null;
+ if(existingUser.UserId.HasValue)
+ {
+ var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
+ response = new MemberResponseModel(existingUserDetails,
+ await _userService.TwoFactorIsEnabledAsync(existingUserDetails), associations);
+ }
+ else
+ {
+ response = new MemberResponseModel(updatedUser, associations);
+ }
+ return new JsonResult(response);
+ }
+
+ ///
+ /// Delete a member.
+ ///
+ ///
+ /// Permanently deletes a member from the organization. This cannot be undone.
+ /// The user account will still remain. The user is only removed from the organization.
+ ///
+ /// The identifier of the member to be deleted.
+ [HttpDelete("{id}")]
+ [ProducesResponseType((int)HttpStatusCode.OK)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public async Task Delete(Guid id)
+ {
+ var user = await _organizationUserRepository.GetByIdAsync(id);
+ if(user == null || user.OrganizationId != _currentContext.OrganizationId)
+ {
+ return new NotFoundResult();
+ }
+ await _organizationService.DeleteUserAsync(_currentContext.OrganizationId.Value, id, null);
+ return new OkResult();
+ }
+ }
+}
diff --git a/src/Core/Models/Api/Public/BaseAssociationWithPermissionsModel.cs b/src/Core/Models/Api/Public/AssociationWithPermissionsBaseModel.cs
similarity index 90%
rename from src/Core/Models/Api/Public/BaseAssociationWithPermissionsModel.cs
rename to src/Core/Models/Api/Public/AssociationWithPermissionsBaseModel.cs
index 3cde538dab..03a21ab317 100644
--- a/src/Core/Models/Api/Public/BaseAssociationWithPermissionsModel.cs
+++ b/src/Core/Models/Api/Public/AssociationWithPermissionsBaseModel.cs
@@ -4,7 +4,7 @@ using Newtonsoft.Json;
namespace Bit.Core.Models.Api.Public
{
- public abstract class BaseAssociationWithPermissionsModel
+ public abstract class AssociationWithPermissionsBaseModel
{
///
/// The associated object's unique identifier.
diff --git a/src/Core/Models/Api/Public/MemberBaseModel.cs b/src/Core/Models/Api/Public/MemberBaseModel.cs
new file mode 100644
index 0000000000..a8745de7ed
--- /dev/null
+++ b/src/Core/Models/Api/Public/MemberBaseModel.cs
@@ -0,0 +1,55 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using Bit.Core.Enums;
+using Bit.Core.Models.Data;
+using Bit.Core.Models.Table;
+
+namespace Bit.Core.Models.Api.Public
+{
+ public abstract class MemberBaseModel
+ {
+ public MemberBaseModel() { }
+
+ public MemberBaseModel(OrganizationUser user)
+ {
+ if(user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ Type = user.Type;
+ AccessAll = user.AccessAll;
+ ExternalId = user.ExternalId;
+ }
+
+ public MemberBaseModel(OrganizationUserUserDetails user)
+ {
+ if(user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ Type = user.Type;
+ AccessAll = user.AccessAll;
+ ExternalId = user.ExternalId;
+ }
+
+ ///
+ /// The member's type (or role) within the organization.
+ ///
+ [Required]
+ public OrganizationUserType? Type { get; set; }
+ ///
+ /// Determines if this member can access all collections within the organization, or only the associated
+ /// collections. If set to true, this option overrides any collection assignments.
+ ///
+ [Required]
+ public bool? AccessAll { get; set; }
+ ///
+ /// External identifier linking this member to another system, such as a user directory.
+ ///
+ /// external_id_123456
+ [StringLength(300)]
+ public string ExternalId { get; set; }
+ }
+}
diff --git a/src/Core/Models/Api/Public/Request/AssociationWithPermissionsRequestModel.cs b/src/Core/Models/Api/Public/Request/AssociationWithPermissionsRequestModel.cs
index 2f0e566ee9..3cf1bfba29 100644
--- a/src/Core/Models/Api/Public/Request/AssociationWithPermissionsRequestModel.cs
+++ b/src/Core/Models/Api/Public/Request/AssociationWithPermissionsRequestModel.cs
@@ -2,7 +2,7 @@
namespace Bit.Core.Models.Api.Public
{
- public class AssociationWithPermissionsRequestModel : BaseAssociationWithPermissionsModel
+ public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel
{
public SelectionReadOnly ToSelectionReadOnly()
{
diff --git a/src/Core/Models/Api/Public/Request/MemberCreateRequestModel.cs b/src/Core/Models/Api/Public/Request/MemberCreateRequestModel.cs
new file mode 100644
index 0000000000..3565d2d9c6
--- /dev/null
+++ b/src/Core/Models/Api/Public/Request/MemberCreateRequestModel.cs
@@ -0,0 +1,22 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using Bit.Core.Models.Table;
+
+namespace Bit.Core.Models.Api.Public
+{
+ public class MemberCreateRequestModel : MemberUpdateRequestModel
+ {
+ ///
+ /// The member's email address.
+ ///
+ /// jsmith@company.com
+ [Required]
+ [EmailAddress]
+ public string Email { get; set; }
+
+ public override OrganizationUser ToOrganizationUser(OrganizationUser existingUser)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Core/Models/Api/Public/Request/MemberUpdateRequestModel.cs b/src/Core/Models/Api/Public/Request/MemberUpdateRequestModel.cs
new file mode 100644
index 0000000000..df69e0e2fb
--- /dev/null
+++ b/src/Core/Models/Api/Public/Request/MemberUpdateRequestModel.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+using Bit.Core.Models.Table;
+
+namespace Bit.Core.Models.Api.Public
+{
+ public class MemberUpdateRequestModel : MemberBaseModel
+ {
+ ///
+ /// The associated collections that this member can access.
+ ///
+ public IEnumerable Collections { get; set; }
+
+ public virtual OrganizationUser ToOrganizationUser(OrganizationUser existingUser)
+ {
+ existingUser.Type = Type.Value;
+ existingUser.AccessAll = AccessAll.Value;
+ existingUser.ExternalId = ExternalId;
+ return existingUser;
+ }
+ }
+}
diff --git a/src/Core/Models/Api/Public/Response/AssociationWithPermissionsResponseModel.cs b/src/Core/Models/Api/Public/Response/AssociationWithPermissionsResponseModel.cs
index 2e8101f8e5..027cd257dc 100644
--- a/src/Core/Models/Api/Public/Response/AssociationWithPermissionsResponseModel.cs
+++ b/src/Core/Models/Api/Public/Response/AssociationWithPermissionsResponseModel.cs
@@ -3,7 +3,7 @@ using Bit.Core.Models.Data;
namespace Bit.Core.Models.Api.Public
{
- public class AssociationWithPermissionsResponseModel : BaseAssociationWithPermissionsModel
+ public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel
{
public AssociationWithPermissionsResponseModel(SelectionReadOnly selection)
{
diff --git a/src/Core/Models/Api/Public/Response/MemberResponseModel.cs b/src/Core/Models/Api/Public/Response/MemberResponseModel.cs
new file mode 100644
index 0000000000..c7a2c093e6
--- /dev/null
+++ b/src/Core/Models/Api/Public/Response/MemberResponseModel.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using Bit.Core.Enums;
+using Bit.Core.Models.Data;
+using Bit.Core.Models.Table;
+
+namespace Bit.Core.Models.Api.Public
+{
+ ///
+ /// An organization member.
+ ///
+ public class MemberResponseModel : MemberBaseModel, IResponseModel
+ {
+ public MemberResponseModel(OrganizationUser user, IEnumerable collections)
+ : base(user)
+ {
+ if(user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ Id = user.Id;
+ Email = user.Email;
+ Status = user.Status;
+ Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
+ }
+
+ public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
+ IEnumerable collections)
+ : base(user)
+ {
+ if(user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ Id = user.Id;
+ Name = user.Name;
+ Email = user.Email;
+ TwoFactorEnabled = twoFactorEnabled;
+ Status = user.Status;
+ Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
+ }
+
+ ///
+ /// String representing the object's type. Objects of the same type share the same properties.
+ ///
+ /// member
+ [Required]
+ public string Object => "member";
+ ///
+ /// The member's unique identifier.
+ ///
+ /// 539a36c5-e0d2-4cf9-979e-51ecf5cf6593
+ [Required]
+ public Guid Id { get; set; }
+ ///
+ /// The member's name, set from their user account profile.
+ ///
+ /// John Smith
+ public string Name { get; set; }
+ ///
+ /// The member's email address.
+ ///
+ /// jsmith@company.com
+ [Required]
+ public string Email { get; set; }
+ ///
+ /// Returns true if the member has a two-step login method enabled on their user account.
+ ///
+ [Required]
+ public bool TwoFactorEnabled { get; set; }
+ ///
+ /// The member's status within the organization. All created members start with a status of "Invited".
+ /// Once a member accept's their invitation to join the organization, their status changes to "Accepted".
+ /// Accepted members are then "Confirmed" by an organization administrator. Once a member is "Confirmed",
+ /// their status can no longer change.
+ ///
+ [Required]
+ public OrganizationUserStatusType Status { get; set; }
+ ///
+ /// The associated collections that this member can access.
+ ///
+ public IEnumerable Collections { get; set; }
+ }
+}
diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs
index 3ae5d5d2f8..61e25dc31a 100644
--- a/src/Core/Services/IOrganizationService.cs
+++ b/src/Core/Services/IOrganizationService.cs
@@ -35,7 +35,7 @@ namespace Bit.Core.Services
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 SaveUserAsync(OrganizationUser user, Guid? savingUserId, IEnumerable collections);
Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId);
Task DeleteUserAsync(Guid organizationId, Guid userId);
Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable groupIds);
diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs
index 701f0aece9..b90b9f67a5 100644
--- a/src/Core/Services/Implementations/OrganizationService.cs
+++ b/src/Core/Services/Implementations/OrganizationService.cs
@@ -871,9 +871,14 @@ namespace Bit.Core.Services
public async Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email,
OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections)
{
- var result = await InviteUserAsync(organizationId, invitingUserId, new List { email }, type, accessAll,
+ var results = await InviteUserAsync(organizationId, invitingUserId, new List { email }, type, accessAll,
externalId, collections);
- return result.FirstOrDefault();
+ var result = results.FirstOrDefault();
+ if(result == null)
+ {
+ throw new BadRequestException("This user has already been invited.");
+ }
+ return result;
}
public async Task> InviteUserAsync(Guid organizationId, Guid? invitingUserId,
@@ -1062,16 +1067,17 @@ namespace Bit.Core.Services
return orgUser;
}
- public async Task SaveUserAsync(OrganizationUser user, Guid savingUserId, IEnumerable collections)
+ public async Task SaveUserAsync(OrganizationUser user, Guid? savingUserId,
+ IEnumerable collections)
{
if(user.Id.Equals(default(Guid)))
{
throw new BadRequestException("Invite the user first.");
}
- if(user.Type == OrganizationUserType.Owner)
+ if(savingUserId.HasValue && user.Type == OrganizationUserType.Owner)
{
- var savingUserOrgs = await _organizationUserRepository.GetManyByUserAsync(savingUserId);
+ var savingUserOrgs = await _organizationUserRepository.GetManyByUserAsync(savingUserId.Value);
if(!savingUserOrgs.Any(u => u.OrganizationId == user.OrganizationId && u.Type == OrganizationUserType.Owner))
{
throw new BadRequestException("Only owners can update other owners.");