1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-25 12:45:18 +01:00

[AC-1081] Merge feature/billing-obfuscation (#2665)

* [EC-1014] Create Organization Status (Pending/Created) (#2610)

* [EC-427] Add columns 'Type' and 'BillingPhone' to Provider table

* [EC-427] Provider table Type and BillingPhone MySql migrations

* [EC-427] Provider table Type and BillingPhone Postgres migrations

* [EC-427] Add mysql migration script

* [EC-427] Add mysql migration script

* [EC-427] Updated Provider sql script to include default column value

* [EC-427] Removed default value from Provider.Type column

* [EC-427] Changed migration script to include a default value constraint instead of updating the null type

* [EC-427] Updated Sql project Provider table script

* [EC-427] Changed migration script to use 'Create OR Alter' for views and sprocs

* [EC-427] Added default values for 'BillingPhone' and 'Type' fields on sprocs [dbo].[Provider_Create] and [dbo].[Provider_Update]

* [EC-427] Adjusting metadata in migration script

* [EC-427] Updated Provider sprocs SQL script files

* [EC-427] Fixed migration script

* [EC-427] Added sqlite migration

* [EC-427] Add missing Provider_Update sproc default value

* [EC-427] Added missing GO action to migration script

* [EC-428] Redirect to Edit after creating Provider

* Revert "[EC-428] Redirect to Edit after creating Provider"

This reverts commit 6347bca1ed.

* [EC-1014] Create OrganizationStatusType and add Status column to Organizations table

* [EC-1014] Added EF migrations

* [EC-1014] dotnet format

* [EC-1014] Changed Organization.Status from SMALLINT to TINYINT

* [EC-1014] Set Organization.Status default value = 1

* [EC-1014] Setting Organization.Status default value as 1

* [EC-459 / EC-428] Admin panel: Add Provider Type to list and creation flow (#2593)

* [EC-427] Add columns 'Type' and 'BillingPhone' to Provider table

* [EC-427] Provider table Type and BillingPhone MySql migrations

* [EC-427] Provider table Type and BillingPhone Postgres migrations

* [EC-427] Add mysql migration script

* [EC-427] Add mysql migration script

* [EC-427] Updated Provider sql script to include default column value

* [EC-427] Removed default value from Provider.Type column

* [EC-427] Changed migration script to include a default value constraint instead of updating the null type

* [EC-427] Updated Sql project Provider table script

* [EC-427] Changed migration script to use 'Create OR Alter' for views and sprocs

* [EC-427] Added default values for 'BillingPhone' and 'Type' fields on sprocs [dbo].[Provider_Create] and [dbo].[Provider_Update]

* [EC-427] Adjusting metadata in migration script

* [EC-427] Updated Provider sprocs SQL script files

* [EC-427] Fixed migration script

* [EC-427] Added sqlite migration

* [EC-427] Add missing Provider_Update sproc default value

* [EC-427] Added missing GO action to migration script

* [EC-459] Added Type column to Providers list

* [EC-428] Added Type, BusinessName and BillingEmail to CreateProviderModel

* [EC-428] Updated Create Provider view to include new fields

* [EC-428] Updated ProviderService to not create a ProviderUser for the type Reseller

* [EC-428] Added custom validation for Provider fields depending on selected Type

* [EC-428] Redirect to Edit after creating Provider

* [EC-428] Setting Provider status as Created for Resellers

* [EC-428] Redirect on Provider creation depending if self host server

* [EC-428] Split ProviderService.CreateAsync into two methods: CreateMspAsync and CreateResellerAsync

* [EC-428] Created ICreateProviderCommand and added service for injection on Admin.Startup

* [EC-428] Modified Provider views to use DisplayName attribute values

* [EC-428] Moved ICreateProviderCommand to Core project

* [EC-428] Adding ICreateProviderCommand injection next to IProviderService

* [EC-428] Moved CreateProviderCommand to Commercial.Core project

* [EC-459] Added Type column to Providers list

* [EC-428] Added Type, BusinessName and BillingEmail to CreateProviderModel

* [EC-428] Updated Create Provider view to include new fields

* [EC-428] Updated ProviderService to not create a ProviderUser for the type Reseller

* [EC-428] Added custom validation for Provider fields depending on selected Type

* [EC-428] Redirect to Edit after creating Provider

* [EC-428] Setting Provider status as Created for Resellers

* [EC-428] Redirect on Provider creation depending if self host server

* [EC-428] Split ProviderService.CreateAsync into two methods: CreateMspAsync and CreateResellerAsync

* [EC-428] Created ICreateProviderCommand and added service for injection on Admin.Startup

* [EC-428] Modified Provider views to use DisplayName attribute values

* [EC-428] Moved ICreateProviderCommand to Core project

* [EC-428] Adding ICreateProviderCommand injection next to IProviderService

* [EC-428] Moved CreateProviderCommand to Commercial.Core project

* [EC-428] Moved CreateProviderCommand to namespace Bit.Commercial.Core.Providers

* [EC-429] Provider details screen updated with Type, BillingPhone and Organization details (#2666)

* [EC-430] Admin portal: Update organization information screen (#2672)

* [EC-430] Added ProviderOrganizationProviderDetailsView to get Provider details for an Organization

* [EC-430] Added Provider information to Organization Edit/View on Admin panel

* [EC-430] Remove "Add to Reseller" button

* [EC-430] Removed unused property OrganizationEditModel.ClientOwnerEmail

* [EC-430] Replaced IProviderOrganizationRepository.GetProviderDetailsByOrganizationAsync with IProviderRepository.GetByOrganizationIdAsync

* [EC-430] Deleted ProviderOrganizationProviderDetails and ProviderOrganizationProviderDetailsReadByOrganizationIdQuery

* [EC-429] Only show Create/Add Existing Organization buttons for Reseller providers (#2723)

* [EC-432] Add existing Organizations to Provider (#2683)

* [EC-432] Added ProviderOrganizationUnassignedOrganizationDetails_Search stored procedure

* [EC-432] Added IProviderOrganizationRepository.SearchAsync

* [EC-432] Created controller ProviderOrganizationsController to assign Organizations to a Provider

* [EC-432] Filter existing organizations by plans Enterprise or Team

* [EC-432] Existing Organization name links to edit page

* [EC-432] EF filtering out existing organizations by plan type enterprise or teams

* [EC-432] Creating multiple ProviderOrganization records

* [EC-432] Added ProviderOrganizationUnassignedOrganizationDetails_Search stored procedure

* [EC-432] Added IProviderOrganizationRepository.SearchAsync

* [EC-432] Created controller ProviderOrganizationsController to assign Organizations to a Provider

* [EC-432] Filter existing organizations by plans Enterprise or Team

* [EC-432] Existing Organization name links to edit page

* [EC-432] EF filtering out existing organizations by plan type enterprise or teams

* [EC-432] Creating multiple ProviderOrganization records

* [EC-432] Renamed migration script and added missing sproc

* [EC-432] Saving multiple events for the created ProviderOrganizations

* [EC-432] Included unit testing for ProviderService.AddOrganizations and EventService.LogProviderOrganizationEventsAsync

* [EC-432] Removed async from NoopEventService.LogProviderOrganizationEventsAsync

* [EC-432] Remove unused dependency setup in ProviderServiceTests.AddOrganizations_Success

* [EC-432] Renamed AddOrganizations to AddOrganizationsToReseller and removed addingUserId and key arguments

* [EC-432] Added DisplayName attributes to ProviderOrganizationViewModel and used them in the view

* [EC-432] Reverted changes to input fields

* [EC-432] Moved unassigned organizations search to Organizations repo

* [EC-432] Moved AddExistingOrganization action to ProvidersController

* [EC-432] dotnet format

* [EC-432] Fixed unit test issues

* [EC-432] Removed unnecessary Html.DisplayNameFor for labels

* [EC-432] Renamed OrganizationSearchViewModel to OrganizationUnassignedToProviderSearchViewModel

* [EC-432] Modified IEventService.LogProviderOrganizationEventsAsync to receive an IEnumerable as parameter

* [EC-432] Updated IProviderOrganizationRepository and replaced CreateWithManyOrganizations method with CreateManyAsync

* [EC-432] Deleted ProviderOrganization_CreateWithManyOrganizations

* [AC-432] Simplified Organization_UnassignedToProviderSearch query

* [AC-432] Removed unnecessary setup

* [EC-432] Checking if stored procedure exists before creating

* [EC-432] Renamed migration file to recent date

* [EC-435] Admin Portal: Add new Organization creation flow UI (#2707)

* [EC-435] Created _OrganizationForm partial view. Added actions for creating an Organization assigned to a provider

* [EC-435] Remove logic for creating an organization

* [EC-435] Created partial view _OrganizationFormScripts

* [EC-435] Remove unused ReferenceEventType

* [EC-435] Added TODO comment on Organization Create

* [EC-435] Checking if Provider type is Reseller on creating new assigned organization

* [EC-435] Setting the Organization plan type as TeamsMonthly by default when adding to a provider

* [EC-435] Removing unused buttons

* [EC-435] Switched hidden fields to form submit route value

* [EC-435] Moved _OrganizationForm and _OrganizationFormScripts to Shared folder

* [EC-435] Moved Create organization actions from OrganizationsController to ProvidersController

* [EC-435] Fixing bug on saving Organization that would have BillingEmail as null

* [EC-435] Added null check to Provider

* [EC-435] Moved trial buttons script logic to Edit view

* [AC-431] Add new organization invite process (#2737)

* [EC-435] Created _OrganizationForm partial view. Added actions for creating an Organization assigned to a provider

* [EC-435] Remove logic for creating an organization

* [EC-435] Created partial view _OrganizationFormScripts

* [EC-435] Remove unused ReferenceEventType

* [EC-435] Added TODO comment on Organization Create

* [EC-435] Checking if Provider type is Reseller on creating new assigned organization

* [EC-435] Setting the Organization plan type as TeamsMonthly by default when adding to a provider

* [EC-435] Removing unused buttons

* [EC-435] Switched hidden fields to form submit route value

* [EC-435] Moved _OrganizationForm and _OrganizationFormScripts to Shared folder

* [EC-435] Moved Create organization actions from OrganizationsController to ProvidersController

* [AC-431] Added new ReferenceEventType OrganizationCreatedByAdmin

* [AC-431] Added method IOrganizationService.CreateOrganization

* [AC-431] Creating new Organization with Pending status and assigning to Provider

* [AC-431] Added method to IMailService to send invitation to initialize org

* [AC-431] Added methods CreatePendingOrganization and InitPendingOrganization to IOrganizationService

* [AC-431] Org invite includes initOrganization parameter

* [AC-431] Modified existing Accept organization user action to initialize org

* [AC-431] Updated ProvidersController method name

* [AC-431] Created OrganizationUserInitInvitedViewModel to link to 'accept-init-organization' url

* [AC-431] Added action AcceptInit to OrganizationUsersController

* [AC-431] Resend owner invite

* [AC-431] dotnet format

* [AC-431] Removed unused parameter 'addingUserId' from IProviderService.AddOrganization

* [AC-431] Removed setting manual values for CreationDate and RevisionDate

* [AC-431] Updated OrganizationService.InitPendingOrganization to throw exceptions when the Organization does not meet the required criteria

* [AC-431] Modified OrganizationUserInitInvitedViewModel to inherit properties from OrganizationUserInvitedViewModel

* [AC-431] Removed unecessary parameter check

* [AC-431] Moved method description to IOrganizationService.InitPendingOrganization

* [AC-431] Moved ApplicationCacheService.UpsertOrganizationAbilityAsync and ReferenceEventService.RaiseEventAsync to OrganizationService

* [AC-431] Creating collection after creating organization

* [EC-435] Fixing bug on saving Organization that would have BillingEmail as null

* [AC-431] Deleted OrganizationUserInitInvitedViewModel and added parameter InitOrganization to OrganizationUserInvitedViewModel.cs

* [AC-431] Checking if the user has any existing SingleOrg policies before initializing an Org

* [AC-431] Remove commented code

* [EC-435] Added null check to Provider

* [EC-435] Moved trial buttons script logic to Edit view

* [AC-431] Added EncryptedString attribute to OrganizationUserAcceptInitRequestModel.CollectionName

* [AC-431] Refactored plan check condition

* [AC-431] Remove duplicate _applicationCacheService.UpsertOrganizationAbilityAsync call

* [AC-431] Removed IMailService.SendOrganizationInitInviteEmailAsync

* [AC-431] Added parameters ClaimsPrincipal and IUserService to IOrganizationService.CreatePendingOrganization

* [AC-434] Hide Billing screen for Reseller clients (#2783)

* [AC-434] Added ProviderType to ProfileOrganizationResponseModel

* [AC-434] Migration script

* [AC-434] Fixed indentation on migration script

* [AC-434] Hiding sensitive subscription data if the user does not have permissions

* [AC-434] Fixed missing dependency in unit test

* [AC-434] Altered BillingSubscription.Amount and BillingSubscriptionUpcomingInvoice.Amount to nullable

* [AC-434] Replaced CurrentContext.ManageBilling with ViewBillingHistory, ViewSubscription, EditSubscription and EditPaymentMethods

* [AC-434] Reverted change on BillingSubscription.Amount and now setting Subscription.Items = null when User does not have permission

* [AC-434] Added ProviderOrganizationProviderDetails_ReadByUserId

* [AC-434] Added IProviderOrganizationRepository.GetManyByUserAsync

* [AC-434] Added CurrentContext.GetOrganizationProviderDetails

* [AC-434] Remove unneeded join Organization table

* [AC-1255] Search Existing Organizations by partial Email (#2830)

* [AC-1255] Added email search field input validation

* [AC-1255] Reverted added email pattern

* [AC-1255] Modified Organization search by Email to search using substring

* [AC-1276] Displaying an Organizations pending owners if the Organization is in a Pending status (#2834)

* [AC-432] Checking that an existing Organization is not assigned to any Provider before being assigned (#2840)

* [AC-432] Checking if any of the selected Organizations is already assigned to a Provider

* [AC-432] Changed ProviderOrganization_ReadByOrganizationIds to only get count

* [AC-432] Replaced IProviderOrganizationRepository.GetCountByOrganizationIdsAsync with call to IProviderOrganizationRepository.GetByOrganizationId

* [AC-432] undo new line

* [AC-432] Fixed unit test

* Revert "[AC-432] Replaced IProviderOrganizationRepository.GetCountByOrganizationIdsAsync with call to IProviderOrganizationRepository.GetByOrganizationId"

This reverts commit ee6e095e88.

# Conflicts:
#	util/Migrator/DbScripts/2023-03-22_00_ProviderAddExistingOrganizations.sql

* [AC-432] Created new migration script for ProviderOrganization_ReadCountByOrganizationIds
This commit is contained in:
Rui Tomé 2023-04-14 11:13:16 +01:00 committed by GitHub
parent 03c740dffb
commit f5a8cf5c9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
96 changed files with 8689 additions and 431 deletions

View File

@ -0,0 +1,62 @@
using Bit.Core.Entities.Provider;
using Bit.Core.Enums.Provider;
using Bit.Core.Exceptions;
using Bit.Core.Providers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Commercial.Core.Providers;
public class CreateProviderCommand : ICreateProviderCommand
{
private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderService _providerService;
private readonly IUserRepository _userRepository;
public CreateProviderCommand(
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderService providerService,
IUserRepository userRepository)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
_providerService = providerService;
_userRepository = userRepository;
}
public async Task CreateMspAsync(Provider provider, string ownerEmail)
{
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null)
{
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
}
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
var providerUser = new ProviderUser
{
ProviderId = provider.Id,
UserId = owner.Id,
Type = ProviderUserType.ProviderAdmin,
Status = ProviderUserStatusType.Confirmed,
};
await _providerUserRepository.CreateAsync(providerUser);
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task CreateResellerAsync(Provider provider)
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
}
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
{
provider.Status = status;
provider.Enabled = true;
provider.UseEvents = true;
await _providerRepository.CreateAsync(provider);
}
}

View File

@ -53,33 +53,6 @@ public class ProviderService : IProviderService
_currentContext = currentContext;
}
public async Task CreateAsync(string ownerEmail)
{
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null)
{
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
}
var provider = new Provider
{
Status = ProviderStatusType.Pending,
Enabled = true,
UseEvents = true,
};
await _providerRepository.CreateAsync(provider);
var providerUser = new ProviderUser
{
ProviderId = provider.Id,
UserId = owner.Id,
Type = ProviderUserType.ProviderAdmin,
Status = ProviderUserStatusType.Confirmed,
};
await _providerUserRepository.CreateAsync(providerUser);
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
{
var owner = await _userService.GetUserByIdAsync(ownerUserId);
@ -370,7 +343,7 @@ public class ProviderService : IProviderService
return result;
}
public async Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key)
public async Task AddOrganization(Guid providerId, Guid organizationId, string key)
{
var po = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
if (po != null)
@ -392,6 +365,26 @@ public class ProviderService : IProviderService
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added);
}
public async Task AddOrganizationsToReseller(Guid providerId, IEnumerable<Guid> organizationIds)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider.Type != ProviderType.Reseller)
{
throw new BadRequestException("Provider must be of type Reseller in order to assign Organizations to it.");
}
var existingProviderOrganizationsCount = await _providerOrganizationRepository.GetCountByOrganizationIdsAsync(organizationIds);
if (existingProviderOrganizationsCount > 0)
{
throw new BadRequestException("Organizations must not be assigned to any Provider.");
}
var providerOrganizationsToInsert = organizationIds.Select(orgId => new ProviderOrganization { ProviderId = providerId, OrganizationId = orgId });
var insertedProviderOrganizations = await _providerOrganizationRepository.CreateManyAsync(providerOrganizationsToInsert);
await _eventService.LogProviderOrganizationEventsAsync(insertedProviderOrganizations.Select(ipo => (ipo, EventType.ProviderOrganization_Added, (DateTime?)null)));
}
public async Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId,
OrganizationSignup organizationSignup, string clientOwnerEmail, User user)
{
@ -456,7 +449,7 @@ public class ProviderService : IProviderService
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
private async Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail)
public async Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail)
{
var token = _dataProtector.Protect($"ProviderSetupInvite {provider.Id} {ownerEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await _mailService.SendProviderSetupInviteEmailAsync(provider, token, ownerEmail);

View File

@ -1,4 +1,6 @@
using Bit.Commercial.Core.Services;
using Bit.Commercial.Core.Providers;
using Bit.Commercial.Core.Services;
using Bit.Core.Providers.Interfaces;
using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
@ -9,5 +11,6 @@ public static class ServiceCollectionExtensions
public static void AddCommercialCoreServices(this IServiceCollection services)
{
services.AddScoped<IProviderService, ProviderService>();
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
}
}

View File

@ -0,0 +1,52 @@
using Bit.Commercial.Core.Providers;
using Bit.Core.Entities;
using Bit.Core.Entities.Provider;
using Bit.Core.Enums.Provider;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Commercial.Core.Test.ProviderFeatures;
[SutProviderCustomize]
public class CreateProviderCommandTests
{
[Theory, BitAutoData]
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{
provider.Type = ProviderType.Msp;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMspAsync(provider, default));
Assert.Contains("Invalid owner.", exception.Message);
}
[Theory, BitAutoData]
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
{
provider.Type = ProviderType.Msp;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
await sutProvider.Sut.CreateMspAsync(provider, user.Email);
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
}
[Theory, BitAutoData]
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{
provider.Type = ProviderType.Reseller;
await sutProvider.Sut.CreateResellerAsync(provider);
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
}
}

View File

@ -17,6 +17,7 @@ using Microsoft.AspNetCore.DataProtection;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
using Provider = Bit.Core.Entities.Provider.Provider;
using ProviderUser = Bit.Core.Entities.Provider.ProviderUser;
namespace Bit.Commercial.Core.Test.Services;
@ -24,26 +25,6 @@ namespace Bit.Commercial.Core.Test.Services;
[SutProviderCustomize]
public class ProviderServiceTests
{
[Theory, BitAutoData]
public async Task CreateAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAsync(default));
Assert.Contains("Invalid owner.", exception.Message);
}
[Theory, BitAutoData]
public async Task CreateAsync_Success(User user, SutProvider<ProviderService> sutProvider)
{
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
await sutProvider.Sut.CreateAsync(user.Email);
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IMailService>().ReceivedWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default, default);
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
{
@ -229,6 +210,14 @@ public class ProviderServiceTests
Assert.True(result.All(r => r.Item2 == ""));
}
[Theory, BitAutoData]
public async Task SendProviderSetupInviteEmailAsync_Success(Provider provider, string email, SutProvider<ProviderService> sutProvider)
{
await sutProvider.Sut.SendProviderSetupInviteEmailAsync(provider, email);
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderSetupInviteEmailAsync(provider, Arg.Any<string>(), email);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_UserIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
{
@ -429,7 +418,7 @@ public class ProviderServiceTests
[Theory, BitAutoData]
public async Task AddOrganization_OrganizationAlreadyBelongsToAProvider_Throws(Provider provider,
Organization organization, ProviderOrganization po, User user, string key,
Organization organization, ProviderOrganization po, string key,
SutProvider<ProviderService> sutProvider)
{
po.OrganizationId = organization.Id;
@ -438,12 +427,12 @@ public class ProviderServiceTests
.Returns(po);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AddOrganization(provider.Id, organization.Id, user.Id, key));
() => sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key));
Assert.Equal("Organization already belongs to a provider.", exception.Message);
}
[Theory, BitAutoData]
public async Task AddOrganization_Success(Provider provider, Organization organization, User user, string key,
public async Task AddOrganization_Success(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
organization.PlanType = PlanType.EnterpriseAnnually;
@ -453,7 +442,7 @@ public class ProviderServiceTests
providerOrganizationRepository.GetByOrganizationId(organization.Id).ReturnsNull();
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, user.Id, key);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IEventService>()
@ -461,6 +450,49 @@ public class ProviderServiceTests
EventType.ProviderOrganization_Added);
}
[Theory, BitAutoData]
public async Task AddOrganizationsToReseller_WithResellerProvider_Success(Provider provider, ICollection<Organization> organizations, SutProvider<ProviderService> sutProvider)
{
provider.Type = ProviderType.Reseller;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
foreach (var organization in organizations)
{
organization.PlanType = PlanType.EnterpriseAnnually;
}
var organizationIds = organizations.Select(o => o.Id).ToArray();
await sutProvider.Sut.AddOrganizationsToReseller(provider.Id, organizationIds);
await providerOrganizationRepository.Received(1).CreateManyAsync(Arg.Is<IEnumerable<ProviderOrganization>>(i => i.All(po => po.ProviderId == provider.Id && organizations.Any(o => o.Id == po.OrganizationId))));
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventsAsync(
Arg.Is<IEnumerable<(ProviderOrganization, EventType, DateTime?)>>(events => events.All(e =>
e.Item1.ProviderId == provider.Id && organizationIds.Contains(e.Item1.OrganizationId) && e.Item2 == EventType.ProviderOrganization_Added)));
}
[Theory, BitAutoData]
public async Task AddOrganizationsToReseller_WithMspProvider_Throws(Provider provider, ICollection<Organization> organizations, SutProvider<ProviderService> sutProvider)
{
provider.Type = ProviderType.Msp;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
foreach (var organization in organizations)
{
organization.PlanType = PlanType.EnterpriseAnnually;
}
var organizationIds = organizations.Select(o => o.Id).ToArray();
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddOrganizationsToReseller(provider.Id, organizationIds));
Assert.Contains("Provider must be of type Reseller in order to assign Organizations to it.", exception.Message);
await providerOrganizationRepository.DidNotReceiveWithAnyArgs().CreateManyAsync(default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogProviderOrganizationEventsAsync(default);
}
[Theory, BitAutoData]
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)

View File

@ -17,6 +17,7 @@ namespace Bit.Admin.Controllers;
[Authorize]
public class OrganizationsController : Controller
{
private readonly IOrganizationService _organizationService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
@ -31,9 +32,11 @@ public class OrganizationsController : Controller
private readonly GlobalSettings _globalSettings;
private readonly IReferenceEventService _referenceEventService;
private readonly IUserService _userService;
private readonly IProviderRepository _providerRepository;
private readonly ILogger<OrganizationsController> _logger;
public OrganizationsController(
IOrganizationService organizationService,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationConnectionRepository organizationConnectionRepository,
@ -48,8 +51,10 @@ public class OrganizationsController : Controller
GlobalSettings globalSettings,
IReferenceEventService referenceEventService,
IUserService userService,
IProviderRepository providerRepository,
ILogger<OrganizationsController> logger)
{
_organizationService = organizationService;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_organizationConnectionRepository = organizationConnectionRepository;
@ -64,6 +69,7 @@ public class OrganizationsController : Controller
_globalSettings = globalSettings;
_referenceEventService = referenceEventService;
_userService = userService;
_providerRepository = providerRepository;
_logger = logger;
}
@ -103,6 +109,7 @@ public class OrganizationsController : Controller
return RedirectToAction("Index");
}
var provider = await _providerRepository.GetByOrganizationIdAsync(id);
var ciphers = await _cipherRepository.GetManyByOrganizationIdAsync(id);
var collections = await _collectionRepository.GetManyByOrganizationIdAsync(id);
IEnumerable<Group> groups = null;
@ -117,7 +124,7 @@ public class OrganizationsController : Controller
}
var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(id);
var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null;
return View(new OrganizationViewModel(organization, billingSyncConnection, users, ciphers, collections, groups, policies));
return View(new OrganizationViewModel(organization, provider, billingSyncConnection, users, ciphers, collections, groups, policies));
}
[SelfHosted(NotSelfHostedOnly = true)]
@ -129,6 +136,7 @@ public class OrganizationsController : Controller
return RedirectToAction("Index");
}
var provider = await _providerRepository.GetByOrganizationIdAsync(id);
var ciphers = await _cipherRepository.GetManyByOrganizationIdAsync(id);
var collections = await _collectionRepository.GetManyByOrganizationIdAsync(id);
IEnumerable<Group> groups = null;
@ -144,7 +152,7 @@ public class OrganizationsController : Controller
var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(id);
var billingInfo = await _paymentService.GetBillingAsync(organization);
var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null;
return View(new OrganizationEditModel(organization, users, ciphers, collections, groups, policies,
return View(new OrganizationEditModel(organization, provider, users, ciphers, collections, groups, policies,
billingInfo, billingSyncConnection, _globalSettings));
}
@ -214,4 +222,21 @@ public class OrganizationsController : Controller
return RedirectToAction("Index");
}
[HttpPost]
public async Task<IActionResult> ResendOwnerInvite(Guid id)
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
return RedirectToAction("Index");
}
var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, OrganizationUserType.Owner);
foreach (var organizationUser in organizationUsers)
{
await _organizationService.ResendInviteAsync(id, null, organizationUser.Id, true);
}
return Json(null);
}
}

View File

@ -1,5 +1,7 @@
using Bit.Admin.Models;
using Bit.Core.Entities.Provider;
using Bit.Core.Enums.Provider;
using Bit.Core.Providers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -13,23 +15,42 @@ namespace Bit.Admin.Controllers;
[SelfHosted(NotSelfHostedOnly = true)]
public class ProvidersController : Controller
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly GlobalSettings _globalSettings;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderService _providerService;
private readonly IReferenceEventService _referenceEventService;
private readonly IUserService _userService;
private readonly ICreateProviderCommand _createProviderCommand;
public ProvidersController(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderService providerService,
GlobalSettings globalSettings, IApplicationCacheService applicationCacheService)
public ProvidersController(
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderService providerService,
GlobalSettings globalSettings,
IApplicationCacheService applicationCacheService,
IReferenceEventService referenceEventService,
IUserService userService,
ICreateProviderCommand createProviderCommand)
{
_organizationRepository = organizationRepository;
_organizationService = organizationService;
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_providerService = providerService;
_globalSettings = globalSettings;
_applicationCacheService = applicationCacheService;
_referenceEventService = referenceEventService;
_userService = userService;
_createProviderCommand = createProviderCommand;
}
public async Task<IActionResult> Index(string name = null, string userEmail = null, int page = 1, int count = 25)
@ -75,9 +96,18 @@ public class ProvidersController : Controller
return View(model);
}
await _providerService.CreateAsync(model.OwnerEmail);
var provider = model.ToProvider();
switch (provider.Type)
{
case ProviderType.Msp:
await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail);
break;
case ProviderType.Reseller:
await _createProviderCommand.CreateResellerAsync(provider);
break;
}
return RedirectToAction("Index");
return RedirectToAction("Edit", new { id = provider.Id });
}
public async Task<IActionResult> View(Guid id)
@ -130,4 +160,76 @@ public class ProvidersController : Controller
TempData["InviteResentTo"] = ownerId;
return RedirectToAction("Edit", new { id = providerId });
}
[HttpGet]
public async Task<IActionResult> AddExistingOrganization(Guid id, string name = null, string ownerEmail = null, int page = 1, int count = 25)
{
if (page < 1)
{
page = 1;
}
if (count < 1)
{
count = 1;
}
var skip = (page - 1) * count;
var unassignedOrganizations = await _organizationRepository.SearchUnassignedToProviderAsync(name, ownerEmail, skip, count);
var viewModel = new OrganizationUnassignedToProviderSearchViewModel
{
OrganizationName = string.IsNullOrWhiteSpace(name) ? null : name,
OrganizationOwnerEmail = string.IsNullOrWhiteSpace(ownerEmail) ? null : ownerEmail,
Page = page,
Count = count,
Items = unassignedOrganizations.Select(uo => new OrganizationSelectableViewModel
{
Id = uo.Id,
Name = uo.Name,
PlanType = uo.PlanType
}).ToList()
};
return View(viewModel);
}
[HttpPost]
public async Task<IActionResult> AddExistingOrganization(Guid id, OrganizationUnassignedToProviderSearchViewModel model)
{
var organizationIds = model.Items.Where(o => o.Selected).Select(o => o.Id).ToArray();
if (organizationIds.Any())
{
await _providerService.AddOrganizationsToReseller(id, organizationIds);
}
return RedirectToAction("Edit", "Providers", new { id = id });
}
[HttpGet]
public async Task<IActionResult> CreateOrganization(Guid providerId)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider is not { Type: ProviderType.Reseller })
{
return RedirectToAction("Index");
}
return View(new OrganizationEditModel(provider));
}
[HttpPost]
public async Task<IActionResult> CreateOrganization(Guid providerId, OrganizationEditModel model)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider is not { Type: ProviderType.Reseller })
{
return RedirectToAction("Index");
}
var organization = model.CreateOrganization(provider);
await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
await _providerService.AddOrganization(providerId, organization.Id, null);
return RedirectToAction("Edit", "Providers", new { id = providerId });
}
}

View File

@ -1,12 +1,59 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities.Provider;
using Bit.Core.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.Models;
public class CreateProviderModel
public class CreateProviderModel : IValidatableObject
{
public CreateProviderModel() { }
[Display(Name = "Provider Type")]
public ProviderType Type { get; set; }
[Display(Name = "Owner Email")]
[Required]
public string OwnerEmail { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
[Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; }
public virtual Provider ToProvider()
{
return new Provider()
{
Type = Type,
BusinessName = BusinessName,
BillingEmail = BillingEmail?.ToLowerInvariant().Trim()
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
{
case ProviderType.Msp:
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName();
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
break;
case ProviderType.Reseller:
if (string.IsNullOrWhiteSpace(BusinessName))
{
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName();
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BillingEmail))
{
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName();
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
break;
}
}
}

View File

@ -1,11 +1,14 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Entities.Provider;
using Bit.Core.Enums;
using Bit.Core.Enums.Provider;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.Models;
@ -13,18 +16,26 @@ public class OrganizationEditModel : OrganizationViewModel
{
public OrganizationEditModel() { }
public OrganizationEditModel(Organization org, IEnumerable<OrganizationUserUserDetails> orgUsers,
public OrganizationEditModel(Provider provider)
{
Provider = provider;
BillingEmail = provider.Type == ProviderType.Reseller ? provider.BillingEmail : string.Empty;
PlanType = Core.Enums.PlanType.TeamsMonthly;
Plan = Core.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName();
}
public OrganizationEditModel(Organization org, Provider provider, IEnumerable<OrganizationUserUserDetails> orgUsers,
IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections, IEnumerable<Group> groups,
IEnumerable<Policy> policies, BillingInfo billingInfo, IEnumerable<OrganizationConnection> connections,
GlobalSettings globalSettings)
: base(org, connections, orgUsers, ciphers, collections, groups, policies)
: base(org, provider, connections, orgUsers, ciphers, collections, groups, policies)
{
BillingInfo = billingInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
Name = org.Name;
BusinessName = org.BusinessName;
BillingEmail = org.BillingEmail;
BillingEmail = provider?.Type == ProviderType.Reseller ? provider.BillingEmail : org.BillingEmail;
PlanType = org.PlanType;
Plan = org.Plan;
Seats = org.Seats;
@ -60,7 +71,7 @@ public class OrganizationEditModel : OrganizationViewModel
public string BraintreeMerchantId { get; set; }
[Required]
[Display(Name = "Name")]
[Display(Name = "Organization Name")]
public string Name { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
@ -124,6 +135,13 @@ public class OrganizationEditModel : OrganizationViewModel
public DateTime? ExpirationDate { get; set; }
public bool SalesAssistedTrialStarted { get; set; }
public Organization CreateOrganization(Provider provider)
{
BillingEmail = provider.BillingEmail;
return ToOrganization(new Organization());
}
public Organization ToOrganization(Organization existingOrganization)
{
existingOrganization.Name = Name;

View File

@ -0,0 +1,8 @@
using Bit.Core.Entities;
namespace Bit.Admin.Models;
public class OrganizationSelectableViewModel : Organization
{
public bool Selected { get; set; }
}

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Models;
public class OrganizationUnassignedToProviderSearchViewModel : PagedModel<OrganizationSelectableViewModel>
{
[Display(Name = "Organization Name")]
public string OrganizationName { get; set; }
[Display(Name = "Owner Email")]
public string OrganizationOwnerEmail { get; set; }
}

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.Entities.Provider;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Vault.Entities;
@ -9,11 +10,12 @@ public class OrganizationViewModel
{
public OrganizationViewModel() { }
public OrganizationViewModel(Organization org, IEnumerable<OrganizationConnection> connections,
public OrganizationViewModel(Organization org, Provider provider, IEnumerable<OrganizationConnection> connections,
IEnumerable<OrganizationUserUserDetails> orgUsers, IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
IEnumerable<Group> groups, IEnumerable<Policy> policies)
{
Organization = org;
Provider = provider;
Connections = connections ?? Enumerable.Empty<OrganizationConnection>();
HasPublicPrivateKeys = org.PublicKey != null && org.PrivateKey != null;
UserInvitedCount = orgUsers.Count(u => u.Status == OrganizationUserStatusType.Invited);
@ -24,17 +26,21 @@ public class OrganizationViewModel
CollectionCount = collections.Count();
GroupCount = groups?.Count() ?? 0;
PolicyCount = policies?.Count() ?? 0;
var organizationUserStatus = org.Status == OrganizationStatusType.Pending
? OrganizationUserStatusType.Invited
: OrganizationUserStatusType.Confirmed;
Owners = string.Join(", ",
orgUsers
.Where(u => u.Type == OrganizationUserType.Owner && u.Status == OrganizationUserStatusType.Confirmed)
.Where(u => u.Type == OrganizationUserType.Owner && u.Status == organizationUserStatus)
.Select(u => u.Email));
Admins = string.Join(", ",
orgUsers
.Where(u => u.Type == OrganizationUserType.Admin && u.Status == OrganizationUserStatusType.Confirmed)
.Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus)
.Select(u => u.Email));
}
public Organization Organization { get; set; }
public Provider Provider { get; set; }
public IEnumerable<OrganizationConnection> Connections { get; set; }
public string Owners { get; set; }
public string Admins { get; set; }

View File

@ -14,10 +14,13 @@ public class ProviderEditModel : ProviderViewModel
Name = provider.Name;
BusinessName = provider.BusinessName;
BillingEmail = provider.BillingEmail;
BillingPhone = provider.BillingPhone;
}
[Display(Name = "Billing Email")]
public string BillingEmail { get; set; }
[Display(Name = "Billing Phone Number")]
public string BillingPhone { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
public string Name { get; set; }
@ -28,6 +31,7 @@ public class ProviderEditModel : ProviderViewModel
existingProvider.Name = Name;
existingProvider.BusinessName = BusinessName;
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant()?.Trim();
return existingProvider;
}
}

View File

@ -1,21 +1,20 @@
@model OrganizationEditModel
@{
ViewData["Title"] = "Organization: " + Model.Organization.Name;
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Organization.Name;
}
@section Scripts {
@await Html.PartialAsync("_OrganizationFormScripts")
<script>
(() => {
document.getElementById('teams-trial').addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !==
'@((byte)Bit.Core.Enums.PlanType.Free)') {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)Bit.Core.Enums.PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
// Plan
document.getElementById('@(nameof(Model.PlanType))').value =
'@((byte)Bit.Core.Enums.PlanType.TeamsAnnually)';
document.getElementById('@(nameof(Model.PlanType))').value = '@((byte)Bit.Core.Enums.PlanType.TeamsAnnually)';
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
document.getElementById('@(nameof(Model.Seats))').value = '10';
document.getElementById('@(nameof(Model.MaxCollections))').value = '';
@ -38,19 +37,14 @@
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.ExpirationDate))').value = '@Model.FourteenDayExpirationDate';
document.getElementById('@(nameof(Model.SalesAssistedTrialStarted))').value = true;
});
document.getElementById('enterprise-trial').addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !==
'@((byte)Bit.Core.Enums.PlanType.Free)') {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)Bit.Core.Enums.PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
// Plan
document.getElementById('@(nameof(Model.PlanType))').value =
'@((byte)Bit.Core.Enums.PlanType.EnterpriseAnnually)';
document.getElementById('@(nameof(Model.PlanType))').value = '@((byte)Bit.Core.Enums.PlanType.EnterpriseAnnually)';
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
document.getElementById('@(nameof(Model.Seats))').value = '10';
document.getElementById('@(nameof(Model.MaxCollections))').value = '';
@ -73,260 +67,24 @@
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.ExpirationDate))').value = '@Model.FourteenDayExpirationDate';
document.getElementById('@(nameof(Model.SalesAssistedTrialStarted))').value = true;
});
document.getElementById('@(nameof(Model.PlanType))').addEventListener('change', () => {
const selectEl = document.getElementById('@(nameof(Model.PlanType))');
const selectText = selectEl.options[selectEl.selectedIndex].text;
document.getElementById('@(nameof(Model.Plan))').value = selectText;
});
document.getElementById('gateway-customer-link').addEventListener('click', () => {
const gateway = document.getElementById('@(nameof(Model.Gateway))');
const customerId = document.getElementById('@(nameof(Model.GatewayCustomerId))');
if (!gateway || gateway.value === '' || !customerId || customerId.value === '') {
return;
}
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/customers/' + customerId.value, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/'
+ customerId.value, '_blank');
}
});
document.getElementById('gateway-subscription-link').addEventListener('click', () => {
const gateway = document.getElementById('@(nameof(Model.Gateway))');
const subId = document.getElementById('@(nameof(Model.GatewaySubscriptionId))');
if (!gateway || gateway.value === '' || !subId || subId.value === '') {
return;
}
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/subscriptions/' + subId.value, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
'subscriptions/' + subId.value, '_blank');
}
});
})();
</script>
}
<h1>Organization <small>@Model.Organization.Name</small></h1>
<h1>@(Model.Provider != null ? "Client " : string.Empty)Organization <small>@Model.Organization.Name</small></h1>
@if (Model.Provider != null)
{
<h2>Provider Relationship</h2>
@await Html.PartialAsync("_ProviderInformation", Model.Provider)
}
<h2>Organization Information</h2>
@await Html.PartialAsync("_ViewInformation", Model)
<h2>Billing Information</h2>
@await Html.PartialAsync("_BillingInformation",
new BillingInformationModel { BillingInfo = Model.BillingInfo, OrganizationId = Model.Organization.Id })
<form method="post" id="edit-form">
<input asp-for="SalesAssistedTrialStarted" type="hidden">
<h2>General</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name" required>
</div>
</div>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" asp-for="Enabled">
<label class="form-check-label" asp-for="Enabled"></label>
</div>
<h2>Business Information</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
</div>
</div>
<h2>Plan</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="PlanType"></label>
<select class="form-control" asp-for="PlanType"
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.PlanType>()"></select>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="Plan"></label>
<input type="text" class="form-control" asp-for="Plan" required>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="Seats"></label>
<input type="number" class="form-control" asp-for="Seats" min="1">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="MaxCollections"></label>
<input type="number" class="form-control" asp-for="MaxCollections" min="1">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="MaxStorageGb"></label>
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1">
</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="row mb-3">
<div class="col-4">
<h3>General</h3>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" asp-for="SelfHost">
<label class="form-check-label" asp-for="SelfHost"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="Use2fa">
<label class="form-check-label" asp-for="Use2fa"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseApi">
<label class="form-check-label" asp-for="UseApi"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseGroups">
<label class="form-check-label" asp-for="UseGroups"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsePolicies">
<label class="form-check-label" asp-for="UsePolicies"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseSso">
<label class="form-check-label" asp-for="UseSso"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseKeyConnector">
<label class="form-check-label" asp-for="UseKeyConnector"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseScim">
<label class="form-check-label" asp-for="UseScim"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseDirectory">
<label class="form-check-label" asp-for="UseDirectory"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseEvents">
<label class="form-check-label" asp-for="UseEvents"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseResetPassword">
<label class="form-check-label" asp-for="UseResetPassword"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions">
<label class="form-check-label" asp-for="UseCustomPermissions"></label>
</div>
</div>
<div class="col-4">
<h3>Password Manager</h3>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseTotp">
<label class="form-check-label" asp-for="UseTotp"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsersGetPremium">
<label class="form-check-label" asp-for="UsersGetPremium"></label>
</div>
</div>
<div class="col-4">
<h3>Secrets Manager</h3>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseSecretsManager">
<label class="form-check-label" asp-for="UseSecretsManager"></label>
</div>
</div>
</div>
<h2>Licensing</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="LicenseKey"></label>
<input type="text" class="form-control" asp-for="LicenseKey">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="ExpirationDate"></label>
<input type="datetime-local" class="form-control" asp-for="ExpirationDate">
</div>
</div>
</div>
<h2>Billing</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="BillingEmail"></label>
<input type="email" class="form-control" asp-for="BillingEmail">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway"
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewayCustomerId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
<i class="fa fa-external-link"></i>
</button>
</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewaySubscriptionId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<i class="fa fa-external-link"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</form>
@await Html.PartialAsync("_OrganizationForm", Model)
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex">

View File

@ -6,6 +6,11 @@
<h1>Organization <small>@Model.Organization.Name</small></h1>
@if (Model.Provider != null)
{
<h2>Provider Relationship</h2>
@await Html.PartialAsync("_ProviderInformation", Model.Provider)
}
<h2>Information</h2>
@await Html.PartialAsync("_ViewInformation", Model)
@if(GlobalSettings.SelfHosted)

View File

@ -0,0 +1,9 @@
@using Bit.SharedWeb.Utilities
@model Bit.Core.Entities.Provider.Provider
<dl class="row">
<dt class="col-sm-4 col-lg-3">Provider Name</dt>
<dd class="col-sm-8 col-lg-9">@Model.Name</dd>
<dt class="col-sm-4 col-lg-3">Provider Type</dt>
<dd class="col-sm-8 col-lg-9">@(Model.Type.GetDisplayAttribute()?.GetName())</dd>
</dl>

View File

@ -0,0 +1,97 @@
@using Bit.SharedWeb.Utilities
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model OrganizationUnassignedToProviderSearchViewModel
@{
ViewData["Title"] = "Add Existing Organization";
var providerId = ViewContext.RouteData.Values["id"];
}
<h1>Add Existing Organization</h1>
<div class="row mb-2">
<div class="col">
<form class="form-inline mb-2" method="get" asp-route-id="@providerId">
<label class="sr-only" asp-for="OrganizationName"></label>
<input type="text" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
<label class="sr-only" asp-for="OrganizationOwnerEmail"></label>
<input type="email" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
<button type="submit" class="btn btn-primary mb-2" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
</form>
</div>
</div>
<form method="post" id="select-form" asp-route-id="@providerId">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th style="width: 20px;">All</th>
<th>Name</th>
<th style="width: 190px;">Plan</th>
</tr>
</thead>
<tbody>
@if (!Model.Items.Any())
{
<tr>
<td colspan="5">No results to list.</td>
</tr>
}
else
{
@for (var i = 0; i < Model.Items.Count; i++)
{
<tr>
<td class="text-center">
@Html.HiddenFor(m => Model.Items[i].Id, new { @readonly = "readonly", autocomplete = "off" })
@Html.CheckBoxFor(m => Model.Items[i].Selected)
</td>
<td>@Html.ActionLink(Model.Items[i].Name, "Edit", "Organizations", new { id = Model.Items[i].Id }, new { target = "_blank" })</td>
<td>@(Model.Items[i].PlanType.GetDisplayAttribute()?.Name ?? Model.Items[i].PlanType.ToString())</td>
</tr>
}
}
</tbody>
</table>
</div>
</form>
<div class="row">
<div class="col">
<nav>
<ul class="pagination">
@if (Model.PreviousPage.HasValue)
{
<li class="page-item">
<a class="page-link" asp-action="AddExistingOrganization" asp-route-id="@providerId" asp-route-page="@Model.PreviousPage.Value"
asp-route-count="@Model.Count" asp-route-ownerEmail="@Model.OrganizationOwnerEmail"
asp-route-name="@Model.OrganizationName">Previous</a>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if (Model.NextPage.HasValue)
{
<li class="page-item">
<a class="page-link" asp-action="AddExistingOrganization" asp-route-id="@providerId" asp-route-page="@Model.NextPage.Value"
asp-route-count="@Model.Count" asp-route-ownerEmail="@Model.OrganizationOwnerEmail"
asp-route-name="@Model.OrganizationName">Next</a>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
</nav>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary" form="select-form">Add to Reseller</button>
</div>
</div>

View File

@ -1,16 +1,55 @@
@model CreateProviderModel
@using Bit.SharedWeb.Utilities
@model CreateProviderModel
@{
ViewData["Title"] = "Create Provider";
}
@section Scripts {
<script>
function toggleProviderTypeInfo(value) {
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
document.getElementById('info-' + value).classList.remove('d-none');
}
</script>
}
<h1>Create Provider</h1>
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
<label asp-for="Type" class="h2"></label>
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType)))
{
var providerTypeValue = (int)providerType;
<div class="form-check">
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input", onclick=$"toggleProviderTypeInfo({providerTypeValue})" })
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
<br/>
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @for = $"providerType-{providerTypeValue}" })
</div>
}
</div>
<div id="@($"info-{(int)ProviderType.Msp}")" class="form-group @(Model.Type != ProviderType.Msp ? "d-none" : string.Empty)">
<h2>MSP Info</h2>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
</div>
<div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)">
<h2>Reseller Info</h2>
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
<div class="form-group">
<label asp-for="BillingEmail"></label>
<input type="text" class="form-control" asp-for="BillingEmail">
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>

View File

@ -0,0 +1,21 @@
@model OrganizationEditModel
@{
ViewData["Title"] = "Create Client Organization";
}
@section Scripts {
@await Html.PartialAsync("_OrganizationFormScripts")
}
<h1>New Client Organization</h1>
@await Html.PartialAsync("_OrganizationForm", Model)
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex">
<form asp-controller="Providers" asp-action="Edit" asp-route-id="@Model.Provider.Id"
onsubmit="return confirm('Are you sure you want to cancel?')">
<button class="btn btn-outline-secondary" type="submit">Cancel</button>
</form>
</div>
</div>

View File

@ -36,6 +36,14 @@
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="BillingPhone"></label>
<input type="tel" class="form-control" asp-for="BillingPhone">
</div>
</div>
</div>
</form>
@await Html.PartialAsync("Organizations", Model)
<div class="d-flex mt-4">

View File

@ -1,4 +1,5 @@
@model ProvidersModel
@using Bit.SharedWeb.Utilities
@model ProvidersModel
@{
ViewData["Title"] = "Providers";
}
@ -25,6 +26,7 @@
<thead>
<tr>
<th>Name</th>
<th style="width: 190px;">Provider Type</th>
<th style="width: 190px;">Status</th>
<th style="width: 150px;">Created</th>
</tr>
@ -44,6 +46,7 @@
<td>
<a asp-action="@Model.Action" asp-route-id="@provider.Id">@(provider.Name ?? "Pending")</a>
</td>
<td>@provider.Type.GetDisplayAttribute()?.GetShortName()</td>
<td>@provider.Status</td>
<td>
<span title="@provider.CreationDate.ToString()">

View File

@ -1,4 +1,7 @@
@model ProviderViewModel
@await Html.PartialAsync("_ProviderScripts")
<h2>Provider Organizations</h2>
<div class="row">
<div class="col-sm">
@ -6,7 +9,17 @@
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th style="width: 50%;">Name</th>
<th style="width: 50%;">Status</th>
<th>
@if (Model.Provider.Type == ProviderType.Reseller)
{
<div class="float-right text-nowrap">
<a asp-controller="Providers" asp-action="CreateOrganization" asp-route-providerId="@Model.Provider.Id" class="btn btn-sm btn-primary">New Organization</a>
<a asp-controller="Providers" asp-action="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary">Add Existing Organization</a>
</div>
}
</th>
</tr>
</thead>
<tbody>
@ -22,8 +35,24 @@
{
<tr>
<td class="align-middle">
<a asp-controller="Organizations" asp-action="Edit"
asp-route-id="@org.OrganizationId">@org.OrganizationName</a>
<a asp-controller="Organizations" asp-action="Edit" asp-route-id="@org.OrganizationId">@org.OrganizationName</a>
</td>
<td>
@org.Status
</td>
<td>
<div class="float-right">
@if (org.Status == OrganizationStatusType.Pending)
{
<a href="#" class="float-right" onclick="return resendOwnerInvite('@org.OrganizationId', '@org.OrganizationName');">
<i class="fa fa-envelope-o fa-lg" title="Resend Setup Invite"></i>
</a>
}
else
{
<i class="fa fa-envelope-o fa-lg text-secondary"></i>
}
</div>
</td>
</tr>
}

View File

@ -0,0 +1,20 @@
<script>
function resendOwnerInvite(orgId, orgName) {
if (confirm('Resend invite to "' + orgName + '"?')) {
$.ajax({
type: "POST",
url: '@Url.Action("ResendOwnerInvite", "Organizations")' + '?id=' + orgId,
dataType: 'json',
contentType: false,
processData: false,
success: function (response) {
alert('Invitation has been resent!');
},
error: function (response) {
alert("Error!");
}
});
}
return false;
}
</script>

View File

@ -1,4 +1,5 @@
@model ProviderViewModel
@using Bit.SharedWeb.Utilities
@model ProviderViewModel
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.Provider.Id</code></dd>
@ -7,7 +8,10 @@
<dd class="col-sm-8 col-lg-9">@Model.Provider.Status</dd>
<dt class="col-sm-4 col-lg-3">Users</dt>
<dd class="col-sm-8 col-lg-9">@Model.UserCount</dd>
<dd class="col-sm-8 col-lg-9">@(Model.Provider.Type == ProviderType.Reseller ? "N/A" : Model.UserCount)</dd>
<dt class="col-sm-4 col-lg-3">Provider Type</dt>
<dd class="col-sm-8 col-lg-9">@(Model.Provider.Type.GetDisplayAttribute()?.GetName())</dd>
<dt class="col-sm-4 col-lg-3">Created</dt>
<dd class="col-sm-8 col-lg-9">@Model.Provider.CreationDate.ToString()</dd>

View File

@ -0,0 +1,247 @@
@using Bit.SharedWeb.Utilities
@model OrganizationEditModel
<form method="post" id="edit-form" asp-route-providerId="@Model.Provider?.Id">
<input asp-for="SalesAssistedTrialStarted" type="hidden">
<h2>General</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name" required>
</div>
</div>
</div>
@if (Model.Provider?.Type == ProviderType.Reseller)
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<label>Client Owner Email</label>
@if (!string.IsNullOrWhiteSpace(Model.Owners))
{
<input type="text" class="form-control" asp-for="Owners" readonly="readonly">
}
else
{
<input type="text" class="form-control" asp-for="Owners" required>
}
<label class="form-check-label small text-muted align-top">This user should be independent of the Provider. If the Provider is disassociated with the organization, this user will maintain ownership of the organization.</label>
</div>
</div>
</div>
}
@if (Model.Organization != null)
{
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" asp-for="Enabled">
<label class="form-check-label" asp-for="Enabled"></label>
</div>
}
<h2>Business Information</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
</div>
</div>
<h2>Plan</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="PlanType"></label>
@{
var planTypes = Enum.GetValues<PlanType>()
.Where(p => Model.Provider == null || p is >= PlanType.TeamsMonthly and <= PlanType.EnterpriseAnnually)
.Select(e => new SelectListItem
{
Value = ((int)e).ToString(),
Text = e.GetDisplayAttribute()?.GetName() ?? e.ToString()
})
.ToList();
}
<select class="form-control" asp-for="PlanType" asp-items="planTypes"></select>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="Plan"></label>
<input type="text" class="form-control" asp-for="Plan" required>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="Seats"></label>
<input type="number" class="form-control" asp-for="Seats" min="1">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="MaxCollections"></label>
<input type="number" class="form-control" asp-for="MaxCollections" min="1">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="MaxStorageGb"></label>
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1">
</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="row mb-3">
<div class="col-4">
<h3>General</h3>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" asp-for="SelfHost">
<label class="form-check-label" asp-for="SelfHost"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="Use2fa">
<label class="form-check-label" asp-for="Use2fa"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseApi">
<label class="form-check-label" asp-for="UseApi"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseGroups">
<label class="form-check-label" asp-for="UseGroups"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsePolicies">
<label class="form-check-label" asp-for="UsePolicies"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseSso">
<label class="form-check-label" asp-for="UseSso"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseKeyConnector">
<label class="form-check-label" asp-for="UseKeyConnector"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseScim">
<label class="form-check-label" asp-for="UseScim"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseDirectory">
<label class="form-check-label" asp-for="UseDirectory"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseEvents">
<label class="form-check-label" asp-for="UseEvents"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseResetPassword">
<label class="form-check-label" asp-for="UseResetPassword"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions">
<label class="form-check-label" asp-for="UseCustomPermissions"></label>
</div>
</div>
<div class="col-4">
<h3>Password Manager</h3>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseTotp">
<label class="form-check-label" asp-for="UseTotp"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsersGetPremium">
<label class="form-check-label" asp-for="UsersGetPremium"></label>
</div>
</div>
<div class="col-4">
<h3>Secrets Manager</h3>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseSecretsManager">
<label class="form-check-label" asp-for="UseSecretsManager"></label>
</div>
</div>
</div>
<h2>Licensing</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="LicenseKey"></label>
<input type="text" class="form-control" asp-for="LicenseKey">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="ExpirationDate"></label>
<input type="datetime-local" class="form-control" asp-for="ExpirationDate">
</div>
</div>
</div>
<h2>Billing</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="BillingEmail"></label>
@if (Model.Provider?.Type == ProviderType.Reseller)
{
<input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly">
}
else
{
<input type="email" class="form-control" asp-for="BillingEmail">
}
</div>
</div>
<div class="col-sm">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway"
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewayCustomerId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
<i class="fa fa-external-link"></i>
</button>
</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewaySubscriptionId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<i class="fa fa-external-link"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,35 @@
<script>
(() => {
document.getElementById('@(nameof(Model.PlanType))').addEventListener('change', () => {
const selectEl = document.getElementById('@(nameof(Model.PlanType))');
const selectText = selectEl.options[selectEl.selectedIndex].text;
document.getElementById('@(nameof(Model.Plan))').value = selectText;
});
document.getElementById('gateway-customer-link').addEventListener('click', () => {
const gateway = document.getElementById('@(nameof(Model.Gateway))');
const customerId = document.getElementById('@(nameof(Model.GatewayCustomerId))');
if (!gateway || gateway.value === '' || !customerId || customerId.value === '') {
return;
}
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/customers/' + customerId.value, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/'
+ customerId.value, '_blank');
}
});
document.getElementById('gateway-subscription-link').addEventListener('click', () => {
const gateway = document.getElementById('@(nameof(Model.Gateway))');
const subId = document.getElementById('@(nameof(Model.GatewaySubscriptionId))');
if (!gateway || gateway.value === '' || !subId || subId.value === '') {
return;
}
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/subscriptions/' + subId.value, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
'subscriptions/' + subId.value, '_blank');
}
});
})();
</script>

View File

@ -177,6 +177,20 @@ public class OrganizationUsersController : Controller
await _organizationService.ResendInviteAsync(orgGuidId, userId.Value, new Guid(id));
}
[HttpPost("{organizationUserId}/accept-init")]
public async Task AcceptInit(Guid orgId, Guid organizationUserId, [FromBody] OrganizationUserAcceptInitRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
await _organizationService.InitPendingOrganization(user.Id, orgId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName);
await _organizationService.AcceptUserAsync(organizationUserId, user, model.Token, _userService);
await _organizationService.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id, _userService);
}
[HttpPost("{organizationUserId}/accept")]
public async Task Accept(Guid orgId, Guid organizationUserId, [FromBody] OrganizationUserAcceptRequestModel model)
{
@ -188,11 +202,9 @@ public class OrganizationUsersController : Controller
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy != null &&
masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
if (useMasterPasswordPolicy &&
string.IsNullOrWhiteSpace(model.ResetPasswordKey))
masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
if (useMasterPasswordPolicy && string.IsNullOrWhiteSpace(model.ResetPasswordKey))
{
throw new BadRequestException(string.Empty, "Master Password reset is required, but not provided.");
}

View File

@ -28,6 +28,7 @@ public class OrganizationsController : Controller
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IProviderRepository _providerRepository;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly IPaymentService _paymentService;
@ -46,6 +47,7 @@ public class OrganizationsController : Controller
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository,
IProviderRepository providerRepository,
IOrganizationService organizationService,
IUserService userService,
IPaymentService paymentService,
@ -63,6 +65,7 @@ public class OrganizationsController : Controller
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_policyRepository = policyRepository;
_providerRepository = providerRepository;
_organizationService = organizationService;
_userService = userService;
_paymentService = paymentService;
@ -101,7 +104,7 @@ public class OrganizationsController : Controller
public async Task<BillingResponseModel> GetBilling(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.ViewBillingHistory(orgIdGuid))
{
throw new NotFoundException();
}
@ -120,7 +123,7 @@ public class OrganizationsController : Controller
public async Task<OrganizationSubscriptionResponseModel> GetSubscription(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.ViewSubscription(orgIdGuid))
{
throw new NotFoundException();
}
@ -139,7 +142,9 @@ public class OrganizationsController : Controller
throw new NotFoundException();
}
return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo);
var hideSensitiveData = !await _currentContext.EditSubscription(orgIdGuid);
return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData);
}
else
{
@ -240,7 +245,7 @@ public class OrganizationsController : Controller
model.BillingEmail != organization.BillingEmail);
var hasRequiredPermissions = updateBilling
? await _currentContext.ManageBilling(orgIdGuid)
? await _currentContext.EditSubscription(orgIdGuid)
: await _currentContext.OrganizationOwner(orgIdGuid);
if (!hasRequiredPermissions)
@ -257,7 +262,7 @@ public class OrganizationsController : Controller
public async Task PostPayment(string id, [FromBody] PaymentRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.EditPaymentMethods(orgIdGuid))
{
throw new NotFoundException();
}
@ -280,7 +285,7 @@ public class OrganizationsController : Controller
public async Task<PaymentResponseModel> PostUpgrade(string id, [FromBody] OrganizationUpgradeRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
@ -294,7 +299,7 @@ public class OrganizationsController : Controller
public async Task PostSubscription(string id, [FromBody] OrganizationSubscriptionUpdateRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
@ -307,7 +312,7 @@ public class OrganizationsController : Controller
public async Task<PaymentResponseModel> PostSeat(string id, [FromBody] OrganizationSeatRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
@ -321,7 +326,7 @@ public class OrganizationsController : Controller
public async Task<PaymentResponseModel> PostStorage(string id, [FromBody] StorageRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
@ -335,7 +340,7 @@ public class OrganizationsController : Controller
public async Task PostVerifyBank(string id, [FromBody] OrganizationVerifyBankRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
@ -348,7 +353,7 @@ public class OrganizationsController : Controller
public async Task PostCancel(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
@ -361,7 +366,7 @@ public class OrganizationsController : Controller
public async Task PostReinstate(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManageBilling(orgIdGuid))
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}

View File

@ -54,9 +54,7 @@ public class ProviderOrganizationsController : Controller
throw new NotFoundException();
}
var userId = _userService.GetProperUserId(User).Value;
await _providerService.AddOrganization(providerId, model.OrganizationId, userId, model.Key);
await _providerService.AddOrganization(providerId, model.OrganizationId, model.Key);
}
[HttpPost("")]

View File

@ -37,6 +37,19 @@ public class OrganizationUserInviteRequestModel
}
}
public class OrganizationUserAcceptInitRequestModel
{
[Required]
public string Token { get; set; }
[Required]
public string Key { get; set; }
[Required]
public OrganizationKeysRequestModel Keys { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string CollectionName { get; set; }
}
public class OrganizationUserAcceptRequestModel
{
[Required]

View File

@ -84,28 +84,30 @@ public class OrganizationResponseModel : ResponseModel
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
{
public OrganizationSubscriptionResponseModel(Organization organization, SubscriptionInfo subscription = null)
: base(organization, "organizationSubscription")
public OrganizationSubscriptionResponseModel(Organization organization) : base(organization, "organizationSubscription")
{
if (subscription != null)
{
Subscription = subscription.Subscription != null ?
new BillingSubscription(subscription.Subscription) : null;
UpcomingInvoice = subscription.UpcomingInvoice != null ?
new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null;
Expiration = DateTime.UtcNow.AddYears(1); // Not used, so just give it a value.
}
else
{
Expiration = organization.ExpirationDate;
}
Expiration = organization.ExpirationDate;
StorageName = organization.Storage.HasValue ?
CoreHelpers.ReadableBytesSize(organization.Storage.Value) : null;
StorageGb = organization.Storage.HasValue ?
Math.Round(organization.Storage.Value / 1073741824D, 2) : 0; // 1 GB
}
public OrganizationSubscriptionResponseModel(Organization organization, SubscriptionInfo subscription, bool hideSensitiveData)
: this(organization)
{
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
UpcomingInvoice = subscription.UpcomingInvoice != null ? new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null;
Expiration = DateTime.UtcNow.AddYears(1); // Not used, so just give it a value.
if (hideSensitiveData)
{
BillingEmail = null;
Subscription.Items = null;
UpcomingInvoice.Amount = null;
}
}
public string StorageName { get; set; }
public double? StorageGb { get; set; }
public BillingSubscription Subscription { get; set; }

View File

@ -1,4 +1,5 @@
using Bit.Core.Enums;
using Bit.Core.Enums.Provider;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@ -46,6 +47,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
UserId = organization.UserId?.ToString();
ProviderId = organization.ProviderId?.ToString();
ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType;
FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName;
FamilySponsorshipAvailable = FamilySponsorshipFriendlyName == null &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
@ -97,6 +99,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool HasPublicAndPrivateKeys { get; set; }
public string ProviderId { get; set; }
public string ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public bool FamilySponsorshipAvailable { get; set; }
public ProductType PlanProductType { get; set; }

View File

@ -100,6 +100,6 @@ public class BillingSubscriptionUpcomingInvoice
Date = inv.Date;
}
public decimal Amount { get; set; }
public decimal? Amount { get; set; }
public DateTime? Date { get; set; }
}

View File

@ -13,9 +13,11 @@ namespace Bit.Core.Context;
public class CurrentContext : ICurrentContext
{
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IProviderUserRepository _providerUserRepository;
private bool _builtHttpContext;
private bool _builtClaimsPrincipal;
private IEnumerable<ProviderOrganizationProviderDetails> _providerOrganizationProviderDetails;
private IEnumerable<ProviderUserOrganizationDetails> _providerUserOrganizations;
public virtual HttpContext HttpContext { get; set; }
@ -37,8 +39,11 @@ public class CurrentContext : ICurrentContext
public virtual ClientType ClientType { get; set; }
public virtual Guid? ServiceAccountOrganizationId { get; set; }
public CurrentContext(IProviderUserRepository providerUserRepository)
public CurrentContext(
IProviderOrganizationRepository providerOrganizationRepository,
IProviderUserRepository providerUserRepository)
{
_providerOrganizationRepository = providerOrganizationRepository;
_providerUserRepository = providerUserRepository;
}
@ -392,15 +397,34 @@ public class CurrentContext : ICurrentContext
&& (o.Permissions?.ManageResetPassword ?? false)) ?? false);
}
public async Task<bool> ManageBilling(Guid orgId)
public async Task<bool> ViewSubscription(Guid orgId)
{
var orgManagedByProvider = await ProviderIdForOrg(orgId) != null;
var orgManagedByMspProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType == ProviderType.Msp);
return orgManagedByMspProvider
? await ProviderUserForOrgAsync(orgId)
: await OrganizationOwner(orgId);
}
public async Task<bool> EditSubscription(Guid orgId)
{
var orgManagedByProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId);
return orgManagedByProvider
? await ProviderUserForOrgAsync(orgId)
: await OrganizationOwner(orgId);
}
public async Task<bool> EditPaymentMethods(Guid orgId)
{
return await EditSubscription(orgId);
}
public async Task<bool> ViewBillingHistory(Guid orgId)
{
return await EditSubscription(orgId);
}
public bool ProviderProviderAdmin(Guid providerId)
{
return Providers?.Any(o => o.Id == providerId && o.Type == ProviderUserType.ProviderAdmin) ?? false;
@ -433,7 +457,7 @@ public class CurrentContext : ICurrentContext
public async Task<bool> ProviderUserForOrgAsync(Guid orgId)
{
return (await GetProviderOrganizations()).Any(po => po.OrganizationId == orgId);
return (await GetProviderUserOrganizations()).Any(po => po.OrganizationId == orgId);
}
public async Task<Guid?> ProviderIdForOrg(Guid orgId)
@ -443,7 +467,7 @@ public class CurrentContext : ICurrentContext
return null;
}
var po = (await GetProviderOrganizations())
var po = (await GetProviderUserOrganizations())
?.FirstOrDefault(po => po.OrganizationId == orgId);
return po?.ProviderId;
@ -520,7 +544,7 @@ public class CurrentContext : ICurrentContext
};
}
protected async Task<IEnumerable<ProviderUserOrganizationDetails>> GetProviderOrganizations()
protected async Task<IEnumerable<ProviderUserOrganizationDetails>> GetProviderUserOrganizations()
{
if (_providerUserOrganizations == null && UserId.HasValue)
{
@ -529,4 +553,14 @@ public class CurrentContext : ICurrentContext
return _providerUserOrganizations;
}
protected async Task<IEnumerable<ProviderOrganizationProviderDetails>> GetOrganizationProviderDetails()
{
if (_providerOrganizationProviderDetails == null && UserId.HasValue)
{
_providerOrganizationProviderDetails = await _providerOrganizationRepository.GetManyByUserAsync(UserId.Value);
}
return _providerOrganizationProviderDetails;
}
}

View File

@ -52,7 +52,10 @@ public interface ICurrentContext
Task<bool> ManageUsers(Guid orgId);
Task<bool> ManageScim(Guid orgId);
Task<bool> ManageResetPassword(Guid orgId);
Task<bool> ManageBilling(Guid orgId);
Task<bool> ViewSubscription(Guid orgId);
Task<bool> EditSubscription(Guid orgId);
Task<bool> EditPaymentMethods(Guid orgId);
Task<bool> ViewBillingHistory(Guid orgId);
Task<bool> ProviderUserForOrgAsync(Guid orgId);
bool ProviderProviderAdmin(Guid providerId);
bool ProviderUser(Guid providerId);

View File

@ -69,6 +69,7 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public int? MaxAutoscaleSeats { get; set; } = null;
public DateTime? OwnersNotifiedOfAutoscaling { get; set; } = null;
public OrganizationStatusType Status { get; set; }
public void SetNewId()
{

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Enums;
public enum OrganizationStatusType : byte
{
Pending = 0,
Created = 1
}

View File

@ -1,7 +1,11 @@
namespace Bit.Core.Enums.Provider;
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Enums.Provider;
public enum ProviderType : byte
{
[Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization")]
Msp = 0,
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing")]
Reseller = 1,
}

View File

@ -40,6 +40,8 @@ public enum ReferenceEventType
CollectionCreated,
[EnumMember(Value = "organization-edited-by-admin")]
OrganizationEditedByAdmin,
[EnumMember(Value = "organization-created-by-admin")]
OrganizationCreatedByAdmin,
[EnumMember(Value = "sm-service-account-accessed-secret")]
SmServiceAccountAccessedSecret,
}

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Enums.Provider;
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
public class OrganizationUserOrganizationDetails
{
@ -36,6 +38,7 @@ public class OrganizationUserOrganizationDetails
public string PrivateKey { get; set; }
public Guid? ProviderId { get; set; }
public string ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public string SsoConfig { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; }

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Models.Data;
using Bit.Core.Enums;
namespace Bit.Core.Models.Data;
public class ProviderOrganizationOrganizationDetails
{
@ -13,4 +15,5 @@ public class ProviderOrganizationOrganizationDetails
public int UserCount { get; set; }
public int? Seats { get; set; }
public string Plan { get; set; }
public OrganizationStatusType Status { get; set; }
}

View File

@ -0,0 +1,12 @@
using Bit.Core.Enums.Provider;
namespace Bit.Core.Models.Data;
public class ProviderOrganizationProviderDetails
{
public Guid Id { get; set; }
public Guid ProviderId { get; set; }
public Guid OrganizationId { get; set; }
public string ProviderName { get; set; }
public ProviderType ProviderType { get; set; }
}

View File

@ -9,12 +9,14 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel
public string OrganizationNameUrlEncoded { get; set; }
public string Token { get; set; }
public string ExpirationDate { get; set; }
public bool InitOrganization { get; set; }
public string Url => string.Format("{0}/accept-organization?organizationId={1}&" +
"organizationUserId={2}&email={3}&organizationName={4}&token={5}",
"organizationUserId={2}&email={3}&organizationName={4}&token={5}&initOrganization={6}",
WebVaultUrl,
OrganizationId,
OrganizationUserId,
Email,
OrganizationNameUrlEncoded,
Token);
Token,
InitOrganization);
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Entities.Provider;
namespace Bit.Core.Providers.Interfaces;
public interface ICreateProviderCommand
{
Task CreateMspAsync(Provider provider, string ownerEmail);
Task CreateResellerAsync(Provider provider);
}

View File

@ -13,4 +13,5 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
Task<ICollection<OrganizationAbility>> GetManyAbilitiesAsync();
Task<Organization> GetByLicenseKeyAsync(string licenseKey);
Task<SelfHostedOrganizationDetails> GetSelfHostedOrganizationDetailsById(Guid id);
Task<ICollection<Organization>> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take);
}

View File

@ -5,6 +5,9 @@ namespace Bit.Core.Repositories;
public interface IProviderOrganizationRepository : IRepository<ProviderOrganization, Guid>
{
Task<ICollection<ProviderOrganization>> CreateManyAsync(IEnumerable<ProviderOrganization> providerOrganizations);
Task<ICollection<ProviderOrganizationOrganizationDetails>> GetManyDetailsByProviderAsync(Guid providerId);
Task<ProviderOrganization> GetByOrganizationId(Guid organizationId);
Task<IEnumerable<ProviderOrganizationProviderDetails>> GetManyByUserAsync(Guid userId);
Task<int> GetCountByOrganizationIdsAsync(IEnumerable<Guid> organizationIds);
}

View File

@ -5,6 +5,7 @@ namespace Bit.Core.Repositories;
public interface IProviderRepository : IRepository<Provider, Guid>
{
Task<Provider> GetByOrganizationIdAsync(Guid organizationId);
Task<ICollection<Provider>> SearchAsync(string name, string userEmail, int skip, int take);
Task<ICollection<ProviderAbility>> GetManyAbilitiesAsync();
}

View File

@ -25,6 +25,7 @@ public interface IEventService
Task LogProviderUserEventAsync(ProviderUser providerUser, EventType type, DateTime? date = null);
Task LogProviderUsersEventAsync(IEnumerable<(ProviderUser, EventType, DateTime?)> events);
Task LogProviderOrganizationEventAsync(ProviderOrganization providerOrganization, EventType type, DateTime? date = null);
Task LogProviderOrganizationEventsAsync(IEnumerable<(ProviderOrganization, EventType, DateTime?)> events);
Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, DateTime? date = null);
Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, EventSystemUser systemUser, DateTime? date = null);
Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, DateTime? date = null);

View File

@ -15,8 +15,8 @@ public interface IMailService
Task SendTwoFactorEmailAsync(string email, string token);
Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint);
Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg);
Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg);
Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false);
Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false);
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);

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities;
using System.Security.Claims;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
@ -37,9 +38,8 @@ public interface IOrganizationService
Task<OrganizationUser> InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email,
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups);
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,
IUserService userService);
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false);
Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token, IUserService userService);
Task<OrganizationUser> AcceptUserAsync(string orgIdentifier, User user, IUserService userService);
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId, IUserService userService);
@ -69,5 +69,13 @@ public interface IOrganizationService
Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser, IUserService userService);
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
/// <summary>
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.
/// </summary>
/// <remarks>
/// This method must target a disabled Organization that has null keys and status as 'Pending'.
/// </remarks>
Task InitPendingOrganization(Guid userId, Guid organizationId, string publicKey, string privateKey, string collectionName);
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
}

View File

@ -7,7 +7,6 @@ namespace Bit.Core.Services;
public interface IProviderService
{
Task CreateAsync(string ownerEmail);
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key);
Task UpdateAsync(Provider provider, bool updateBilling = false);
@ -20,11 +19,13 @@ public interface IProviderService
Task<List<Tuple<ProviderUser, string>>> DeleteUsersAsync(Guid providerId, IEnumerable<Guid> providerUserIds,
Guid deletingUserId);
Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key);
Task AddOrganization(Guid providerId, Guid organizationId, string key);
Task AddOrganizationsToReseller(Guid providerId, IEnumerable<Guid> organizationIds);
Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId, OrganizationSignup organizationSignup,
string clientOwnerEmail, User user);
Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId);
Task LogProviderAccessToOrganizationAsync(Guid organizationId);
Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId);
Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail);
}

View File

@ -332,26 +332,38 @@ public class EventService : IEventService
public async Task LogProviderOrganizationEventAsync(ProviderOrganization providerOrganization, EventType type,
DateTime? date = null)
{
await LogProviderOrganizationEventsAsync(new[] { (providerOrganization, type, date) });
}
public async Task LogProviderOrganizationEventsAsync(IEnumerable<(ProviderOrganization, EventType, DateTime?)> events)
{
var providerAbilities = await _applicationCacheService.GetProviderAbilitiesAsync();
if (!CanUseProviderEvents(providerAbilities, providerOrganization.ProviderId))
var eventMessages = new List<IEvent>();
foreach (var (providerOrganization, type, date) in events)
{
return;
if (!CanUseProviderEvents(providerAbilities, providerOrganization.ProviderId))
{
continue;
}
var e = new EventMessage(_currentContext)
{
ProviderId = providerOrganization.ProviderId,
ProviderOrganizationId = providerOrganization.Id,
Type = type,
ActingUserId = _currentContext?.UserId,
Date = date.GetValueOrDefault(DateTime.UtcNow)
};
eventMessages.Add(e);
}
var e = new EventMessage(_currentContext)
{
ProviderId = providerOrganization.ProviderId,
ProviderOrganizationId = providerOrganization.Id,
Type = type,
ActingUserId = _currentContext?.UserId,
Date = date.GetValueOrDefault(DateTime.UtcNow)
};
await _eventWriteService.CreateAsync(e);
await _eventWriteService.CreateManyAsync(eventMessages);
}
public async Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type,
DateTime? date = null)
DateTime? date = null)
{
var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
if (!CanUseEvents(orgAbilities, organizationDomain.OrganizationId))

View File

@ -203,10 +203,10 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg) =>
BulkSendOrganizationInviteEmailAsync(organizationName, new[] { (orgUser, token) }, isFreeOrg);
public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false) =>
BulkSendOrganizationInviteEmailAsync(organizationName, new[] { (orgUser, token) }, isFreeOrg, initOrganization);
public async Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg)
public async Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false)
{
MailQueueMessage CreateMessage(string email, object model)
{
@ -229,6 +229,7 @@ public class HandlebarsMailService : IMailService
OrganizationNameUrlEncoded = WebUtility.UrlEncode(organizationName),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
InitOrganization = initOrganization
}
));

View File

@ -1,4 +1,5 @@
using System.Text.Json;
using System.Security.Claims;
using System.Text.Json;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -598,7 +599,7 @@ public class OrganizationService : IOrganizationService
bool provider = false)
{
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan);
if (!(plan is { LegacyYear: null }))
if (plan is not { LegacyYear: null })
{
throw new BadRequestException("Invalid plan selected.");
}
@ -649,6 +650,7 @@ public class OrganizationService : IOrganizationService
PrivateKey = signup.PrivateKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created
};
if (plan.Type == PlanType.Free && !provider)
@ -748,7 +750,8 @@ public class OrganizationService : IOrganizationService
PublicKey = publicKey,
PrivateKey = privateKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created
};
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false);
@ -1167,14 +1170,14 @@ public class OrganizationService : IOrganizationService
continue;
}
await SendInviteAsync(orgUser, org);
await SendInviteAsync(orgUser, org, false);
result.Add(Tuple.Create(orgUser, ""));
}
return result;
}
public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId)
public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false)
{
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (orgUser == null || orgUser.OrganizationId != organizationId ||
@ -1184,7 +1187,7 @@ public class OrganizationService : IOrganizationService
}
var org = await GetOrgById(orgUser.OrganizationId);
await SendInviteAsync(orgUser, org);
await SendInviteAsync(orgUser, org, initOrganization);
}
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
@ -1196,13 +1199,13 @@ public class OrganizationService : IOrganizationService
orgUsers.Select(o => (o, new ExpiringToken(MakeToken(o), DateTime.UtcNow.AddDays(5)))), organization.PlanType == PlanType.Free);
}
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization)
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
{
var now = DateTime.UtcNow;
var nowMillis = CoreHelpers.ToEpocMilliseconds(now);
var token = _dataProtector.Protect(
$"OrganizationUserInvite {orgUser.Id} {orgUser.Email} {nowMillis}");
await _mailService.SendOrganizationInviteEmailAsync(organization.Name, orgUser, new ExpiringToken(token, now.AddDays(5)), organization.PlanType == PlanType.Free);
await _mailService.SendOrganizationInviteEmailAsync(organization.Name, orgUser, new ExpiringToken(token, now.AddDays(5)), organization.PlanType == PlanType.Free, initOrganization);
}
public async Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token,
@ -2425,4 +2428,89 @@ public class OrganizationService : IOrganizationService
return status;
}
public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted)
{
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType);
if (plan is not { LegacyYear: null })
{
throw new BadRequestException("Invalid plan selected.");
}
if (plan.Disabled)
{
throw new BadRequestException("Plan not found.");
}
organization.Id = CoreHelpers.GenerateComb();
organization.Enabled = false;
organization.Status = OrganizationStatusType.Pending;
await SignUpAsync(organization, default, null, null, true);
var ownerOrganizationUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = null,
Email = ownerEmail,
Key = null,
Type = OrganizationUserType.Owner,
Status = OrganizationUserStatusType.Invited,
AccessAll = true
};
await _organizationUserRepository.CreateAsync(ownerOrganizationUser);
await SendInviteAsync(ownerOrganizationUser, organization, true);
await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationCreatedByAdmin, organization)
{
EventRaisedByUser = userService.GetUserName(user),
SalesAssistedTrialStarted = salesAssistedTrialStarted,
});
}
public async Task InitPendingOrganization(Guid userId, Guid organizationId, string publicKey, string privateKey, string collectionName)
{
await ValidateSignUpPoliciesAsync(userId);
var org = await GetOrgById(organizationId);
if (org.Enabled)
{
throw new BadRequestException("Organization is already enabled.");
}
if (org.Status != OrganizationStatusType.Pending)
{
throw new BadRequestException("Organization is not on a Pending status.");
}
if (!string.IsNullOrEmpty(org.PublicKey))
{
throw new BadRequestException("Organization already has a Public Key.");
}
if (!string.IsNullOrEmpty(org.PrivateKey))
{
throw new BadRequestException("Organization already has a Private Key.");
}
org.Enabled = true;
org.Status = OrganizationStatusType.Created;
org.PublicKey = publicKey;
org.PrivateKey = privateKey;
await UpdateAsync(org);
if (!string.IsNullOrWhiteSpace(collectionName))
{
var defaultCollection = new Collection
{
Name = collectionName,
OrganizationId = org.Id
};
await _collectionRepository.CreateAsync(defaultCollection);
}
}
}

View File

@ -70,8 +70,13 @@ public class NoopEventService : IEventService
return Task.FromResult(0);
}
public Task LogProviderOrganizationEventsAsync(IEnumerable<(ProviderOrganization, EventType, DateTime?)> events)
{
return Task.FromResult(0);
}
public Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type,
DateTime? date = null)
DateTime? date = null)
{
return Task.FromResult(0);
}

View File

@ -52,12 +52,12 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg)
public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false)
{
return Task.FromResult(0);
}
public Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg)
public Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false)
{
return Task.FromResult(0);
}

View File

@ -7,8 +7,6 @@ namespace Bit.Core.Services;
public class NoopProviderService : IProviderService
{
public Task CreateAsync(string ownerEmail) => throw new NotImplementedException();
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key) => throw new NotImplementedException();
public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException();
@ -25,7 +23,9 @@ public class NoopProviderService : IProviderService
public Task<List<Tuple<ProviderUser, string>>> DeleteUsersAsync(Guid providerId, IEnumerable<Guid> providerUserIds, Guid deletingUserId) => throw new NotImplementedException();
public Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key) => throw new NotImplementedException();
public Task AddOrganization(Guid providerId, Guid organizationId, string key) => throw new NotImplementedException();
public Task AddOrganizationsToReseller(Guid providerId, IEnumerable<Guid> organizationIds) => throw new NotImplementedException();
public Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId, OrganizationSignup organizationSignup, string clientOwnerEmail, User user) => throw new NotImplementedException();
@ -34,4 +34,5 @@ public class NoopProviderService : IProviderService
public Task LogProviderAccessToOrganizationAsync(Guid organizationId) => throw new NotImplementedException();
public Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid userId) => throw new NotImplementedException();
public Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail) => throw new NotImplementedException();
}

View File

@ -134,4 +134,18 @@ public class OrganizationRepository : Repository<Organization, Guid>, IOrganizat
return selfHostOrganization;
}
}
public async Task<ICollection<Organization>> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take)
{
using (var connection = new SqlConnection(ReadOnlyConnectionString))
{
var results = await connection.QueryAsync<Organization>(
"[dbo].[Organization_UnassignedToProviderSearch]",
new { Name = name, OwnerEmail = ownerEmail, Skip = skip, Take = take },
commandType: CommandType.StoredProcedure,
commandTimeout: 120);
return results.ToList();
}
}
}

View File

@ -18,6 +18,48 @@ public class ProviderOrganizationRepository : Repository<ProviderOrganization, G
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<ICollection<ProviderOrganization>> CreateManyAsync(IEnumerable<ProviderOrganization> providerOrganizations)
{
var entities = providerOrganizations.ToList();
if (!entities.Any())
{
return default;
}
foreach (var providerOrganization in entities)
{
providerOrganization.SetNewId();
}
using (var connection = new SqlConnection(ConnectionString))
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
try
{
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "[dbo].[ProviderOrganization]";
var dataTable = BuildProviderOrganizationsTable(bulkCopy, entities);
await bulkCopy.WriteToServerAsync(dataTable);
}
transaction.Commit();
return entities.ToList();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}
public async Task<ICollection<ProviderOrganizationOrganizationDetails>> GetManyDetailsByProviderAsync(Guid providerId)
{
using (var connection = new SqlConnection(ConnectionString))
@ -43,4 +85,83 @@ public class ProviderOrganizationRepository : Repository<ProviderOrganization, G
return results.SingleOrDefault();
}
}
public async Task<IEnumerable<ProviderOrganizationProviderDetails>> GetManyByUserAsync(Guid userId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<ProviderOrganizationProviderDetails>(
"[dbo].[ProviderOrganizationProviderDetails_ReadByUserId]",
new { UserId = userId },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task<int> GetCountByOrganizationIdsAsync(
IEnumerable<Guid> organizationIds)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteScalarAsync<int>(
$"[{Schema}].[ProviderOrganization_ReadCountByOrganizationIds]",
new { Ids = organizationIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
return results;
}
}
private DataTable BuildProviderOrganizationsTable(SqlBulkCopy bulkCopy, IEnumerable<ProviderOrganization> providerOrganizations)
{
var po = providerOrganizations.FirstOrDefault();
if (po == null)
{
throw new ApplicationException("Must have some ProviderOrganizations to bulk import.");
}
var providerOrganizationsTable = new DataTable("ProviderOrganizationDataTable");
var idColumn = new DataColumn(nameof(po.Id), typeof(Guid));
providerOrganizationsTable.Columns.Add(idColumn);
var providerIdColumn = new DataColumn(nameof(po.ProviderId), typeof(Guid));
providerOrganizationsTable.Columns.Add(providerIdColumn);
var organizationIdColumn = new DataColumn(nameof(po.OrganizationId), typeof(Guid));
providerOrganizationsTable.Columns.Add(organizationIdColumn);
var keyColumn = new DataColumn(nameof(po.Key), typeof(string));
providerOrganizationsTable.Columns.Add(keyColumn);
var settingsColumn = new DataColumn(nameof(po.Settings), typeof(string));
providerOrganizationsTable.Columns.Add(settingsColumn);
var creationDateColumn = new DataColumn(nameof(po.CreationDate), po.CreationDate.GetType());
providerOrganizationsTable.Columns.Add(creationDateColumn);
var revisionDateColumn = new DataColumn(nameof(po.RevisionDate), po.RevisionDate.GetType());
providerOrganizationsTable.Columns.Add(revisionDateColumn);
foreach (DataColumn col in providerOrganizationsTable.Columns)
{
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
}
var keys = new DataColumn[1];
keys[0] = idColumn;
providerOrganizationsTable.PrimaryKey = keys;
foreach (var providerOrganization in providerOrganizations)
{
var row = providerOrganizationsTable.NewRow();
row[idColumn] = providerOrganization.Id;
row[providerIdColumn] = providerOrganization.ProviderId;
row[organizationIdColumn] = providerOrganization.OrganizationId;
row[keyColumn] = providerOrganization.Key;
row[settingsColumn] = providerOrganization.Settings;
row[creationDateColumn] = providerOrganization.CreationDate;
row[revisionDateColumn] = providerOrganization.RevisionDate;
providerOrganizationsTable.Rows.Add(row);
}
return providerOrganizationsTable;
}
}

View File

@ -18,6 +18,19 @@ public class ProviderRepository : Repository<Provider, Guid>, IProviderRepositor
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<Provider> GetByOrganizationIdAsync(Guid organizationId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Provider>(
"[dbo].[Provider_ReadByOrganizationId]",
new { OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public async Task<ICollection<Provider>> SearchAsync(string name, string userEmail, int skip, int take)
{
using (var connection = new SqlConnection(ReadOnlyConnectionString))

View File

@ -91,6 +91,45 @@ public class OrganizationRepository : Repository<Core.Entities.Organization, Org
}
}
public async Task<ICollection<Core.Entities.Organization>> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = from o in dbContext.Organizations
where o.PlanType >= PlanType.TeamsMonthly && o.PlanType <= PlanType.EnterpriseAnnually &&
!dbContext.ProviderOrganizations.Any(po => po.OrganizationId == o.Id) &&
(string.IsNullOrWhiteSpace(name) || EF.Functions.Like(o.Name, $"%{name}%"))
select o;
if (!string.IsNullOrWhiteSpace(ownerEmail))
{
if (dbContext.Database.IsNpgsql())
{
query = from o in query
join ou in dbContext.OrganizationUsers
on o.Id equals ou.OrganizationId
join u in dbContext.Users
on ou.UserId equals u.Id
where ou.Type == OrganizationUserType.Owner && EF.Functions.ILike(EF.Functions.Collate(u.Email, "default"), $"{ownerEmail}%")
select o;
}
else
{
query = from o in query
join ou in dbContext.OrganizationUsers
on o.Id equals ou.OrganizationId
join u in dbContext.Users
on ou.UserId equals u.Id
where ou.Type == OrganizationUserType.Owner && EF.Functions.Like(u.Email, $"{ownerEmail}%")
select o;
}
}
return await query.OrderByDescending(o => o.CreationDate).Skip(skip).Take(take).ToArrayAsync();
}
}
public async Task UpdateStorageAsync(Guid id)
{
await OrganizationUpdateStorage(id);

View File

@ -15,6 +15,30 @@ public class ProviderOrganizationRepository :
: base(serviceScopeFactory, mapper, context => context.ProviderOrganizations)
{ }
public async Task<ICollection<ProviderOrganization>> CreateManyAsync(IEnumerable<ProviderOrganization> providerOrganizations)
{
var entities = providerOrganizations.ToList();
if (!entities.Any())
{
return default;
}
foreach (var providerOrganization in entities)
{
providerOrganization.SetNewId();
}
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
await dbContext.AddRangeAsync(entities);
await dbContext.SaveChangesAsync();
}
return entities;
}
public async Task<ICollection<ProviderOrganizationOrganizationDetails>> GetManyDetailsByProviderAsync(Guid providerId)
{
using (var scope = ServiceScopeFactory.CreateScope())
@ -32,4 +56,21 @@ public class ProviderOrganizationRepository :
var dbContext = GetDatabaseContext(scope);
return await GetDbSet(dbContext).Where(po => po.OrganizationId == organizationId).FirstOrDefaultAsync();
}
public async Task<IEnumerable<ProviderOrganizationProviderDetails>> GetManyByUserAsync(Guid userId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = new ProviderOrganizationReadByUserIdQuery(userId);
var data = await query.Run(dbContext).ToListAsync();
return data;
}
}
public async Task<int> GetCountByOrganizationIdsAsync(IEnumerable<Guid> organizationIds)
{
var query = new ProviderOrganizationCountByOrganizationIdsQuery(organizationIds);
return await GetCountFromQuery(query);
}
}

View File

@ -25,6 +25,20 @@ public class ProviderRepository : Repository<Provider, Models.Provider, Guid>, I
await base.DeleteAsync(provider);
}
public async Task<Provider> GetByOrganizationIdAsync(Guid organizationId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = from p in dbContext.Providers
join po in dbContext.ProviderOrganizations
on p.Id equals po.ProviderId
where po.OrganizationId == organizationId
select p;
return await query.FirstOrDefaultAsync();
}
}
public async Task<ICollection<Provider>> SearchAsync(string name, string userEmail, int skip, int take)
{
using (var scope = ServiceScopeFactory.CreateScope())

View File

@ -55,6 +55,7 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery<OrganizationU
Permissions = ou.Permissions,
ProviderId = p.Id,
ProviderName = p.Name,
ProviderType = p.Type,
SsoConfig = ss.Data,
FamilySponsorshipFriendlyName = os.FriendlyName,
FamilySponsorshipLastSyncDate = os.LastSyncDate,

View File

@ -0,0 +1,21 @@
using Bit.Core.Entities.Provider;
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
public class ProviderOrganizationCountByOrganizationIdsQuery : IQuery<ProviderOrganization>
{
private readonly IEnumerable<Guid> _organizationIds;
public ProviderOrganizationCountByOrganizationIdsQuery(IEnumerable<Guid> organizationIds)
{
_organizationIds = organizationIds;
}
public IQueryable<ProviderOrganization> Run(DatabaseContext dbContext)
{
var query = from po in dbContext.ProviderOrganizations
where _organizationIds.Contains(po.OrganizationId)
select po;
return query;
}
}

View File

@ -31,7 +31,8 @@ public class ProviderOrganizationOrganizationDetailsReadByProviderIdQuery : IQue
RevisionDate = x.po.RevisionDate,
UserCount = x.o.OrganizationUsers.Count(ou => ou.Status == Core.Enums.OrganizationUserStatusType.Confirmed),
Seats = x.o.Seats,
Plan = x.o.Plan
Plan = x.o.Plan,
Status = x.o.Status
});
}
}

View File

@ -0,0 +1,32 @@
using Bit.Core.Models.Data;
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
public class ProviderOrganizationReadByUserIdQuery : IQuery<ProviderOrganizationProviderDetails>
{
private readonly Guid _userId;
public ProviderOrganizationReadByUserIdQuery(Guid userId)
{
_userId = userId;
}
public IQueryable<ProviderOrganizationProviderDetails> Run(DatabaseContext dbContext)
{
var query = from po in dbContext.ProviderOrganizations
join ou in dbContext.OrganizationUsers
on po.OrganizationId equals ou.OrganizationId
join p in dbContext.Providers
on po.ProviderId equals p.Id
where ou.UserId == _userId
select new ProviderOrganizationProviderDetails
{
Id = po.Id,
OrganizationId = po.OrganizationId,
ProviderId = po.ProviderId,
ProviderName = p.Name,
ProviderType = p.Type
};
return query;
}
}

View File

@ -18,7 +18,7 @@ public class NotificationsHub : Microsoft.AspNetCore.SignalR.Hub
public override async Task OnConnectedAsync()
{
var currentContext = new CurrentContext(null);
var currentContext = new CurrentContext(null, null);
await currentContext.BuildAsync(Context.User, _globalSettings);
if (currentContext.Organizations != null)
{
@ -33,7 +33,7 @@ public class NotificationsHub : Microsoft.AspNetCore.SignalR.Hub
public override async Task OnDisconnectedAsync(Exception exception)
{
var currentContext = new CurrentContext(null);
var currentContext = new CurrentContext(null, null);
await currentContext.BuildAsync(Context.User, _globalSettings);
if (currentContext.Organizations != null)
{

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection;
namespace Bit.SharedWeb.Utilities;
public static class DisplayAttributeHelpers
{
public static DisplayAttribute GetDisplayAttribute(this Enum enumValue)
{
return enumValue.GetType()
.GetMember(enumValue.ToString())
.First()
.GetCustomAttribute<DisplayAttribute>();
}
public static DisplayAttribute GetDisplayAttribute<T>(this string property)
{
MemberInfo propertyInfo = typeof(T).GetProperty(property);
return propertyInfo?.GetCustomAttribute(typeof(DisplayAttribute)) as DisplayAttribute;
}
}

View File

@ -263,6 +263,10 @@
<Build Include="dbo\Stored Procedures\Policy_ReadByUserId.sql" />
<Build Include="dbo\Stored Procedures\Policy_Update.sql" />
<Build Include="dbo\Stored Procedures\ProviderOrganizationOrganizationDetails_ReadByProviderId.sql" />
<Build Include="dbo\Stored Procedures\ProviderOrganizationProviderDetails_ReadByUserId.sql" />
<Build Include="dbo\Stored Procedures\ProviderOrganization_ReadCountByOrganizationIds.sql" />
<Build Include="dbo\Stored Procedures\Provider_ReadByOrganizationId.sql" />
<Build Include="dbo\Stored Procedures\Organization_UnassignedToProviderSearch.sql" />
<Build Include="dbo\Stored Procedures\ProviderOrganization_Create.sql" />
<Build Include="dbo\Stored Procedures\ProviderOrganization_DeleteById.sql" />
<Build Include="dbo\Stored Procedures\ProviderOrganization_ReadById.sql" />

View File

@ -43,7 +43,8 @@
@UseKeyConnector BIT = 0,
@UseScim BIT = 0,
@UseCustomPermissions BIT = 0,
@UseSecretsManager BIT = 0
@UseSecretsManager BIT = 0,
@Status TINYINT = 0
AS
BEGIN
SET NOCOUNT ON
@ -94,7 +95,8 @@ BEGIN
[UseKeyConnector],
[UseScim],
[UseCustomPermissions],
[UseSecretsManager]
[UseSecretsManager],
[Status]
)
VALUES
(
@ -142,6 +144,7 @@ BEGIN
@UseKeyConnector,
@UseScim,
@UseCustomPermissions,
@UseSecretsManager
@UseSecretsManager,
@Status
)
END

View File

@ -0,0 +1,46 @@
CREATE PROCEDURE [dbo].[Organization_UnassignedToProviderSearch]
@Name NVARCHAR(50),
@OwnerEmail NVARCHAR(256),
@Skip INT = 0,
@Take INT = 25
WITH RECOMPILE
AS
BEGIN
SET NOCOUNT ON
DECLARE @NameLikeSearch NVARCHAR(55) = '%' + @Name + '%'
DECLARE @OwnerLikeSearch NVARCHAR(55) = @OwnerEmail + '%'
IF @OwnerEmail IS NOT NULL
BEGIN
SELECT
O.*
FROM
[dbo].[OrganizationView] O
INNER JOIN
[dbo].[OrganizationUser] OU ON O.[Id] = OU.[OrganizationId]
INNER JOIN
[dbo].[User] U ON U.[Id] = OU.[UserId]
WHERE
O.[PlanType] >= 8 AND O.[PlanType] <= 11 -- Get 'Team' and 'Enterprise' Organizations
AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id])
AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch)
AND (U.[Email] LIKE @OwnerLikeSearch)
ORDER BY O.[CreationDate] DESC
OFFSET @Skip ROWS
FETCH NEXT @Take ROWS ONLY
END
ELSE
BEGIN
SELECT
O.*
FROM
[dbo].[OrganizationView] O
WHERE
O.[PlanType] >= 8 AND O.[PlanType] <= 11 -- Get 'Team' and 'Enterprise' Organizations
AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id])
AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch)
ORDER BY O.[CreationDate] DESC
OFFSET @Skip ROWS
FETCH NEXT @Take ROWS ONLY
END
END

View File

@ -43,7 +43,8 @@
@UseKeyConnector BIT = 0,
@UseScim BIT = 0,
@UseCustomPermissions BIT = 0,
@UseSecretsManager BIT = 0
@UseSecretsManager BIT = 0,
@Status TINYINT = 0
AS
BEGIN
SET NOCOUNT ON
@ -94,7 +95,8 @@ BEGIN
[UseKeyConnector] = @UseKeyConnector,
[UseScim] = @UseScim,
[UseCustomPermissions] = @UseCustomPermissions,
[UseSecretsManager] = @UseSecretsManager
[UseSecretsManager] = @UseSecretsManager,
[Status] = @Status
WHERE
[Id] = @Id
END

View File

@ -0,0 +1,21 @@
CREATE PROCEDURE [dbo].[ProviderOrganizationProviderDetails_ReadByUserId]
@UserId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
PO.Id,
PO.OrganizationId,
PO.ProviderId,
P.Name as ProviderName,
P.[Type] as ProviderType
FROM
[dbo].[ProviderOrganizationView] PO
INNER JOIN
[dbo].[OrganizationUser] OU ON PO.OrganizationId = OU.OrganizationId
INNER JOIN
[dbo].[Provider] P ON PO.ProviderId = P.Id
WHERE
OU.UserId = @UserId
END

View File

@ -0,0 +1,18 @@
CREATE PROCEDURE [dbo].[ProviderOrganization_ReadCountByOrganizationIds]
@Ids AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
IF (SELECT COUNT(1) FROM @Ids) < 1
BEGIN
RETURN(-1)
END
SELECT
COUNT(1)
FROM
[dbo].[ProviderOrganizationView]
WHERE
[OrganizationId] IN (SELECT [Id] FROM @Ids)
END

View File

@ -0,0 +1,15 @@
CREATE PROCEDURE [dbo].[Provider_ReadByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
P.*
FROM
[dbo].[ProviderView] P
INNER JOIN
[dbo].[ProviderOrganization] PO ON PO.[ProviderId] = P.[Id]
WHERE
PO.[OrganizationId] = @OrganizationId
END

View File

@ -44,6 +44,7 @@
[UseScim] BIT NOT NULL CONSTRAINT [DF_Organization_UseScim] DEFAULT (0),
[UseCustomPermissions] BIT NOT NULL CONSTRAINT [DF_Organization_UseCustomPermissions] DEFAULT (0),
[UseSecretsManager] BIT NOT NULL CONSTRAINT [DF_Organization_UseSecretsManager] DEFAULT (0),
[Status] TINYINT NOT NULL CONSTRAINT [DF_Organization_Status] DEFAULT (1),
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
);

View File

@ -35,6 +35,7 @@ SELECT
OU.[Permissions],
PO.[ProviderId],
P.[Name] ProviderName,
P.[Type] ProviderType,
SS.[Data] SsoConfig,
OS.[FriendlyName] FamilySponsorshipFriendlyName,
OS.[LastSyncDate] FamilySponsorshipLastSyncDate,

View File

@ -11,7 +11,8 @@ SELECT
PO.[RevisionDate],
(SELECT COUNT(1) FROM [dbo].[OrganizationUser] OU WHERE OU.OrganizationId = PO.OrganizationId AND OU.Status = 2) UserCount,
O.[Seats],
O.[Plan]
O.[Plan],
O.[Status]
FROM
[dbo].[ProviderOrganization] PO
LEFT JOIN

View File

@ -24,6 +24,7 @@ public class OrganizationsControllerTests : IDisposable
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IProviderRepository _providerRepository;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoConfigService _ssoConfigService;
private readonly IUserService _userService;
@ -46,6 +47,7 @@ public class OrganizationsControllerTests : IDisposable
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_paymentService = Substitute.For<IPaymentService>();
_policyRepository = Substitute.For<IPolicyRepository>();
_providerRepository = Substitute.For<IProviderRepository>();
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
_ssoConfigService = Substitute.For<ISsoConfigService>();
_getOrganizationApiKeyQuery = Substitute.For<IGetOrganizationApiKeyQuery>();
@ -57,7 +59,7 @@ public class OrganizationsControllerTests : IDisposable
_updateOrganizationLicenseCommand = Substitute.For<IUpdateOrganizationLicenseCommand>();
_sut = new OrganizationsController(_organizationRepository, _organizationUserRepository,
_policyRepository, _organizationService, _userService, _paymentService, _currentContext,
_policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext,
_ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand,
_createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand,
_cloudGetOrganizationLicenseQuery, _globalSettings);

View File

@ -214,4 +214,40 @@ public class EventServiceTests
await sutProvider.GetDependency<IEventWriteService>().Received(1).CreateManyAsync(Arg.Is(AssertHelper.AssertPropertyEqual<IEvent>(expected, new[] { "IdempotencyId" })));
}
[Theory, BitAutoData]
public async Task LogProviderOrganizationEventsAsync_LogsRequiredInfo(Provider provider, ICollection<ProviderOrganization> providerOrganizations, EventType eventType, DateTime date,
Guid actingUserId, Guid providerId, string ipAddress, DeviceType deviceType, SutProvider<EventService> sutProvider)
{
foreach (var providerOrganization in providerOrganizations)
{
providerOrganization.ProviderId = provider.Id;
}
var providerAbilities = new Dictionary<Guid, ProviderAbility>()
{
{ provider.Id, new ProviderAbility() { UseEvents = true, Enabled = true } }
};
sutProvider.GetDependency<IApplicationCacheService>().GetProviderAbilitiesAsync().Returns(providerAbilities);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
sutProvider.GetDependency<ICurrentContext>().IpAddress.Returns(ipAddress);
sutProvider.GetDependency<ICurrentContext>().DeviceType.Returns(deviceType);
sutProvider.GetDependency<ICurrentContext>().ProviderIdForOrg(Arg.Any<Guid>()).Returns(providerId);
await sutProvider.Sut.LogProviderOrganizationEventsAsync(providerOrganizations.Select(po => (po, eventType, (DateTime?)date)));
var expected = providerOrganizations.Select(po =>
new EventMessage()
{
DeviceType = deviceType,
IpAddress = ipAddress,
ProviderId = provider.Id,
ProviderOrganizationId = po.Id,
Type = eventType,
ActingUserId = actingUserId,
Date = date
}).ToList();
await sutProvider.GetDependency<IEventWriteService>().Received(1).CreateManyAsync(Arg.Is(AssertHelper.AssertPropertyEqual<IEvent>(expected, new[] { "IdempotencyId" })));
}
}

View File

@ -1,4 +1,6 @@
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Test.AutoFixture.Attributes;
using Bit.Infrastructure.EFIntegration.Test.AutoFixture;
using Bit.Infrastructure.EFIntegration.Test.Repositories.EqualityComparers;
@ -146,4 +148,42 @@ public class OrganizationRepositoryTests
list.Concat(await sqlOrganizationRepo.GetManyAbilitiesAsync());
Assert.True(list.All(x => x.GetType() == typeof(OrganizationAbility)));
}
[CiSkippedTheory, EfOrganizationUserAutoData]
public async void SearchUnassignedAsync_Works(OrganizationUser orgUser, User user, Organization org,
List<EfRepo.OrganizationUserRepository> efOrgUserRepos, List<EfRepo.OrganizationRepository> efOrgRepos, List<EfRepo.UserRepository> efUserRepos,
SqlRepo.OrganizationUserRepository sqlOrgUserRepo, SqlRepo.OrganizationRepository sqlOrgRepo, SqlRepo.UserRepository sqlUserRepo)
{
orgUser.Type = OrganizationUserType.Owner;
org.PlanType = PlanType.EnterpriseAnnually;
var efList = new List<Organization>();
foreach (var efOrgUserRepo in efOrgUserRepos)
{
var i = efOrgUserRepos.IndexOf(efOrgUserRepo);
var postEfUser = await efUserRepos[i].CreateAsync(user);
var postEfOrg = await efOrgRepos[i].CreateAsync(org);
efOrgUserRepo.ClearChangeTracking();
orgUser.UserId = postEfUser.Id;
orgUser.OrganizationId = postEfOrg.Id;
await efOrgUserRepo.CreateAsync(orgUser);
efOrgUserRepo.ClearChangeTracking();
efList.AddRange(await efOrgRepos[i].SearchUnassignedToProviderAsync(org.Name, user.Email, 0, 10));
}
var postSqlUser = await sqlUserRepo.CreateAsync(user);
var postSqlOrg = await sqlOrgRepo.CreateAsync(org);
orgUser.UserId = postSqlUser.Id;
orgUser.OrganizationId = postSqlOrg.Id;
await sqlOrgUserRepo.CreateAsync(orgUser);
var sqlResult = await sqlOrgRepo.SearchUnassignedToProviderAsync(org.Name, user.Email, 0, 10);
Assert.Equal(efOrgRepos.Count, efList.Count);
Assert.True(efList.All(o => o.Name == org.Name));
Assert.Equal(1, sqlResult.Count);
Assert.True(sqlResult.All(o => o.Name == org.Name));
}
}

View File

@ -0,0 +1,291 @@
--Add column 'Status' to 'Organization' table
IF COL_LENGTH('[dbo].[Organization]', 'Status') IS NULL
BEGIN
ALTER TABLE
[dbo].[Organization]
ADD
[Status] TINYINT NOT NULL CONSTRAINT [DF_Organization_Status] DEFAULT (1)
END
GO
--Insert value in column 'Status'
CREATE OR ALTER PROCEDURE [dbo].[Organization_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@Identifier NVARCHAR(50),
@Name NVARCHAR(50),
@BusinessName NVARCHAR(50),
@BusinessAddress1 NVARCHAR(50),
@BusinessAddress2 NVARCHAR(50),
@BusinessAddress3 NVARCHAR(50),
@BusinessCountry VARCHAR(2),
@BusinessTaxNumber NVARCHAR(30),
@BillingEmail NVARCHAR(256),
@Plan NVARCHAR(50),
@PlanType TINYINT,
@Seats INT,
@MaxCollections SMALLINT,
@UsePolicies BIT,
@UseSso BIT,
@UseGroups BIT,
@UseDirectory BIT,
@UseEvents BIT,
@UseTotp BIT,
@Use2fa BIT,
@UseApi BIT,
@UseResetPassword BIT,
@SelfHost BIT,
@UsersGetPremium BIT,
@Storage BIGINT,
@MaxStorageGb SMALLINT,
@Gateway TINYINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ReferenceData VARCHAR(MAX),
@Enabled BIT,
@LicenseKey VARCHAR(100),
@PublicKey VARCHAR(MAX),
@PrivateKey VARCHAR(MAX),
@TwoFactorProviders NVARCHAR(MAX),
@ExpirationDate DATETIME2(7),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT,
@UseKeyConnector BIT = 0,
@UseScim BIT = 0,
@UseCustomPermissions BIT = 0,
@UseSecretsManager BIT = 0,
@Status TINYINT = 0
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Organization]
(
[Id],
[Identifier],
[Name],
[BusinessName],
[BusinessAddress1],
[BusinessAddress2],
[BusinessAddress3],
[BusinessCountry],
[BusinessTaxNumber],
[BillingEmail],
[Plan],
[PlanType],
[Seats],
[MaxCollections],
[UsePolicies],
[UseSso],
[UseGroups],
[UseDirectory],
[UseEvents],
[UseTotp],
[Use2fa],
[UseApi],
[UseResetPassword],
[SelfHost],
[UsersGetPremium],
[Storage],
[MaxStorageGb],
[Gateway],
[GatewayCustomerId],
[GatewaySubscriptionId],
[ReferenceData],
[Enabled],
[LicenseKey],
[PublicKey],
[PrivateKey],
[TwoFactorProviders],
[ExpirationDate],
[CreationDate],
[RevisionDate],
[OwnersNotifiedOfAutoscaling],
[MaxAutoscaleSeats],
[UseKeyConnector],
[UseScim],
[UseCustomPermissions],
[UseSecretsManager],
[Status]
)
VALUES
(
@Id,
@Identifier,
@Name,
@BusinessName,
@BusinessAddress1,
@BusinessAddress2,
@BusinessAddress3,
@BusinessCountry,
@BusinessTaxNumber,
@BillingEmail,
@Plan,
@PlanType,
@Seats,
@MaxCollections,
@UsePolicies,
@UseSso,
@UseGroups,
@UseDirectory,
@UseEvents,
@UseTotp,
@Use2fa,
@UseApi,
@UseResetPassword,
@SelfHost,
@UsersGetPremium,
@Storage,
@MaxStorageGb,
@Gateway,
@GatewayCustomerId,
@GatewaySubscriptionId,
@ReferenceData,
@Enabled,
@LicenseKey,
@PublicKey,
@PrivateKey,
@TwoFactorProviders,
@ExpirationDate,
@CreationDate,
@RevisionDate,
@OwnersNotifiedOfAutoscaling,
@MaxAutoscaleSeats,
@UseKeyConnector,
@UseScim,
@UseCustomPermissions,
@UseSecretsManager,
@Status
)
END
GO
--Update column 'Status'
CREATE OR ALTER PROCEDURE [dbo].[Organization_Update]
@Id UNIQUEIDENTIFIER,
@Identifier NVARCHAR(50),
@Name NVARCHAR(50),
@BusinessName NVARCHAR(50),
@BusinessAddress1 NVARCHAR(50),
@BusinessAddress2 NVARCHAR(50),
@BusinessAddress3 NVARCHAR(50),
@BusinessCountry VARCHAR(2),
@BusinessTaxNumber NVARCHAR(30),
@BillingEmail NVARCHAR(256),
@Plan NVARCHAR(50),
@PlanType TINYINT,
@Seats INT,
@MaxCollections SMALLINT,
@UsePolicies BIT,
@UseSso BIT,
@UseGroups BIT,
@UseDirectory BIT,
@UseEvents BIT,
@UseTotp BIT,
@Use2fa BIT,
@UseApi BIT,
@UseResetPassword BIT,
@SelfHost BIT,
@UsersGetPremium BIT,
@Storage BIGINT,
@MaxStorageGb SMALLINT,
@Gateway TINYINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ReferenceData VARCHAR(MAX),
@Enabled BIT,
@LicenseKey VARCHAR(100),
@PublicKey VARCHAR(MAX),
@PrivateKey VARCHAR(MAX),
@TwoFactorProviders NVARCHAR(MAX),
@ExpirationDate DATETIME2(7),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT,
@UseKeyConnector BIT = 0,
@UseScim BIT = 0,
@UseCustomPermissions BIT = 0,
@UseSecretsManager BIT = 0,
@Status TINYINT = 0
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Organization]
SET
[Identifier] = @Identifier,
[Name] = @Name,
[BusinessName] = @BusinessName,
[BusinessAddress1] = @BusinessAddress1,
[BusinessAddress2] = @BusinessAddress2,
[BusinessAddress3] = @BusinessAddress3,
[BusinessCountry] = @BusinessCountry,
[BusinessTaxNumber] = @BusinessTaxNumber,
[BillingEmail] = @BillingEmail,
[Plan] = @Plan,
[PlanType] = @PlanType,
[Seats] = @Seats,
[MaxCollections] = @MaxCollections,
[UsePolicies] = @UsePolicies,
[UseSso] = @UseSso,
[UseGroups] = @UseGroups,
[UseDirectory] = @UseDirectory,
[UseEvents] = @UseEvents,
[UseTotp] = @UseTotp,
[Use2fa] = @Use2fa,
[UseApi] = @UseApi,
[UseResetPassword] = @UseResetPassword,
[SelfHost] = @SelfHost,
[UsersGetPremium] = @UsersGetPremium,
[Storage] = @Storage,
[MaxStorageGb] = @MaxStorageGb,
[Gateway] = @Gateway,
[GatewayCustomerId] = @GatewayCustomerId,
[GatewaySubscriptionId] = @GatewaySubscriptionId,
[ReferenceData] = @ReferenceData,
[Enabled] = @Enabled,
[LicenseKey] = @LicenseKey,
[PublicKey] = @PublicKey,
[PrivateKey] = @PrivateKey,
[TwoFactorProviders] = @TwoFactorProviders,
[ExpirationDate] = @ExpirationDate,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling,
[MaxAutoscaleSeats] = @MaxAutoscaleSeats,
[UseKeyConnector] = @UseKeyConnector,
[UseScim] = @UseScim,
[UseCustomPermissions] = @UseCustomPermissions,
[UseSecretsManager] = @UseSecretsManager,
[Status] = @Status
WHERE
[Id] = @Id
END
GO
--Add column 'Status'
CREATE OR ALTER VIEW [dbo].[ProviderOrganizationOrganizationDetailsView]
AS
SELECT
PO.[Id],
PO.[ProviderId],
PO.[OrganizationId],
O.[Name] OrganizationName,
PO.[Key],
PO.[Settings],
PO.[CreationDate],
PO.[RevisionDate],
(SELECT COUNT(1) FROM [dbo].[OrganizationUser] OU WHERE OU.OrganizationId = PO.OrganizationId AND OU.Status = 2) UserCount,
O.[Seats],
O.[Plan],
O.[Status]
FROM
[dbo].[ProviderOrganization] PO
LEFT JOIN
[dbo].[Organization] O ON O.[Id] = PO.[OrganizationId]
GO

View File

@ -0,0 +1,16 @@
CREATE OR ALTER PROCEDURE [dbo].[Provider_ReadByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
P.*
FROM
[dbo].[ProviderView] P
INNER JOIN
[dbo].[ProviderOrganization] PO ON PO.[ProviderId] = P.[Id]
WHERE
PO.[OrganizationId] = @OrganizationId
END
GO

View File

@ -0,0 +1,82 @@
CREATE OR ALTER VIEW [dbo].[OrganizationUserOrganizationDetailsView]
AS
SELECT
OU.[UserId],
OU.[OrganizationId],
O.[Name],
O.[Enabled],
O.[PlanType],
O.[UsePolicies],
O.[UseSso],
O.[UseKeyConnector],
O.[UseScim],
O.[UseGroups],
O.[UseDirectory],
O.[UseEvents],
O.[UseTotp],
O.[Use2fa],
O.[UseApi],
O.[UseResetPassword],
O.[SelfHost],
O.[UsersGetPremium],
O.[UseCustomPermissions],
O.[UseSecretsManager],
O.[Seats],
O.[MaxCollections],
O.[MaxStorageGb],
O.[Identifier],
OU.[Key],
OU.[ResetPasswordKey],
O.[PublicKey],
O.[PrivateKey],
OU.[Status],
OU.[Type],
SU.[ExternalId] SsoExternalId,
OU.[Permissions],
PO.[ProviderId],
P.[Name] ProviderName,
P.[Type] ProviderType,
SS.[Data] SsoConfig,
OS.[FriendlyName] FamilySponsorshipFriendlyName,
OS.[LastSyncDate] FamilySponsorshipLastSyncDate,
OS.[ToDelete] FamilySponsorshipToDelete,
OS.[ValidUntil] FamilySponsorshipValidUntil,
OU.[AccessSecretsManager]
FROM
[dbo].[OrganizationUser] OU
LEFT JOIN
[dbo].[Organization] O ON O.[Id] = OU.[OrganizationId]
LEFT JOIN
[dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId]
LEFT JOIN
[dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id]
LEFT JOIN
[dbo].[Provider] P ON P.[Id] = PO.[ProviderId]
LEFT JOIN
[dbo].[SsoConfig] SS ON SS.[OrganizationId] = OU.[OrganizationId]
LEFT JOIN
[dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserID] = OU.[Id]
GO
CREATE OR ALTER PROCEDURE [dbo].[ProviderOrganizationProviderDetails_ReadByUserId]
@UserId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
PO.Id,
PO.OrganizationId,
PO.ProviderId,
P.Name as ProviderName,
P.[Type] as ProviderType
FROM
[dbo].[ProviderOrganizationView] PO
INNER JOIN
[dbo].[OrganizationUser] OU ON PO.OrganizationId = OU.OrganizationId
INNER JOIN
[dbo].[Provider] P ON PO.ProviderId = P.Id
WHERE
OU.UserId = @UserId
END
GO

View File

@ -0,0 +1,54 @@
-- Drop existing SPROC
IF OBJECT_ID('[dbo].[Organization_UnassignedToProviderSearch]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Organization_UnassignedToProviderSearch]
END
GO
CREATE PROCEDURE [dbo].[Organization_UnassignedToProviderSearch]
@Name NVARCHAR(50),
@OwnerEmail NVARCHAR(256),
@Skip INT = 0,
@Take INT = 25
WITH RECOMPILE
AS
BEGIN
SET NOCOUNT ON
DECLARE @NameLikeSearch NVARCHAR(55) = '%' + @Name + '%'
DECLARE @OwnerLikeSearch NVARCHAR(55) = @OwnerEmail + '%'
IF @OwnerEmail IS NOT NULL
BEGIN
SELECT
O.*
FROM
[dbo].[OrganizationView] O
INNER JOIN
[dbo].[OrganizationUser] OU ON O.[Id] = OU.[OrganizationId]
INNER JOIN
[dbo].[User] U ON U.[Id] = OU.[UserId]
WHERE
O.[PlanType] >= 8 AND O.[PlanType] <= 11 -- Get 'Team' and 'Enterprise' Organizations
AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id])
AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch)
AND (U.[Email] LIKE @OwnerLikeSearch)
ORDER BY O.[CreationDate] DESC
OFFSET @Skip ROWS
FETCH NEXT @Take ROWS ONLY
END
ELSE
BEGIN
SELECT
O.*
FROM
[dbo].[OrganizationView] O
WHERE
O.[PlanType] >= 8 AND O.[PlanType] <= 11 -- Get 'Team' and 'Enterprise' Organizations
AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id])
AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch)
ORDER BY O.[CreationDate] DESC
OFFSET @Skip ROWS
FETCH NEXT @Take ROWS ONLY
END
END
GO

View File

@ -0,0 +1,19 @@
CREATE OR ALTER PROCEDURE [dbo].[ProviderOrganization_ReadCountByOrganizationIds]
@Ids AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
IF (SELECT COUNT(1) FROM @Ids) < 1
BEGIN
RETURN(-1)
END
SELECT
COUNT(1)
FROM
[dbo].[ProviderOrganizationView]
WHERE
[OrganizationId] IN (SELECT [Id] FROM @Ids)
END
GO

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
public partial class OrganizationStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte>(
name: "Status",
table: "Organization",
type: "tinyint unsigned",
nullable: false,
defaultValue: (byte)1);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Status",
table: "Organization");
}
}

View File

@ -609,6 +609,9 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<bool>("SelfHost")
.HasColumnType("tinyint(1)");
b.Property<byte>("Status")
.HasColumnType("tinyint unsigned");
b.Property<long?>("Storage")
.HasColumnType("bigint");

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
public partial class OrganizationStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte>(
name: "Status",
table: "Organization",
type: "smallint",
nullable: false,
defaultValue: (byte)1);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Status",
table: "Organization");
}
}

View File

@ -540,6 +540,9 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<bool>("SelfHost")
.HasColumnType("boolean");
b.Property<byte>("Status")
.HasColumnType("smallint");
b.Property<long?>("Storage")
.HasColumnType("bigint");

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
public partial class OrganizationStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte>(
name: "Status",
table: "Organization",
type: "INTEGER",
nullable: false,
defaultValue: (byte)1);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Status",
table: "Organization");
}
}

View File

@ -533,6 +533,9 @@ namespace Bit.SqliteMigrations.Migrations
b.Property<bool>("SelfHost")
.HasColumnType("INTEGER");
b.Property<byte>("Status")
.HasColumnType("INTEGER");
b.Property<long?>("Storage")
.HasColumnType("INTEGER");