mirror of
https://github.com/bitwarden/server.git
synced 2025-06-30 15:42:48 -05:00
Organization autoscaling (#1585)
* Add autoscale fields to Organization * Add autoscale setting changes * Autoscale organizations updates InviteUsersAsync to support all invite sources. sends an email to org owners when organization autoscaled * All organizations autoscale Disabling autoscaling can be done by setting max seats to current seats. We only warn about autoscaling on the first autoscaling event. * Fix tests * Bug fixes * Simplify subscription update logic * Void invoices that fail to delete Stripe no longer allows deletion of draft invoices that were created as part of subscription updates. It's necessary to void out these invoices without sending tem to the client. * Notify org owners when their subscription runs out of seats * Use datetime for notifications Allows for later re-sending email if we want to periodically remind owners * Do not update subscription if it already matches new quatity * Include all migrations * Remove unnecessary inline styling * SubscriptionUpdate handles update decisions * Remove unnecessary html setter * PR review * Use minimum access for class methods
This commit is contained in:
@ -28,6 +28,7 @@ namespace Bit.Admin.Models
|
||||
PlanType = org.PlanType;
|
||||
Plan = org.Plan;
|
||||
Seats = org.Seats;
|
||||
MaxAutoscaleSeats = org.MaxAutoscaleSeats;
|
||||
MaxCollections = org.MaxCollections;
|
||||
UsePolicies = org.UsePolicies;
|
||||
UseSso = org.UseSso;
|
||||
@ -69,6 +70,8 @@ namespace Bit.Admin.Models
|
||||
public string Plan { get; set; }
|
||||
[Display(Name = "Seats")]
|
||||
public int? Seats { get; set; }
|
||||
[Display(Name = "Max. Autoscale Seats")]
|
||||
public int? MaxAutoscaleSeats { get; set; }
|
||||
[Display(Name = "Max. Collections")]
|
||||
public short? MaxCollections { get; set; }
|
||||
[Display(Name = "Policies")]
|
||||
@ -136,6 +139,7 @@ namespace Bit.Admin.Models
|
||||
existingOrganization.Enabled = Enabled;
|
||||
existingOrganization.LicenseKey = LicenseKey;
|
||||
existingOrganization.ExpirationDate = ExpirationDate;
|
||||
existingOrganization.MaxAutoscaleSeats = MaxAutoscaleSeats;
|
||||
return existingOrganization;
|
||||
}
|
||||
}
|
||||
|
@ -145,6 +145,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxAutoscaleSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Features</h2>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseTotp">
|
||||
|
@ -134,7 +134,8 @@ namespace Bit.Api.Controllers
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
var result = await _organizationService.InviteUserAsync(orgGuidId, userId.Value, null, new OrganizationUserInvite(model));
|
||||
var result = await _organizationService.InviteUsersAsync(orgGuidId, userId.Value,
|
||||
new (OrganizationUserInvite, string)[] { (new OrganizationUserInvite(model), null) });
|
||||
}
|
||||
|
||||
[HttpPost("reinvite")]
|
||||
|
@ -281,6 +281,19 @@ namespace Bit.Api.Controllers
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("{id}/subscription")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostSubscription(string id, [FromBody] OrganizationSubscriptionUpdateRequestModel model)
|
||||
{
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.OrganizationOwner(orgIdGuid))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/seat")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostSeat(string id, [FromBody]OrganizationSeatRequestModel model)
|
||||
|
@ -0,0 +1,45 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0"
|
||||
style="margin: 0; box-sizing: border-box;">
|
||||
<tr
|
||||
style="margin: 0; box-sizing: border-box;">
|
||||
<td class="content-block"
|
||||
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
|
||||
valign="top">
|
||||
To accommodate new user invitations, your seat count has increased from {{InitialSeatCount}} to {{CurrentSeatCount}}. A
|
||||
prorated charge has been immediately applied to your subscription for the new users. This notification will only be sent
|
||||
once.
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="margin: 0; box-sizing: border-box;">
|
||||
<td class="content-block"
|
||||
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
|
||||
valign="top">
|
||||
To manage your subscription:
|
||||
<ol>
|
||||
<li>Log in to your
|
||||
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/settings/subscription">
|
||||
Web Vault
|
||||
</a>
|
||||
and open your Organization.
|
||||
</li>
|
||||
<li>Open the <b>Settings</b> tab and select <b>Subscription</b> from the left-hand menu.</li>
|
||||
<li>Update your subscription.</li>
|
||||
<li>Click <b>Save</b>.</li>
|
||||
</ol>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="margin: 0; box-sizing: border-box;">
|
||||
<td class="content-block last"
|
||||
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;"
|
||||
valign="top">
|
||||
For more information, please refer to
|
||||
<a href="https://bitwarden.com/help/article/managing-users/#subscription">
|
||||
our help article.
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
@ -0,0 +1,13 @@
|
||||
{{#>BasicTextLayout}}
|
||||
To accommodate new user invitations, your seat count has increased from {{InitialSeatCount}} to {{CurrentSeatCount}}. A
|
||||
prorated charge has been immediately applied to your subscription for the new users. This notification will only be sent
|
||||
once.
|
||||
|
||||
To manage your subscription:
|
||||
1. Log in to your Web Vault and open your Organization.
|
||||
2. Open the Settings tab and select Subscription from the left-hand menu.
|
||||
4. Update your subscription.
|
||||
5. Click Save.
|
||||
|
||||
For more information, please refer to the following help article: https://bitwarden.com/help/article/managing-user/#subscription
|
||||
{{/BasicTextLayout}}
|
@ -0,0 +1,38 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0"
|
||||
style="margin: 0; box-sizing: border-box; ">
|
||||
<tr
|
||||
style="margin: 0; box-sizing: border-box; ">
|
||||
<td class="content-block"
|
||||
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
|
||||
valign="top">
|
||||
Your organization has reached the seat limit of {{MaxSeatCount}} and new users cannot be invited.
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="margin: 0; box-sizing: border-box; ">
|
||||
<td class="content-block"
|
||||
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
|
||||
valign="top">
|
||||
To increase your subscription:
|
||||
<ol>
|
||||
<li>Log in to your <a
|
||||
href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/settings/subscription">Web
|
||||
Vault</a> and open your Organization.</li>
|
||||
<li>Open the <b>Settings</b> tab and select <b>Subscription</b> from the left-hand menu.</li>
|
||||
<li>Update your subscription.</li>
|
||||
<li>Click <b>Save</b>.</li>
|
||||
</ol>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="margin: 0; box-sizing: border-box; ">
|
||||
<td class="content-block last"
|
||||
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;"
|
||||
valign="top">
|
||||
For more information, please refer to the following help article: <a
|
||||
href="https://bitwarden.com/help/article/managing-users/#subscription">https://bitwarden.com/help/article/managing-users/#subscription</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
@ -0,0 +1,11 @@
|
||||
{{#>BasicTextLayout}}
|
||||
Your organization has reached the seat limit of {{MaxSeatCount}} and new users cannot be invited.
|
||||
|
||||
To increase your subscription:
|
||||
1. Log in to your Web Vault and open your Organization.
|
||||
2. Open the Settings tab and select Subscription from the left-hand menu.
|
||||
4. Update your subscription.
|
||||
5. Click Save.
|
||||
|
||||
For more information, please refer to the following help article: https://bitwarden.com/help/article/managing-user/#subscription
|
||||
{{/BasicTextLayout}}
|
@ -40,6 +40,7 @@ namespace Bit.Core.Models.Api
|
||||
public string BillingAddressPostalCode { get; set; }
|
||||
[StringLength(2)]
|
||||
public string BillingAddressCountry { get; set; }
|
||||
public int? MaxAutoscaleSeats { get; set; }
|
||||
|
||||
public virtual OrganizationSignup ToOrganizationSignup(User user)
|
||||
{
|
||||
@ -52,6 +53,7 @@ namespace Bit.Core.Models.Api
|
||||
PaymentMethodType = PaymentMethodType,
|
||||
PaymentToken = PaymentToken,
|
||||
AdditionalSeats = AdditionalSeats,
|
||||
MaxAutoscaleSeats = MaxAutoscaleSeats,
|
||||
AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(0),
|
||||
PremiumAccessAddon = PremiumAccessAddon,
|
||||
BillingEmail = BillingEmail,
|
||||
|
@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Core.Models.Api
|
||||
{
|
||||
public class OrganizationSubscriptionUpdateRequestModel
|
||||
{
|
||||
[Required]
|
||||
public int SeatAdjustment { get; set; }
|
||||
public int? MaxAutoscaleSeats { get; set; }
|
||||
}
|
||||
}
|
@ -30,6 +30,7 @@ namespace Bit.Core.Models.Api
|
||||
Plan = new PlanResponseModel(Utilities.StaticStore.Plans.FirstOrDefault(plan => plan.Type == organization.PlanType));
|
||||
PlanType = organization.PlanType;
|
||||
Seats = organization.Seats;
|
||||
MaxAutoscaleSeats = organization.MaxAutoscaleSeats;
|
||||
MaxCollections = organization.MaxCollections;
|
||||
MaxStorageGb = organization.MaxStorageGb;
|
||||
UsePolicies = organization.UsePolicies;
|
||||
@ -59,6 +60,7 @@ namespace Bit.Core.Models.Api
|
||||
public PlanResponseModel Plan { get; set; }
|
||||
public PlanType PlanType { get; set; }
|
||||
public int? Seats { get; set; }
|
||||
public int? MaxAutoscaleSeats { get; set; } = null;
|
||||
public short? MaxCollections { get; set; }
|
||||
public short? MaxStorageGb { get; set; }
|
||||
public bool UsePolicies { get; set; }
|
||||
|
@ -177,7 +177,7 @@ namespace Bit.Core.Models.Business
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanUse(GlobalSettings globalSettings)
|
||||
public bool CanUse(IGlobalSettings globalSettings)
|
||||
{
|
||||
if (!Enabled || Issued > DateTime.UtcNow || Expires < DateTime.UtcNow)
|
||||
{
|
||||
|
@ -12,5 +12,6 @@ namespace Bit.Core.Models.Business
|
||||
public string CollectionName { get; set; }
|
||||
public PaymentMethodType? PaymentMethodType { get; set; }
|
||||
public string PaymentToken { get; set; }
|
||||
public int? MaxAutoscaleSeats { get; set; } = null;
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
using System.Linq;
|
||||
using Bit.Core.Models.Table;
|
||||
using Stripe;
|
||||
using StaticStore = Bit.Core.Models.StaticStore;
|
||||
|
||||
namespace Bit.Core.Models.Business
|
||||
{
|
||||
@ -11,6 +10,10 @@ namespace Bit.Core.Models.Business
|
||||
|
||||
public abstract SubscriptionItemOptions RevertItemOptions(Subscription subscription);
|
||||
public abstract SubscriptionItemOptions UpgradeItemOptions(Subscription subscription);
|
||||
|
||||
public bool UpdateNeeded(Subscription subscription) =>
|
||||
(SubscriptionItem(subscription)?.Quantity ?? 0) != (UpgradeItemOptions(subscription).Quantity ?? 0);
|
||||
|
||||
protected SubscriptionItem SubscriptionItem(Subscription subscription) =>
|
||||
subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == PlanId);
|
||||
}
|
||||
|
11
src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs
Normal file
11
src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Bit.Core.Models.Mail
|
||||
{
|
||||
public class OrganizationSeatsAutoscaledViewModel : BaseMailModel
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public int InitialSeatCount { get; set; }
|
||||
public int CurrentSeatCount { get; set; }
|
||||
}
|
||||
}
|
10
src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs
Normal file
10
src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Bit.Core.Models.Mail
|
||||
{
|
||||
public class OrganizationSeatsMaxReachedViewModel : BaseMailModel
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public int MaxSeatCount { get; set; }
|
||||
}
|
||||
}
|
@ -15,6 +15,7 @@ namespace Bit.Core.Models.StaticStore
|
||||
public short? BaseStorageGb { get; set; }
|
||||
public short? MaxCollections { get; set; }
|
||||
public short? MaxUsers { get; set; }
|
||||
public bool AllowSeatAutoscale { get; set; }
|
||||
|
||||
public bool HasAdditionalSeatsOption { get; set; }
|
||||
public int? MaxAdditionalSeats { get; set; }
|
||||
|
@ -66,6 +66,8 @@ namespace Bit.Core.Models.Table
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
public int? MaxAutoscaleSeats { get; set; } = null;
|
||||
public DateTime? OwnersNotifiedOfAutoscaling { get; set; } = null;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
|
@ -20,7 +20,7 @@ namespace Bit.Core.Repositories.EntityFramework
|
||||
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationUsers)
|
||||
{ }
|
||||
|
||||
public async Task CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections)
|
||||
public async Task<Guid> CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections)
|
||||
{
|
||||
var organizationUser = await base.CreateAsync(obj);
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
@ -41,13 +41,15 @@ namespace Bit.Core.Repositories.EntityFramework
|
||||
await dbContext.CollectionUsers.AddRangeAsync(collectionUsers);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return organizationUser.Id;
|
||||
}
|
||||
|
||||
public async Task CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers)
|
||||
public async Task<ICollection<Guid>> CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers)
|
||||
{
|
||||
if (!organizationUsers.Any())
|
||||
{
|
||||
return;
|
||||
return new List<Guid>();
|
||||
}
|
||||
|
||||
foreach (var organizationUser in organizationUsers)
|
||||
@ -61,6 +63,8 @@ namespace Bit.Core.Repositories.EntityFramework
|
||||
var entities = Mapper.Map<List<EfModel.OrganizationUser>>(organizationUsers);
|
||||
await dbContext.AddRangeAsync(entities);
|
||||
}
|
||||
|
||||
return organizationUsers.Select(u => u.Id).ToList();
|
||||
}
|
||||
|
||||
public async Task DeleteManyAsync(IEnumerable<Guid> organizationUserIds)
|
||||
|
@ -28,8 +28,8 @@ namespace Bit.Core.Repositories
|
||||
OrganizationUserStatusType? status = null);
|
||||
Task UpdateGroupsAsync(Guid orgUserId, IEnumerable<Guid> groupIds);
|
||||
Task UpsertManyAsync(IEnumerable<OrganizationUser> organizationUsers);
|
||||
Task CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections);
|
||||
Task CreateManyAsync(IEnumerable<OrganizationUser> organizationIdUsers);
|
||||
Task<Guid> CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections);
|
||||
Task<ICollection<Guid>> CreateManyAsync(IEnumerable<OrganizationUser> organizationIdUsers);
|
||||
Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections);
|
||||
Task ReplaceManyAsync(IEnumerable<OrganizationUser> organizationUsers);
|
||||
Task<ICollection<OrganizationUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds);
|
||||
|
@ -240,7 +240,7 @@ namespace Bit.Core.Repositories.SqlServer
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections)
|
||||
public async Task<Guid> CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections)
|
||||
{
|
||||
obj.SetNewId();
|
||||
var objWithCollections = JsonConvert.DeserializeObject<OrganizationUserWithCollections>(
|
||||
@ -254,6 +254,8 @@ namespace Bit.Core.Repositories.SqlServer
|
||||
objWithCollections,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
return obj.Id;
|
||||
}
|
||||
|
||||
public async Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections)
|
||||
@ -339,11 +341,11 @@ namespace Bit.Core.Repositories.SqlServer
|
||||
await ReplaceManyAsync(replaceUsers);
|
||||
}
|
||||
|
||||
public async Task CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers)
|
||||
public async Task<ICollection<Guid>> CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers)
|
||||
{
|
||||
if (!organizationUsers.Any())
|
||||
{
|
||||
return;
|
||||
return default;
|
||||
}
|
||||
|
||||
foreach(var organizationUser in organizationUsers)
|
||||
@ -359,6 +361,8 @@ namespace Bit.Core.Repositories.SqlServer
|
||||
new { OrganizationUsersInput = orgUsersTVP },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
return organizationUsers.Select(u => u.Id).ToList();
|
||||
}
|
||||
|
||||
public async Task ReplaceManyAsync(IEnumerable<OrganizationUser> organizationUsers)
|
||||
|
@ -20,6 +20,8 @@ namespace Bit.Core.Services
|
||||
Task SendMasterPasswordHintEmailAsync(string email, string hint);
|
||||
Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token);
|
||||
Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites);
|
||||
Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
|
||||
Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
|
||||
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails);
|
||||
Task SendOrganizationConfirmedEmailAsync(string organizationName, string email);
|
||||
Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email);
|
||||
|
@ -17,7 +17,8 @@ namespace Bit.Core.Services
|
||||
Task ReinstateSubscriptionAsync(Guid organizationId);
|
||||
Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade);
|
||||
Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb);
|
||||
Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment);
|
||||
Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats);
|
||||
Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment, DateTime? prorationDate = null);
|
||||
Task VerifyBankAsync(Guid organizationId, int amount1, int amount2);
|
||||
Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false);
|
||||
Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationLicense license, User owner,
|
||||
@ -31,9 +32,10 @@ namespace Bit.Core.Services
|
||||
Task UpdateAsync(Organization organization, bool updateBilling = false);
|
||||
Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type);
|
||||
Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type);
|
||||
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
|
||||
Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<SelectionReadOnly> collections);
|
||||
Task<List<OrganizationUser>> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string externalId, OrganizationUserInvite orgUserInvite);
|
||||
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
|
||||
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId);
|
||||
Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token,
|
||||
|
@ -144,6 +144,35 @@ namespace Bit.Core.Services
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails)
|
||||
{
|
||||
var message = CreateDefaultMessage($"{organization.Name} Seat Count Has Increased", ownerEmails);
|
||||
var model = new OrganizationSeatsAutoscaledViewModel
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
InitialSeatCount = initialSeatCount,
|
||||
CurrentSeatCount = organization.Seats.Value,
|
||||
};
|
||||
|
||||
await AddMessageContentAsync(message, "OrganizationSeatsAutoscaled", model);
|
||||
message.Category = "OrganizationSeatsAutoscaled";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails)
|
||||
{
|
||||
var message = CreateDefaultMessage($"{organization.Name} Seat Limit Reached", ownerEmails);
|
||||
var model = new OrganizationSeatsMaxReachedViewModel
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
MaxSeatCount = maxSeatCount,
|
||||
};
|
||||
|
||||
await AddMessageContentAsync(message, "OrganizationSeatsMaxReached", model);
|
||||
message.Category = "OrganizationSeatsMaxReached";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier,
|
||||
IEnumerable<string> adminEmails)
|
||||
{
|
||||
|
@ -16,6 +16,7 @@ using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Context;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services
|
||||
{
|
||||
@ -40,9 +41,11 @@ namespace Bit.Core.Services
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ISsoUserRepository _ssoUserRepository;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly ITaxRateRepository _taxRateRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<OrganizationService> _logger;
|
||||
|
||||
|
||||
public OrganizationService(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -64,9 +67,10 @@ namespace Bit.Core.Services
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ISsoUserRepository ssoUserRepository,
|
||||
IReferenceEventService referenceEventService,
|
||||
GlobalSettings globalSettings,
|
||||
IGlobalSettings globalSettings,
|
||||
ITaxRateRepository taxRateRepository,
|
||||
ICurrentContext currentContext)
|
||||
ICurrentContext currentContext,
|
||||
ILogger<OrganizationService> logger)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -90,6 +94,7 @@ namespace Bit.Core.Services
|
||||
_globalSettings = globalSettings;
|
||||
_taxRateRepository = taxRateRepository;
|
||||
_currentContext = currentContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||
@ -241,14 +246,14 @@ namespace Bit.Core.Services
|
||||
$"Disable your SSO configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!newPlan.HasResetPassword && organization.UseResetPassword)
|
||||
{
|
||||
var resetPasswordPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Your new plan does not allow the Password Reset feature. " +
|
||||
throw new BadRequestException("Your new plan does not allow the Password Reset feature. " +
|
||||
"Disable your Password Reset policy.");
|
||||
}
|
||||
}
|
||||
@ -347,7 +352,7 @@ namespace Bit.Core.Services
|
||||
return secret;
|
||||
}
|
||||
|
||||
public async Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment)
|
||||
public async Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats)
|
||||
{
|
||||
var organization = await GetOrgById(organizationId);
|
||||
if (organization == null)
|
||||
@ -355,6 +360,69 @@ namespace Bit.Core.Services
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var newSeatCount = organization.Seats + seatAdjustment;
|
||||
if (maxAutoscaleSeats.HasValue && newSeatCount > maxAutoscaleSeats.Value)
|
||||
{
|
||||
throw new BadRequestException("Cannot set max seat autoscaling below seat count.");
|
||||
}
|
||||
|
||||
if (seatAdjustment != 0)
|
||||
{
|
||||
await AdjustSeatsAsync(organization, seatAdjustment);
|
||||
}
|
||||
if (maxAutoscaleSeats != organization.MaxAutoscaleSeats)
|
||||
{
|
||||
await UpdateAutoscalingAsync(organization, maxAutoscaleSeats);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateAutoscalingAsync(Organization organization, int? maxAutoscaleSeats)
|
||||
{
|
||||
|
||||
if (maxAutoscaleSeats.HasValue &&
|
||||
organization.Seats.HasValue &&
|
||||
maxAutoscaleSeats.Value < organization.Seats.Value)
|
||||
{
|
||||
throw new BadRequestException($"Cannot set max seat autoscaling below current seat count.");
|
||||
}
|
||||
|
||||
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType);
|
||||
if (plan == null)
|
||||
{
|
||||
throw new BadRequestException("Existing plan not found.");
|
||||
}
|
||||
|
||||
if (!plan.AllowSeatAutoscale)
|
||||
{
|
||||
throw new BadRequestException("Your plan does not allow seat autoscaling.");
|
||||
}
|
||||
|
||||
if (plan.MaxUsers.HasValue && maxAutoscaleSeats.HasValue &&
|
||||
maxAutoscaleSeats > plan.MaxUsers)
|
||||
{
|
||||
throw new BadRequestException(string.Concat($"Your plan has a seat limit of {plan.MaxUsers}, ",
|
||||
$"but you have specified a max autoscale count of {maxAutoscaleSeats}.",
|
||||
"Reduce your max autoscale seat count."));
|
||||
}
|
||||
|
||||
organization.MaxAutoscaleSeats = maxAutoscaleSeats;
|
||||
|
||||
await ReplaceAndUpdateCache(organization);
|
||||
}
|
||||
|
||||
public async Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment, DateTime? prorationDate = null)
|
||||
{
|
||||
var organization = await GetOrgById(organizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return await AdjustSeatsAsync(organization, seatAdjustment, prorationDate);
|
||||
}
|
||||
|
||||
private async Task<string> AdjustSeatsAsync(Organization organization, int seatAdjustment, DateTime? prorationDate = null, IEnumerable<string> ownerEmails = null)
|
||||
{
|
||||
if (organization.Seats == null)
|
||||
{
|
||||
throw new BadRequestException("Organization has no seat limit, no need to adjust seats");
|
||||
@ -420,6 +488,24 @@ namespace Bit.Core.Services
|
||||
});
|
||||
organization.Seats = (short?)newSeatTotal;
|
||||
await ReplaceAndUpdateCache(organization);
|
||||
|
||||
if (organization.Seats.HasValue && organization.MaxAutoscaleSeats.HasValue && organization.Seats == organization.MaxAutoscaleSeats)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ownerEmails == null)
|
||||
{
|
||||
ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
|
||||
OrganizationUserType.Owner)).Select(u => u.Email).Distinct();
|
||||
}
|
||||
await _mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSeats.Value, ownerEmails);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Error encountered notifying organization owners of seat limit reached.");
|
||||
}
|
||||
}
|
||||
|
||||
return paymentIntentClientSecret;
|
||||
}
|
||||
|
||||
@ -470,7 +556,7 @@ namespace Bit.Core.Services
|
||||
bool provider = false)
|
||||
{
|
||||
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan);
|
||||
if (!(plan is {LegacyYear: null}))
|
||||
if (!(plan is { LegacyYear: null }))
|
||||
{
|
||||
throw new BadRequestException("Invalid plan selected.");
|
||||
}
|
||||
@ -558,7 +644,7 @@ namespace Bit.Core.Services
|
||||
|
||||
var orgsWithSingleOrgPolicy = policies.Where(p => p.Enabled && p.Type == PolicyType.SingleOrg)
|
||||
.Select(p => p.OrganizationId);
|
||||
var blockedBySingleOrgPolicy = orgUsers.Any(ou => ou is {Type: OrganizationUserType.Owner} &&
|
||||
var blockedBySingleOrgPolicy = orgUsers.Any(ou => ou is { Type: OrganizationUserType.Owner } &&
|
||||
ou.Type != OrganizationUserType.Admin &&
|
||||
ou.Status != OrganizationUserStatusType.Invited &&
|
||||
orgsWithSingleOrgPolicy.Contains(ou.OrganizationId));
|
||||
@ -786,14 +872,14 @@ namespace Bit.Core.Services
|
||||
$"Your new license does not allow for the use of SSO. Disable your SSO configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!license.UseResetPassword && organization.UseResetPassword)
|
||||
{
|
||||
var resetPasswordPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Your new license does not allow the Password Reset feature. "
|
||||
throw new BadRequestException("Your new license does not allow the Password Reset feature. "
|
||||
+ "Disable your Password Reset policy.");
|
||||
}
|
||||
}
|
||||
@ -964,11 +1050,12 @@ namespace Bit.Core.Services
|
||||
await UpdateAsync(organization);
|
||||
}
|
||||
|
||||
private async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId,
|
||||
public async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites)
|
||||
{
|
||||
var organization = await GetOrgById(organizationId);
|
||||
if (organization == null || invites.Any(i => i.invite.Emails == null || i.externalId == null))
|
||||
var initialSeatCount = organization.Seats;
|
||||
if (organization == null || invites.Any(i => i.invite.Emails == null))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@ -983,23 +1070,34 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
|
||||
var newSeatsRequired = 0;
|
||||
var existingEmails = new HashSet<string>(await _organizationUserRepository.SelectKnownEmailsAsync(
|
||||
organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase);
|
||||
if (organization.Seats.HasValue)
|
||||
{
|
||||
var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organizationId);
|
||||
var availableSeats = organization.Seats.Value - userCount;
|
||||
if (availableSeats < invites.Select(i => i.invite.Emails.Count()).Sum())
|
||||
{
|
||||
throw new BadRequestException("You have reached the maximum number of users " +
|
||||
$"({organization.Seats.Value}) for this organization.");
|
||||
}
|
||||
newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats;
|
||||
}
|
||||
|
||||
var (canScale, failureReason) = await CanScaleAsync(organization, newSeatsRequired);
|
||||
if (!canScale)
|
||||
{
|
||||
throw new BadRequestException(failureReason);
|
||||
}
|
||||
|
||||
var invitedAreAllOwners = invites.All(i => i.invite.Type == OrganizationUserType.Owner);
|
||||
if (!invitedAreAllOwners && !await HasConfirmedOwnersExceptAsync(organizationId, new Guid[] { }))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
}
|
||||
|
||||
|
||||
var orgUsers = new List<OrganizationUser>();
|
||||
var limitedCollectionOrgUsers = new List<(OrganizationUser, IEnumerable<SelectionReadOnly>)>();
|
||||
var orgUserInvitedCount = 0;
|
||||
var exceptions = new List<Exception>();
|
||||
var events = new List<(OrganizationUser, EventType, DateTime?)>();
|
||||
var existingEmails = new HashSet<string>(await _organizationUserRepository.SelectKnownEmailsAsync(
|
||||
organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase);
|
||||
foreach (var (invite, externalId) in invites)
|
||||
{
|
||||
foreach (var email in invite.Emails)
|
||||
@ -1036,11 +1134,14 @@ namespace Bit.Core.Services
|
||||
|
||||
if (!orgUser.AccessAll && invite.Collections.Any())
|
||||
{
|
||||
throw new Exception("Bulk invite does not support limited collection invites");
|
||||
limitedCollectionOrgUsers.Add((orgUser, invite.Collections));
|
||||
}
|
||||
else
|
||||
{
|
||||
orgUsers.Add(orgUser);
|
||||
}
|
||||
|
||||
events.Add((orgUser, EventType.OrganizationUser_Invited, DateTime.UtcNow));
|
||||
orgUsers.Add(orgUser);
|
||||
orgUserInvitedCount++;
|
||||
}
|
||||
catch (Exception e)
|
||||
@ -1050,9 +1151,21 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
|
||||
if (exceptions.Any())
|
||||
{
|
||||
throw new AggregateException("One or more errors occurred while inviting users.", exceptions);
|
||||
}
|
||||
|
||||
var prorationDate = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
await _organizationUserRepository.CreateManyAsync(orgUsers);
|
||||
foreach (var (orgUser, collections) in limitedCollectionOrgUsers)
|
||||
{
|
||||
await _organizationUserRepository.CreateAsync(orgUser, collections);
|
||||
}
|
||||
|
||||
await AutoAddSeatsAsync(organization, newSeatsRequired, prorationDate);
|
||||
await SendInvitesAsync(orgUsers, organization);
|
||||
await _eventService.LogOrganizationUserEventsAsync(events);
|
||||
|
||||
@ -1064,6 +1177,16 @@ namespace Bit.Core.Services
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Revert any added users.
|
||||
var invitedOrgUserIds = orgUsers.Select(u => u.Id).Concat(limitedCollectionOrgUsers.Select(u => u.Item1.Id));
|
||||
await _organizationUserRepository.DeleteManyAsync(invitedOrgUserIds);
|
||||
var currentSeatCount = (await _organizationRepository.GetByIdAsync(organization.Id)).Seats;
|
||||
|
||||
if (initialSeatCount.HasValue && currentSeatCount.HasValue && currentSeatCount.Value != initialSeatCount.Value)
|
||||
{
|
||||
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentSeatCount.Value, prorationDate);
|
||||
}
|
||||
|
||||
exceptions.Add(e);
|
||||
}
|
||||
|
||||
@ -1075,94 +1198,6 @@ namespace Bit.Core.Services
|
||||
return orgUsers;
|
||||
}
|
||||
|
||||
public async Task<List<OrganizationUser>> InviteUserAsync(Guid organizationId, Guid? invitingUserId,
|
||||
string externalId, OrganizationUserInvite invite)
|
||||
{
|
||||
var organization = await GetOrgById(organizationId);
|
||||
if (organization == null || invite?.Emails == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (invitingUserId.HasValue && invite.Type.HasValue)
|
||||
{
|
||||
await ValidateOrganizationUserUpdatePermissions(organizationId, invite.Type.Value, null);
|
||||
}
|
||||
|
||||
if (organization.Seats.HasValue)
|
||||
{
|
||||
var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organizationId);
|
||||
var availableSeats = organization.Seats.Value - userCount;
|
||||
if (availableSeats < invite.Emails.Count())
|
||||
{
|
||||
throw new BadRequestException("You have reached the maximum number of users " +
|
||||
$"({organization.Seats.Value}) for this organization.");
|
||||
}
|
||||
}
|
||||
|
||||
var invitedIsOwner = invite.Type is OrganizationUserType.Owner;
|
||||
if (!invitedIsOwner && !await HasConfirmedOwnersExceptAsync(organizationId, new Guid[] {}))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
}
|
||||
|
||||
var orgUsers = new List<OrganizationUser>();
|
||||
var orgUserInvitedCount = 0;
|
||||
foreach (var email in invite.Emails)
|
||||
{
|
||||
// Make sure user is not already invited
|
||||
var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync(
|
||||
organizationId, email, false);
|
||||
if (existingOrgUserCount > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var orgUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
UserId = null,
|
||||
Email = email.ToLowerInvariant(),
|
||||
Key = null,
|
||||
Type = invite.Type.Value,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
AccessAll = invite.AccessAll,
|
||||
ExternalId = externalId,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
if (invite.Permissions != null)
|
||||
{
|
||||
orgUser.Permissions = System.Text.Json.JsonSerializer.Serialize(invite.Permissions, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
});
|
||||
}
|
||||
|
||||
if (!orgUser.AccessAll && invite.Collections.Any())
|
||||
{
|
||||
await _organizationUserRepository.CreateAsync(orgUser, invite.Collections);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _organizationUserRepository.CreateAsync(orgUser);
|
||||
}
|
||||
|
||||
await SendInviteAsync(orgUser, organization);
|
||||
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Invited);
|
||||
orgUsers.Add(orgUser);
|
||||
orgUserInvitedCount++;
|
||||
}
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.InvitedUsers, organization)
|
||||
{
|
||||
Users = orgUserInvitedCount
|
||||
});
|
||||
|
||||
return orgUsers;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId,
|
||||
IEnumerable<Guid> organizationUsersId)
|
||||
{
|
||||
@ -1349,7 +1384,7 @@ namespace Bit.Core.Services
|
||||
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
Guid confirmingUserId, IUserService userService)
|
||||
{
|
||||
var result = await ConfirmUsersAsync(organizationId, new Dictionary<Guid, string>() {{organizationUserId, key}},
|
||||
var result = await ConfirmUsersAsync(organizationId, new Dictionary<Guid, string>() { { organizationUserId, key } },
|
||||
confirmingUserId, userService);
|
||||
|
||||
if (!result.Any())
|
||||
@ -1364,7 +1399,7 @@ namespace Bit.Core.Services
|
||||
}
|
||||
return orgUser;
|
||||
}
|
||||
|
||||
|
||||
public async Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
|
||||
Guid confirmingUserId, IUserService userService)
|
||||
{
|
||||
@ -1379,7 +1414,7 @@ namespace Bit.Core.Services
|
||||
}
|
||||
|
||||
var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList();
|
||||
|
||||
|
||||
var organization = await GetOrgById(organizationId);
|
||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds);
|
||||
@ -1417,7 +1452,7 @@ namespace Bit.Core.Services
|
||||
orgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
orgUser.Key = keys[orgUser.Id];
|
||||
orgUser.Email = null;
|
||||
|
||||
|
||||
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
|
||||
await _mailService.SendOrganizationConfirmedEmailAsync(organization.Name, user.Email);
|
||||
await DeleteAndPushUserRegistrationAsync(organizationId, user.Id);
|
||||
@ -1435,6 +1470,68 @@ namespace Bit.Core.Services
|
||||
return result;
|
||||
}
|
||||
|
||||
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd)
|
||||
{
|
||||
var failureReason = "";
|
||||
if (_globalSettings.SelfHosted)
|
||||
{
|
||||
failureReason = "Cannot autoscale on self-hosted instance.";
|
||||
return (false, failureReason);
|
||||
}
|
||||
|
||||
if (!await _currentContext.ManageUsers(organization.Id))
|
||||
{
|
||||
failureReason = "Cannot manage organization users.";
|
||||
return (false, failureReason);
|
||||
}
|
||||
// if (!await _currentContext.OrganizationOwner(organization.Id))
|
||||
// {
|
||||
// failureReason = "Only organization owners can autoscale seats.";
|
||||
// return (false, failureReason);
|
||||
// }
|
||||
|
||||
if (seatsToAdd < 1)
|
||||
{
|
||||
return (true, failureReason);
|
||||
}
|
||||
|
||||
if (organization.Seats.HasValue &&
|
||||
organization.MaxAutoscaleSeats.HasValue &&
|
||||
organization.MaxAutoscaleSeats.Value < organization.Seats.Value + seatsToAdd)
|
||||
{
|
||||
return (false, $"Cannot invite new users. Seat limit has been reached.");
|
||||
}
|
||||
|
||||
return (true, failureReason);
|
||||
}
|
||||
|
||||
private async Task AutoAddSeatsAsync(Organization organization, int seatsToAdd, DateTime? prorationDate = null)
|
||||
{
|
||||
if (seatsToAdd < 1 || !organization.Seats.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (canScale, failureMessage) = await CanScaleAsync(organization, seatsToAdd);
|
||||
if (!canScale)
|
||||
{
|
||||
throw new BadRequestException(failureMessage);
|
||||
}
|
||||
|
||||
var ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
|
||||
OrganizationUserType.Owner)).Select(u => u.Email).Distinct();
|
||||
|
||||
await AdjustSeatsAsync(organization, seatsToAdd, prorationDate, ownerEmails);
|
||||
|
||||
if (!organization.OwnersNotifiedOfAutoscaling.HasValue)
|
||||
{
|
||||
await _mailService.SendOrganizationAutoscaledEmailAsync(organization, organization.Seats.Value + seatsToAdd,
|
||||
ownerEmails);
|
||||
organization.OwnersNotifiedOfAutoscaling = DateTime.UtcNow;
|
||||
await _organizationRepository.UpsertAsync(organization);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckPolicies(ICollection<Policy> policies, Guid organizationId, User user,
|
||||
ICollection<OrganizationUser> userOrgs, IUserService userService)
|
||||
{
|
||||
@ -1474,7 +1571,7 @@ namespace Bit.Core.Services
|
||||
}
|
||||
|
||||
if (user.Type != OrganizationUserType.Owner &&
|
||||
!await HasConfirmedOwnersExceptAsync(user.OrganizationId, new[] {user.Id}))
|
||||
!await HasConfirmedOwnersExceptAsync(user.OrganizationId, new[] { user.Id }))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
}
|
||||
@ -1507,7 +1604,7 @@ namespace Bit.Core.Services
|
||||
throw new BadRequestException("Only owners can delete other owners.");
|
||||
}
|
||||
|
||||
if (!await HasConfirmedOwnersExceptAsync(organizationId, new[] {organizationUserId}))
|
||||
if (!await HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
}
|
||||
@ -1529,7 +1626,7 @@ namespace Bit.Core.Services
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!await HasConfirmedOwnersExceptAsync(organizationId, new[] {orgUser.Id}))
|
||||
if (!await HasConfirmedOwnersExceptAsync(organizationId, new[] { orgUser.Id }))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
}
|
||||
@ -1630,12 +1727,12 @@ namespace Bit.Core.Services
|
||||
{
|
||||
// Org User must be the same as the calling user and the organization ID associated with the user must match passed org ID
|
||||
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, organizationUserId);
|
||||
if (!callingUserId.HasValue || orgUser == null || orgUser.UserId != callingUserId.Value ||
|
||||
if (!callingUserId.HasValue || orgUser == null || orgUser.UserId != callingUserId.Value ||
|
||||
orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
throw new BadRequestException("User not valid.");
|
||||
}
|
||||
|
||||
|
||||
// Make sure the organization has the ability to use password reset
|
||||
var org = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
if (org == null || !org.UseResetPassword)
|
||||
@ -1650,7 +1747,7 @@ namespace Bit.Core.Services
|
||||
{
|
||||
throw new BadRequestException("Organization does not have the password reset policy enabled.");
|
||||
}
|
||||
|
||||
|
||||
// Block the user from withdrawal if auto enrollment is enabled
|
||||
if (resetPasswordKey == null && resetPasswordPolicy.Data != null)
|
||||
{
|
||||
@ -1661,7 +1758,7 @@ namespace Bit.Core.Services
|
||||
throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from Password Reset.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
orgUser.ResetPasswordKey = resetPasswordKey;
|
||||
await _organizationUserRepository.ReplaceAsync(orgUser);
|
||||
await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ?
|
||||
@ -1702,7 +1799,8 @@ namespace Bit.Core.Services
|
||||
AccessAll = accessAll,
|
||||
Collections = collections,
|
||||
};
|
||||
var results = await InviteUserAsync(organizationId, invitingUserId, externalId, invite);
|
||||
var results = await InviteUsersAsync(organizationId, invitingUserId,
|
||||
new (OrganizationUserInvite, string)[] { (invite, externalId) });
|
||||
var result = results.FirstOrDefault();
|
||||
if (result == null)
|
||||
{
|
||||
@ -1797,11 +1895,6 @@ namespace Bit.Core.Services
|
||||
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
|
||||
}
|
||||
|
||||
if (!enoughSeatsAvailable)
|
||||
{
|
||||
throw new BadRequestException($"Organization does not have enough seats available. Need {usersToAdd.Count} but {seatsAvailable} available.");
|
||||
}
|
||||
|
||||
var userInvites = new List<(OrganizationUserInvite, string)>();
|
||||
foreach (var user in newUsers)
|
||||
{
|
||||
@ -1891,7 +1984,7 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.DirectorySynced, organization));
|
||||
}
|
||||
@ -1934,7 +2027,7 @@ namespace Bit.Core.Services
|
||||
org.PublicKey = publicKey;
|
||||
org.PrivateKey = privateKey;
|
||||
await UpdateAsync(org);
|
||||
|
||||
|
||||
return org;
|
||||
}
|
||||
|
||||
|
@ -718,6 +718,12 @@ namespace Bit.Core.Services
|
||||
ProrationDate = prorationDate,
|
||||
};
|
||||
|
||||
if (!subscriptionUpdate.UpdateNeeded(sub))
|
||||
{
|
||||
// No need to update subscription, quantity matches
|
||||
return null;
|
||||
}
|
||||
|
||||
var customer = await new CustomerService().GetAsync(sub.CustomerId);
|
||||
if (!string.IsNullOrWhiteSpace(customer?.Address?.Country)
|
||||
&& !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode))
|
||||
|
@ -35,6 +35,16 @@ namespace Bit.Core.Services
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
|
@ -1,8 +1,14 @@
|
||||
using static Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Core.Settings
|
||||
{
|
||||
public interface IGlobalSettings
|
||||
{
|
||||
// This interface exists for testing. Add settings here as needed for testing
|
||||
bool SelfHosted { get; set; }
|
||||
string LicenseDirectory { get; set; }
|
||||
int OrganizationInviteExpirationHours { get; set; }
|
||||
InstallationSettings Installation { get; set; }
|
||||
IFileStorageSettings Attachment { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -643,7 +643,7 @@ namespace Bit.Core.Utilities
|
||||
}
|
||||
|
||||
public static bool UserInviteTokenIsValid(IDataProtector protector, string token, string userEmail,
|
||||
Guid orgUserId, GlobalSettings globalSettings)
|
||||
Guid orgUserId, IGlobalSettings globalSettings)
|
||||
{
|
||||
return TokenIsValid("OrganizationUserInvite", protector, token, userEmail, orgUserId,
|
||||
globalSettings.OrganizationInviteExpirationHours);
|
||||
|
@ -115,7 +115,9 @@ namespace Bit.Core.Utilities
|
||||
MaxUsers = 2,
|
||||
|
||||
UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to
|
||||
DisplaySortOrder = -1
|
||||
DisplaySortOrder = -1,
|
||||
|
||||
AllowSeatAutoscale = false,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -145,7 +147,9 @@ namespace Bit.Core.Utilities
|
||||
StripePremiumAccessPlanId = "personal-org-premium-access-annually",
|
||||
BasePrice = 12,
|
||||
AdditionalStoragePricePerGb = 4,
|
||||
PremiumAccessOptionPrice = 40
|
||||
PremiumAccessOptionPrice = 40,
|
||||
|
||||
AllowSeatAutoscale = false,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -174,7 +178,9 @@ namespace Bit.Core.Utilities
|
||||
StripeStoragePlanId = "storage-gb-annually",
|
||||
BasePrice = 60,
|
||||
SeatPrice = 24,
|
||||
AdditionalStoragePricePerGb = 4
|
||||
AdditionalStoragePricePerGb = 4,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -202,7 +208,9 @@ namespace Bit.Core.Utilities
|
||||
StripeStoragePlanId = "storage-gb-monthly",
|
||||
BasePrice = 8,
|
||||
SeatPrice = 2.5M,
|
||||
AdditionalStoragePricePerGb = 0.5M
|
||||
AdditionalStoragePricePerGb = 0.5M,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -239,7 +247,9 @@ namespace Bit.Core.Utilities
|
||||
StripeStoragePlanId = "storage-gb-annually",
|
||||
BasePrice = 0,
|
||||
SeatPrice = 36,
|
||||
AdditionalStoragePricePerGb = 4
|
||||
AdditionalStoragePricePerGb = 4,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -275,7 +285,9 @@ namespace Bit.Core.Utilities
|
||||
StripeStoragePlanId = "storage-gb-monthly",
|
||||
BasePrice = 0,
|
||||
SeatPrice = 4M,
|
||||
AdditionalStoragePricePerGb = 0.5M
|
||||
AdditionalStoragePricePerGb = 0.5M,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -302,7 +314,9 @@ namespace Bit.Core.Utilities
|
||||
StripePlanId = "2020-families-org-annually",
|
||||
StripeStoragePlanId = "storage-gb-annually",
|
||||
BasePrice = 40,
|
||||
AdditionalStoragePricePerGb = 4
|
||||
AdditionalStoragePricePerGb = 4,
|
||||
|
||||
AllowSeatAutoscale = false,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -334,7 +348,9 @@ namespace Bit.Core.Utilities
|
||||
StripeSeatPlanId = "2020-teams-org-seat-annually",
|
||||
StripeStoragePlanId = "storage-gb-annually",
|
||||
SeatPrice = 36,
|
||||
AdditionalStoragePricePerGb = 4
|
||||
AdditionalStoragePricePerGb = 4,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -365,7 +381,9 @@ namespace Bit.Core.Utilities
|
||||
StripeSeatPlanId = "2020-teams-org-seat-monthly",
|
||||
StripeStoragePlanId = "storage-gb-monthly",
|
||||
SeatPrice = 4,
|
||||
AdditionalStoragePricePerGb = 0.5M
|
||||
AdditionalStoragePricePerGb = 0.5M,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -402,7 +420,9 @@ namespace Bit.Core.Utilities
|
||||
StripeStoragePlanId = "storage-gb-annually",
|
||||
BasePrice = 0,
|
||||
SeatPrice = 60,
|
||||
AdditionalStoragePricePerGb = 4
|
||||
AdditionalStoragePricePerGb = 4,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
@ -438,11 +458,15 @@ namespace Bit.Core.Utilities
|
||||
StripeStoragePlanId = "storage-gb-monthly",
|
||||
BasePrice = 0,
|
||||
SeatPrice = 6,
|
||||
AdditionalStoragePricePerGb = 0.5M
|
||||
AdditionalStoragePricePerGb = 0.5M,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
new Plan
|
||||
{
|
||||
Type = PlanType.Custom
|
||||
Type = PlanType.Custom,
|
||||
|
||||
AllowSeatAutoscale = true,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -38,7 +38,9 @@
|
||||
@TwoFactorProviders NVARCHAR(MAX),
|
||||
@ExpirationDate DATETIME2(7),
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7)
|
||||
@RevisionDate DATETIME2(7),
|
||||
@OwnersNotifiedOfAutoscaling DATETIME2(7),
|
||||
@MaxAutoscaleSeats INT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@ -84,7 +86,9 @@ BEGIN
|
||||
[TwoFactorProviders],
|
||||
[ExpirationDate],
|
||||
[CreationDate],
|
||||
[RevisionDate]
|
||||
[RevisionDate],
|
||||
[OwnersNotifiedOfAutoscaling],
|
||||
[MaxAutoscaleSeats]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@ -127,6 +131,8 @@ BEGIN
|
||||
@TwoFactorProviders,
|
||||
@ExpirationDate,
|
||||
@CreationDate,
|
||||
@RevisionDate
|
||||
@RevisionDate,
|
||||
@OwnersNotifiedOfAutoscaling,
|
||||
@MaxAutoscaleSeats
|
||||
)
|
||||
END
|
||||
|
@ -38,7 +38,9 @@
|
||||
@TwoFactorProviders NVARCHAR(MAX),
|
||||
@ExpirationDate DATETIME2(7),
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7)
|
||||
@RevisionDate DATETIME2(7),
|
||||
@OwnersNotifiedOfAutoscaling DATETIME2(7),
|
||||
@MaxAutoscaleSeats INT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@ -84,7 +86,9 @@ BEGIN
|
||||
[TwoFactorProviders] = @TwoFactorProviders,
|
||||
[ExpirationDate] = @ExpirationDate,
|
||||
[CreationDate] = @CreationDate,
|
||||
[RevisionDate] = @RevisionDate
|
||||
[RevisionDate] = @RevisionDate,
|
||||
[OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling,
|
||||
[MaxAutoscaleSeats] = @MaxAutoscaleSeats
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
|
@ -1,44 +1,46 @@
|
||||
CREATE TABLE [dbo].[Organization] (
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[Identifier] NVARCHAR (50) NULL,
|
||||
[Name] NVARCHAR (50) NOT NULL,
|
||||
[BusinessName] NVARCHAR (50) NULL,
|
||||
[BusinessAddress1] NVARCHAR (50) NULL,
|
||||
[BusinessAddress2] NVARCHAR (50) NULL,
|
||||
[BusinessAddress3] NVARCHAR (50) NULL,
|
||||
[BusinessCountry] VARCHAR (2) NULL,
|
||||
[BusinessTaxNumber] NVARCHAR (30) NULL,
|
||||
[BillingEmail] NVARCHAR (256) NOT NULL,
|
||||
[Plan] NVARCHAR (50) NOT NULL,
|
||||
[PlanType] TINYINT NOT NULL,
|
||||
[Seats] INT NULL,
|
||||
[MaxCollections] SMALLINT NULL,
|
||||
[UsePolicies] BIT NOT NULL,
|
||||
[UseSso] BIT NOT NULL,
|
||||
[UseGroups] BIT NOT NULL,
|
||||
[UseDirectory] BIT NOT NULL,
|
||||
[UseEvents] BIT NOT NULL,
|
||||
[UseTotp] BIT NOT NULL,
|
||||
[Use2fa] BIT NOT NULL,
|
||||
[UseApi] BIT NOT NULL,
|
||||
[UseResetPassword] BIT NOT NULL,
|
||||
[SelfHost] BIT NOT NULL,
|
||||
[UsersGetPremium] BIT NOT NULL,
|
||||
[Storage] BIGINT NULL,
|
||||
[MaxStorageGb] SMALLINT NULL,
|
||||
[Gateway] TINYINT NULL,
|
||||
[GatewayCustomerId] VARCHAR (50) NULL,
|
||||
[GatewaySubscriptionId] VARCHAR (50) NULL,
|
||||
[ReferenceData] NVARCHAR (MAX) NULL,
|
||||
[Enabled] BIT NOT NULL,
|
||||
[LicenseKey] VARCHAR (100) NULL,
|
||||
[ApiKey] VARCHAR (30) NOT NULL,
|
||||
[PublicKey] VARCHAR (MAX) NULL,
|
||||
[PrivateKey] VARCHAR (MAX) NULL,
|
||||
[TwoFactorProviders] NVARCHAR (MAX) NULL,
|
||||
[ExpirationDate] DATETIME2 (7) NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
[RevisionDate] DATETIME2 (7) NOT NULL,
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[Identifier] NVARCHAR (50) NULL,
|
||||
[Name] NVARCHAR (50) NOT NULL,
|
||||
[BusinessName] NVARCHAR (50) NULL,
|
||||
[BusinessAddress1] NVARCHAR (50) NULL,
|
||||
[BusinessAddress2] NVARCHAR (50) NULL,
|
||||
[BusinessAddress3] NVARCHAR (50) NULL,
|
||||
[BusinessCountry] VARCHAR (2) NULL,
|
||||
[BusinessTaxNumber] NVARCHAR (30) NULL,
|
||||
[BillingEmail] NVARCHAR (256) NOT NULL,
|
||||
[Plan] NVARCHAR (50) NOT NULL,
|
||||
[PlanType] TINYINT NOT NULL,
|
||||
[Seats] INT NULL,
|
||||
[MaxCollections] SMALLINT NULL,
|
||||
[UsePolicies] BIT NOT NULL,
|
||||
[UseSso] BIT NOT NULL,
|
||||
[UseGroups] BIT NOT NULL,
|
||||
[UseDirectory] BIT NOT NULL,
|
||||
[UseEvents] BIT NOT NULL,
|
||||
[UseTotp] BIT NOT NULL,
|
||||
[Use2fa] BIT NOT NULL,
|
||||
[UseApi] BIT NOT NULL,
|
||||
[UseResetPassword] BIT NOT NULL,
|
||||
[SelfHost] BIT NOT NULL,
|
||||
[UsersGetPremium] BIT NOT NULL,
|
||||
[Storage] BIGINT NULL,
|
||||
[MaxStorageGb] SMALLINT NULL,
|
||||
[Gateway] TINYINT NULL,
|
||||
[GatewayCustomerId] VARCHAR (50) NULL,
|
||||
[GatewaySubscriptionId] VARCHAR (50) NULL,
|
||||
[ReferenceData] NVARCHAR (MAX) NULL,
|
||||
[Enabled] BIT NOT NULL,
|
||||
[LicenseKey] VARCHAR (100) NULL,
|
||||
[ApiKey] VARCHAR (30) NOT NULL,
|
||||
[PublicKey] VARCHAR (MAX) NULL,
|
||||
[PrivateKey] VARCHAR (MAX) NULL,
|
||||
[TwoFactorProviders] NVARCHAR (MAX) NULL,
|
||||
[ExpirationDate] DATETIME2 (7) NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
[RevisionDate] DATETIME2 (7) NOT NULL,
|
||||
[OwnersNotifiedOfAutoscaling] DATETIME2(7) NULL,
|
||||
[MaxAutoscaleSeats] INT NULL,
|
||||
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
|
||||
);
|
||||
|
||||
|
Reference in New Issue
Block a user