mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12:49 -05:00
Merge branch 'main' into km/pm-10600
# Conflicts: # src/Core/NotificationHub/NotificationHubPushRegistrationService.cs
This commit is contained in:
@ -5,8 +5,10 @@ using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
@ -53,8 +55,8 @@ public class OrganizationsController : Controller
|
||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationsController(
|
||||
IOrganizationService organizationService,
|
||||
@ -80,8 +82,8 @@ public class OrganizationsController : Controller
|
||||
IServiceAccountRepository serviceAccountRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
|
||||
IFeatureService featureService,
|
||||
IProviderBillingService providerBillingService)
|
||||
IProviderBillingService providerBillingService,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -106,8 +108,8 @@ public class OrganizationsController : Controller
|
||||
_serviceAccountRepository = serviceAccountRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
|
||||
_featureService = featureService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Org_List_View)]
|
||||
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
|
||||
{
|
||||
var organization = await GetOrganization(id, model);
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
TempData["Error"] = "Could not find organization to update.";
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var existingOrganizationData = new Organization
|
||||
{
|
||||
Id = organization.Id,
|
||||
Status = organization.Status,
|
||||
PlanType = organization.PlanType,
|
||||
Seats = organization.Seats
|
||||
};
|
||||
|
||||
UpdateOrganization(organization, model);
|
||||
|
||||
if (organization.UseSecretsManager &&
|
||||
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
||||
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
await HandlePotentialProviderSeatScalingAsync(
|
||||
existingOrganizationData,
|
||||
model);
|
||||
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
||||
{
|
||||
@ -262,9 +285,7 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
if (organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
@ -394,10 +415,9 @@ public class OrganizationsController : Controller
|
||||
|
||||
return Json(null);
|
||||
}
|
||||
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
|
||||
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
|
||||
{
|
||||
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
|
||||
{
|
||||
organization.Enabled = model.Enabled;
|
||||
@ -449,7 +469,62 @@ public class OrganizationsController : Controller
|
||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
||||
}
|
||||
}
|
||||
|
||||
return organization;
|
||||
private async Task HandlePotentialProviderSeatScalingAsync(
|
||||
Organization organization,
|
||||
OrganizationEditModel update)
|
||||
{
|
||||
var scaleMSPOnClientOrganizationUpdate =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
|
||||
|
||||
if (!scaleMSPOnClientOrganizationUpdate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
// No scaling required
|
||||
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
|
||||
organization is not { Status: OrganizationStatusType.Managed } ||
|
||||
!organization.Seats.HasValue ||
|
||||
update is { Seats: null, PlanType: null } ||
|
||||
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
|
||||
(PlanTypesMatch() && SeatsMatch()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only scale the plan
|
||||
if (!PlanTypesMatch() && SeatsMatch())
|
||||
{
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
|
||||
}
|
||||
// Only scale the seats
|
||||
else if (PlanTypesMatch() && !SeatsMatch())
|
||||
{
|
||||
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
||||
}
|
||||
// Scale both
|
||||
else if (!PlanTypesMatch() && !SeatsMatch())
|
||||
{
|
||||
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||
var planTypeAdjustment = organization.Seats.Value;
|
||||
var totalAdjustment = seatAdjustment + planTypeAdjustment;
|
||||
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
bool PlanTypesMatch()
|
||||
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
|
||||
|
||||
bool SeatsMatch()
|
||||
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
|
||||
}
|
||||
}
|
||||
|
@ -282,9 +282,7 @@ public class ProvidersController : Controller
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
@ -340,10 +338,7 @@ public class ProvidersController : Controller
|
||||
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
|
||||
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
|
||||
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
|
||||
}
|
||||
|
@ -103,19 +103,19 @@
|
||||
|
||||
<div class="d-flex mt-4">
|
||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||
<div class="ml-auto d-flex">
|
||||
<div class="ms-auto d-flex">
|
||||
@if (canInitiateTrial && Model.Provider is null)
|
||||
{
|
||||
<button class="btn btn-secondary mr-2" type="button" id="teams-trial">
|
||||
<button class="btn btn-secondary me-2" type="button" id="teams-trial">
|
||||
Teams Trial
|
||||
</button>
|
||||
<button class="btn btn-secondary mr-2" type="button" id="enterprise-trial">
|
||||
<button class="btn btn-secondary me-2" type="button" id="enterprise-trial">
|
||||
Enterprise Trial
|
||||
</button>
|
||||
}
|
||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||
{
|
||||
<button class="btn btn-outline-danger mr-2"
|
||||
<button class="btn btn-outline-danger me-2"
|
||||
onclick="return unlinkProvider('@Model.Organization.Id');">
|
||||
Unlink provider
|
||||
</button>
|
||||
@ -124,7 +124,7 @@
|
||||
{
|
||||
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
|
||||
<input type="hidden" name="AdminEmail" id="AdminEmail" />
|
||||
<button class="btn btn-danger mr-2" type="submit">Request Delete</button>
|
||||
<button class="btn btn-danger me-2" type="submit">Request Delete</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
|
||||
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
|
||||
|
@ -5,21 +5,31 @@
|
||||
|
||||
<h1>Organizations</h1>
|
||||
|
||||
<form class="form-inline mb-2" method="get">
|
||||
<label class="sr-only" asp-for="Name">Name</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name">
|
||||
<label class="sr-only" asp-for="UserEmail">User email</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
||||
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="Name">Name</label>
|
||||
<input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="UserEmail">User email</label>
|
||||
<input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
||||
</div>
|
||||
@if(!Model.SelfHosted)
|
||||
{
|
||||
<label class="sr-only" asp-for="Paid">Customer</label>
|
||||
<select class="form-control mb-2 mr-2" asp-for="Paid" name="paid">
|
||||
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
|
||||
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
|
||||
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
|
||||
</select>
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="Paid">Customer</label>
|
||||
<select class="form-select" asp-for="Paid" name="paid">
|
||||
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
|
||||
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
|
||||
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary" title="Search">
|
||||
<i class="fa fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
@ -68,7 +78,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-smile-o fa-lg fa-fw text-muted" title="Freeloader"></i>
|
||||
<i class="fa fa-smile-o fa-lg fa-fw text-body-secondary" title="Freeloader"></i>
|
||||
}
|
||||
}
|
||||
@if(org.MaxStorageGb.HasValue && org.MaxStorageGb > 1)
|
||||
@ -78,7 +88,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
|
||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-body-secondary"
|
||||
title="No Additional Storage"></i>
|
||||
}
|
||||
@if(org.Enabled)
|
||||
@ -88,7 +98,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Disabled"></i>
|
||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Disabled"></i>
|
||||
}
|
||||
@if(org.TwoFactorIsEnabled())
|
||||
{
|
||||
@ -96,7 +106,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -9,12 +9,18 @@
|
||||
<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 class="row g-3 align-items-center mb-2" method="get" asp-route-id="@providerId">
|
||||
<div class="col">
|
||||
<label class="visually-hidden" asp-for="OrganizationName"></label>
|
||||
<input type="text" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="visually-hidden" asp-for="OrganizationOwnerEmail"></label>
|
||||
<input type="email" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -22,23 +22,23 @@
|
||||
<h1>Create Provider</h1>
|
||||
<form method="post" asp-action="Create">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Type" class="h2"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Type" class="form-label h2"></label>
|
||||
@foreach (var providerType in providerTypes)
|
||||
{
|
||||
var providerTypeValue = (int)providerType;
|
||||
<div class="form-group">
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-check">
|
||||
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label", @for = $"providerType-{providerTypeValue}" })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted align-top", @for = $"providerType-{providerTypeValue}" })
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-body-secondary ps-4", @for = $"providerType-{providerTypeValue}" })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,39 +1,31 @@
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core
|
||||
|
||||
@model CreateMspProviderModel
|
||||
|
||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Managed Service Provider";
|
||||
}
|
||||
|
||||
<h1>Create Managed Service Provider</h1>
|
||||
<div>
|
||||
<form class="form-group" method="post" asp-action="CreateMsp">
|
||||
<form method="post" asp-action="CreateMsp">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="OwnerEmail"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="OwnerEmail" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
|
||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -7,17 +7,17 @@
|
||||
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
|
||||
}
|
||||
|
||||
<h1>Create Multi-organization Enterprise Provider</h1>
|
||||
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
|
||||
<div>
|
||||
<form class="form-group" method="post" asp-action="CreateMultiOrganizationEnterprise">
|
||||
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="OwnerEmail"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="OwnerEmail" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<div class="mb-3">
|
||||
@{
|
||||
var multiOrgPlans = new List<PlanType>
|
||||
{
|
||||
@ -25,19 +25,19 @@
|
||||
PlanType.EnterpriseMonthly
|
||||
};
|
||||
}
|
||||
<label asp-for="Plan"></label>
|
||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||
<label asp-for="Plan" class="form-label"></label>
|
||||
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseSeatMinimum"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="EnterpriseSeatMinimum" class="form-label"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||
<button type="submit" class="btn btn-primary">Create Provider</button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -18,7 +18,7 @@
|
||||
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", 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">
|
||||
<div class="ms-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>
|
||||
|
@ -6,18 +6,18 @@
|
||||
|
||||
<h1>Create Reseller Provider</h1>
|
||||
<div>
|
||||
<form class="form-group" method="post" asp-action="CreateReseller">
|
||||
<form class="mb-3" method="post" asp-action="CreateReseller">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Name"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Name" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="Name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="BusinessName"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="BusinessName" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="BusinessName">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingEmail"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="BillingEmail" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="BillingEmail">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||
|
@ -34,21 +34,21 @@
|
||||
<h2>Billing</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingEmail"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="BillingEmail" class="form-label"></label>
|
||||
<input type="email" class="form-control" asp-for="BillingEmail" readonly='@(!canEdit)'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingPhone"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="BillingPhone" class="form-label"></label>
|
||||
<input type="tel" class="form-control" asp-for="BillingPhone">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
|
||||
@if (Model.Provider.IsBillable())
|
||||
{
|
||||
switch (Model.Provider.Type)
|
||||
{
|
||||
@ -56,58 +56,18 @@
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
|
||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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">
|
||||
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</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">
|
||||
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
@ -116,7 +76,7 @@
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<div class="mb-3">
|
||||
@{
|
||||
var multiOrgPlans = new List<PlanType>
|
||||
{
|
||||
@ -124,15 +84,15 @@
|
||||
PlanType.EnterpriseMonthly
|
||||
};
|
||||
}
|
||||
<label asp-for="Plan"></label>
|
||||
<label asp-for="Plan" class="form-label"></label>
|
||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMinimumSeats"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="EnterpriseMinimumSeats" class="form-label"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
|
||||
</div>
|
||||
</div>
|
||||
@ -141,6 +101,40 @@
|
||||
break;
|
||||
}
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label asp-for="Gateway" class="form-label"></label>
|
||||
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label asp-for="GatewayCustomerId" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
||||
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewayCustomerUrl', '_blank')">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
||||
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewaySubscriptionUrl', '_blank')">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
@await Html.PartialAsync("Organizations", Model)
|
||||
@ -151,21 +145,21 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content rounded">
|
||||
<div class="p-3">
|
||||
<h4 class="font-weight-bolder" id="exampleModalLabel">Request provider deletion</h4>
|
||||
<h4 class="fw-bolder" id="exampleModalLabel">Request provider deletion</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span class="font-weight-light">
|
||||
<span class="fw-light">
|
||||
Enter the email of the provider admin that will receive the request to delete the provider portal.
|
||||
</span>
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<div class="mb-3">
|
||||
<label for="provider-email" class="col-form-label">Provider email</label>
|
||||
<input type="email" class="form-control" id="provider-email">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger btn-pill" onclick="initiateDeleteProvider('@Model.Provider.Id')">Send email request</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -175,21 +169,21 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content rounded">
|
||||
<div class="p-3">
|
||||
<h4 class="font-weight-bolder" id="exampleModalLabel">Delete provider</h4>
|
||||
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span class="font-weight-light">
|
||||
<span class="fw-light">
|
||||
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
|
||||
</span>
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<div class="mb-3">
|
||||
<label for="provider-name" class="col-form-label">Provider name</label>
|
||||
<input type="text" class="form-control" id="provider-name">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -199,12 +193,12 @@
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content rounded">
|
||||
<div class="modal-body">
|
||||
<h4 class="font-weight-bolder">Cannot Delete @Model.Name</h4>
|
||||
<p class="font-weight-lighter">You must unlink all clients before you can delete @Model.Name.</p>
|
||||
<h4 class="fw-bolder">Cannot Delete @Model.Name</h4>
|
||||
<p class="fw-lighter">You must unlink all clients before you can delete @Model.Name.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary btn-pill" data-dismiss="modal">Ok</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary btn-pill" data-bs-dismiss="modal">Ok</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -214,15 +208,14 @@
|
||||
|
||||
<div class="d-flex mt-4">
|
||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||
<div class="ml-auto d-flex">
|
||||
<div class="ms-auto d-flex">
|
||||
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
|
||||
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
|
||||
<button id="requestDeletionBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#requestDeletionModal"></button>
|
||||
|
||||
<button class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
|
||||
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
|
||||
|
||||
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
|
||||
<button class="btn btn-outline-danger ms-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
|
||||
<button id="deleteBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#DeleteModal"></button>
|
||||
|
||||
<button id="linkAccWarningBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#linkedWarningModal"></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@ -11,23 +11,27 @@
|
||||
|
||||
<h1>Providers</h1>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<form class="form-inline mb-2" method="get">
|
||||
<label class="sr-only" asp-for="Name">Name</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name">
|
||||
<label class="sr-only" asp-for="UserEmail">User email</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
||||
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
|
||||
</form>
|
||||
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="Name">Name</label>
|
||||
<input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="UserEmail">User email</label>
|
||||
<input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary" title="Search">
|
||||
<i class="fa fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
@if (canCreateProvider)
|
||||
{
|
||||
<div class="col-auto">
|
||||
<div class="col-auto ms-auto">
|
||||
<a asp-action="Create" class="btn btn-secondary">Create Provider</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
|
@ -24,9 +24,9 @@
|
||||
<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 class="float-end text-nowrap">
|
||||
<a asp-controller="Providers" asp-action="CreateOrganization" asp-route-providerId="@Model.Provider.Id" class="btn btn-sm btn-primary text-decoration-none">New Organization</a>
|
||||
<a asp-controller="Providers" asp-action="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary text-decoration-none">Add Existing Organization</a>
|
||||
</div>
|
||||
}
|
||||
</th>
|
||||
@ -51,16 +51,16 @@
|
||||
@providerOrganization.Status
|
||||
</td>
|
||||
<td>
|
||||
<div class="float-right">
|
||||
<div class="float-end">
|
||||
@if (canUnlinkFromProvider)
|
||||
{
|
||||
<a href="#" class="text-danger float-right" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
|
||||
<a href="#" class="text-danger float-end" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
|
||||
Unlink provider
|
||||
</a>
|
||||
}
|
||||
@if (providerOrganization.Status == OrganizationStatusType.Pending)
|
||||
{
|
||||
<a href="#" class="float-right mr-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
|
||||
<a href="#" class="float-end me-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
|
||||
Resend invitation
|
||||
</a>
|
||||
}
|
||||
|
@ -26,8 +26,8 @@
|
||||
<h2>General</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="Name"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="Name"></label>
|
||||
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required>
|
||||
</div>
|
||||
</div>
|
||||
@ -37,17 +37,17 @@
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label>Client Owner Email</label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client Owner Email</label>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Owners))
|
||||
{
|
||||
<input type="text" class="form-control" asp-for="Owners" readonly="readonly">
|
||||
<input type="text" class="form-control" asp-for="Owners" 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 class="form-text mt-0">This user should be independent of the Provider. If the Provider is disassociated with the organization, this user will maintain ownership of the organization.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -66,8 +66,8 @@
|
||||
<h2>Plan</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="PlanType"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="PlanType"></label>
|
||||
@{
|
||||
var planTypes = Enum.GetValues<PlanType>()
|
||||
.Where(p =>
|
||||
@ -83,12 +83,12 @@
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
<select class="form-control" asp-for="PlanType" asp-items="planTypes" disabled='@(canEditPlan ? null : "disabled")'></select>
|
||||
<select class="form-select" asp-for="PlanType" asp-items="planTypes" disabled='@(canEditPlan ? null : "disabled")'></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="Plan"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="Plan"></label>
|
||||
<input type="text" class="form-control" asp-for="Plan" required readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
@ -172,28 +172,28 @@
|
||||
<h2>Password Manager Configuration</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="Seats"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="Seats"></label>
|
||||
<input type="number" class="form-control" asp-for="Seats" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxCollections"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxCollections"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxCollections" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxStorageGb"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxStorageGb"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxAutoscaleSeats"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxAutoscaleSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
@ -202,32 +202,32 @@
|
||||
|
||||
@if (canViewPlan)
|
||||
{
|
||||
<div id="organization-secrets-configuration" hidden="@(!Model.UseSecretsManager)">
|
||||
<div id="organization-secrets-configuration" @(Model.UseSecretsManager ? null : "lass='d-none'")>
|
||||
<h2>Secrets Manager Configuration</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="SmSeats"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="SmSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="SmSeats" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxAutoscaleSmSeats"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxAutoscaleSmSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSmSeats" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="SmServiceAccounts"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="SmServiceAccounts"></label>
|
||||
<input type="number" class="form-control" asp-for="SmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxAutoscaleSmServiceAccounts"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxAutoscaleSmServiceAccounts"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
@ -240,14 +240,14 @@
|
||||
<h2>Licensing</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="LicenseKey"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="LicenseKey"></label>
|
||||
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="ExpirationDate"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="ExpirationDate"></label>
|
||||
<input type="datetime-local" class="form-control" asp-for="ExpirationDate" readonly='@(!canEditLicensing)' step="1">
|
||||
</div>
|
||||
</div>
|
||||
@ -259,52 +259,46 @@
|
||||
<h2>Billing</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingEmail"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="BillingEmail"></label>
|
||||
<input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly">
|
||||
</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" disabled='@(canEditBilling ? null : "disabled")'
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="Gateway"></label>
|
||||
<select class="form-select" asp-for="Gateway" disabled="@(!canEditBilling)"
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewayCustomerId"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="GatewayCustomerId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
|
||||
@if(canLaunchGateway)
|
||||
{
|
||||
<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>
|
||||
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
|
||||
@if (canLaunchGateway)
|
||||
{
|
||||
<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>
|
||||
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,8 +3,8 @@
|
||||
ViewData["Title"] = "Login";
|
||||
}
|
||||
|
||||
<div class="row justify-content-md-center">
|
||||
<div class="col col-lg-6 col-md-8">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 col-md-8">
|
||||
@if(!string.IsNullOrWhiteSpace(Model.Success))
|
||||
{
|
||||
<div class="alert alert-success" role="alert">@Model.Success</div>
|
||||
@ -19,12 +19,12 @@
|
||||
<form asp-action="" method="post">
|
||||
<input type="hidden" asp-for="ReturnUrl" />
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Email" class="sr-only">Email Address</label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Email" class="visually-hidden">Email Address</label>
|
||||
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
||||
required autofocus>
|
||||
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
||||
<small class="form-text text-muted">We'll email you a secure login link.</small>
|
||||
<div class="form-text">We'll email you a secure login link.</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Continue</button>
|
||||
</form>
|
||||
|
@ -25,22 +25,22 @@ bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
|
||||
</div>
|
||||
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="ProviderIds"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="ProviderIds"></label>
|
||||
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" value="Run" class="btn btn-primary mb-2"/>
|
||||
<div class="mb-3">
|
||||
<input type="submit" value="Run" class="btn btn-primary"/>
|
||||
</div>
|
||||
</form>
|
||||
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="ProviderIds"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="ProviderIds"></label>
|
||||
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" value="See Previous Results" class="btn btn-primary mb-2"/>
|
||||
<div class="mb-3">
|
||||
<input type="submit" value="See Previous Results" class="btn btn-primary"/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
@ -1,4 +1,7 @@
|
||||
@import "webfonts.scss";
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/mixins";
|
||||
|
||||
$primary: #175DDC;
|
||||
$primary-accent: #1252A3;
|
||||
@ -7,7 +10,8 @@ $info: #555555;
|
||||
$warning: #bf7e16;
|
||||
$danger: #dd4b39;
|
||||
|
||||
$theme-colors: ( "primary-accent": $primary-accent );
|
||||
$theme-colors: map-merge($theme-colors, ("primary-accent": $primary-accent));
|
||||
|
||||
$font-family-sans-serif: 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
|
||||
$h1-font-size: 2rem;
|
||||
@ -17,7 +21,7 @@ $h4-font-size: 1rem;
|
||||
$h5-font-size: 1rem;
|
||||
$h6-font-size: 1rem;
|
||||
|
||||
@import "bootstrap/scss/bootstrap.scss";
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
|
||||
h1 {
|
||||
border-bottom: 1px solid $border-color;
|
||||
@ -49,3 +53,11 @@ h3 {
|
||||
.form-check-input {
|
||||
margin-top: .45rem;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
@ -110,7 +110,6 @@ public static class RolePermissionMapping
|
||||
Permission.User_Licensing_View,
|
||||
Permission.User_Billing_View,
|
||||
Permission.User_Billing_LaunchGateway,
|
||||
Permission.User_Delete,
|
||||
Permission.Org_List_View,
|
||||
Permission.Org_OrgInformation_View,
|
||||
Permission.Org_GeneralDetails_View,
|
||||
|
@ -105,7 +105,7 @@
|
||||
<h3>SMTP</h3>
|
||||
@if(!Bit.Core.Utilities.CoreHelpers.SettingHasValue(Model.GlobalSettings.Mail?.Smtp?.Host))
|
||||
{
|
||||
<p class="text-muted">Not configured</p>
|
||||
<p class="text-body-secondary">Not configured</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -159,7 +159,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not configured</span>
|
||||
<span class="text-body-secondary">Not configured</span>
|
||||
}
|
||||
</dd>
|
||||
|
||||
@ -171,7 +171,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not configured</span>
|
||||
<span class="text-body-secondary">Not configured</span>
|
||||
}
|
||||
</dd>
|
||||
|
||||
@ -183,7 +183,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not configured</span>
|
||||
<span class="text-body-secondary">Not configured</span>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
|
@ -37,12 +37,12 @@
|
||||
<a class="navbar-brand" asp-controller="Home" asp-action="Index">
|
||||
<i class="fa fa-lg fa-fw fa-shield"></i> Admin
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
|
||||
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-md-0">
|
||||
@if (SignInManager.IsSignedIn(User))
|
||||
{
|
||||
@if (canViewUsers)
|
||||
@ -69,10 +69,10 @@
|
||||
{
|
||||
<li class="nav-item dropdown" active-controller="tools">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="toolsDropdown" role="button"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
Tools
|
||||
</a>
|
||||
<div class="dropdown-menu" aria-labelledby="toolsDropdown">
|
||||
<ul class="dropdown-menu" aria-labelledby="toolsDropdown">
|
||||
@if (canChargeBraintree)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="Tools" asp-action="ChargeBraintree">
|
||||
@ -121,7 +121,7 @@
|
||||
Migrate Providers
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
@ -10,30 +10,30 @@
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="UserId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="UserId" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="UserId">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="OrganizationId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="OrganizationId" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="OrganizationId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="Date"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Date" class="form-label"></label>
|
||||
<input type="datetime-local" class="form-control" asp-for="Date" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label asp-for="Type"></label>
|
||||
<select class="form-control" asp-for="Type" required
|
||||
<div class="mb-3">
|
||||
<label asp-for="Type" class="form-label"></label>
|
||||
<select class="form-select" asp-for="Type" required
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.TransactionType>()"></select>
|
||||
</div>
|
||||
</div>
|
||||
@ -41,24 +41,20 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="Amount"></label>
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">$</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Amount" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" min="-1000000.00" max="1000000.00" step="0.01" class="form-control"
|
||||
asp-for="Amount" required placeholder="ex. 10.00">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="RefundedAmount"></label>
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">$</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="RefundedAmount" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" min="0.01" max="1000000.00" step="0.01" class="form-control"
|
||||
asp-for="RefundedAmount" placeholder="ex. 10.00">
|
||||
</div>
|
||||
@ -69,39 +65,35 @@
|
||||
<input type="checkbox" class="form-check-input" asp-for="Refunded">
|
||||
<label class="form-check-label" asp-for="Refunded"></label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Details"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Details" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="Details" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<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 class="mb-3">
|
||||
<label asp-for="Gateway" class="form-label"></label>
|
||||
<select class="form-select" asp-for="Gateway"
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewayId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="GatewayId" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="GatewayId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label asp-for="PaymentMethod"></label>
|
||||
<select class="form-control" asp-for="PaymentMethod"
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.PaymentMethodType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="PaymentMethod" class="form-label"></label>
|
||||
<select class="form-select" asp-for="PaymentMethod"
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.PaymentMethodType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,28 +9,28 @@
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="UserId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="UserId" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="UserId">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="OrganizationId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="OrganizationId" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="OrganizationId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="InstallationId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="InstallationId" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="InstallationId">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="Version"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Version" class="form-label"></label>
|
||||
<input type="number" class="form-control" asp-for="Version">
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,17 +9,17 @@
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="UserId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="UserId" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="UserId">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="OrganizationId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="OrganizationId" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="OrganizationId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-2">Promote Admin</button>
|
||||
<button type="submit" class="btn btn-primary">Promote Admin</button>
|
||||
</form>
|
@ -74,208 +74,204 @@
|
||||
<div class="alert alert-success"></div>
|
||||
}
|
||||
<form method="post">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label asp-for="Filter.Status">Status</label>
|
||||
<select asp-for="Filter.Status" name="filter.Status" class="form-control mr-2">
|
||||
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
|
||||
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
|
||||
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-append">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Filter.Status">Status</label>
|
||||
<select asp-for="Filter.Status" name="filter.Status" class="form-select">
|
||||
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
|
||||
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
|
||||
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<span class="mr-1">
|
||||
<input type="radio" class="mr-1" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="lt">Before
|
||||
</span>
|
||||
<input type="radio" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="gt">After
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="lt" id="beforeRadio">
|
||||
<label class="form-check-label me-2" for="beforeRadio">Before</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="gt" id="afterRadio">
|
||||
<label class="form-check-label" for="afterRadio">After</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@{
|
||||
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
|
||||
@{
|
||||
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
|
||||
}
|
||||
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Filter.Price">Price ID</label>
|
||||
<select asp-for="Filter.Price" name="filter.Price" class="form-select">
|
||||
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
|
||||
@foreach (var price in Model.Prices)
|
||||
{
|
||||
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
|
||||
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
|
||||
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
|
||||
@foreach (var clock in Model.TestClocks)
|
||||
{
|
||||
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 text-end">
|
||||
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search">
|
||||
<i class="fa fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-6">
|
||||
<label asp-for="Filter.Price">Price ID</label>
|
||||
<select asp-for="Filter.Price" name="filter.Price" class="form-control mr-2">
|
||||
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
|
||||
@foreach (var price in Model.Prices)
|
||||
{
|
||||
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
|
||||
}
|
||||
</select>
|
||||
<hr/>
|
||||
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
|
||||
<div class="text-center row d-flex justify-content-center">
|
||||
<div id="selectAll" class="d-none col-8">
|
||||
All @Model.Items.Count subscriptions on this page are selected.<br/>
|
||||
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
|
||||
<span id="selectedAllConfirmation" class="d-none text-body-secondary">
|
||||
<i class="fa fa-check"></i> All subscriptions for this search are selected.
|
||||
</span>
|
||||
<div class="alert alert-warning mt-2" role="alert">
|
||||
Please be aware that bulk operations may take several minutes to complete.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label asp-for="Filter.TestClock">Test Clock</label>
|
||||
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-control mr-2">
|
||||
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
|
||||
@foreach (var clock in Model.TestClocks)
|
||||
{
|
||||
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row col-12 d-flex justify-content-end my-3">
|
||||
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search"><i class="fa fa-search"></i> Search</button>
|
||||
</div>
|
||||
<hr/>
|
||||
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
|
||||
<div class="text-center row d-flex justify-content-center">
|
||||
<div id="selectAll" class="d-none col-8">
|
||||
All @Model.Items.Count subscriptions on this page are selected.<br/>
|
||||
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
|
||||
<span id="selectedAllConfirmation" class="d-none text-muted">✔ All subscriptions for this search are selected.</span><br/>
|
||||
<div class="alert alert-warning" role="alert">Please be aware that bulk operations may take several minutes to complete.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="form-check form-check-inline">
|
||||
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
|
||||
</div>
|
||||
</th>
|
||||
<th>Id</th>
|
||||
<th>Customer Email</th>
|
||||
<th>Status</th>
|
||||
<th>Product Tier</th>
|
||||
<th>Current Period End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colspan="6">No results to list.</td>
|
||||
<th>
|
||||
<div class="form-check">
|
||||
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
|
||||
</div>
|
||||
</th>
|
||||
<th>Id</th>
|
||||
<th>Customer Email</th>
|
||||
<th>Status</th>
|
||||
<th>Product Tier</th>
|
||||
<th>Current Period End</th>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@for (var i = 0; i < Model.Items.Count; i++)
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@{
|
||||
var i0 = i;
|
||||
}
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
|
||||
|
||||
@for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
|
||||
{
|
||||
var i1 = i;
|
||||
var j1 = j;
|
||||
<input
|
||||
type="hidden"
|
||||
asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
|
||||
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
|
||||
}
|
||||
<div class="form-check">
|
||||
|
||||
@{
|
||||
var i2 = i;
|
||||
}
|
||||
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Id
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Customer?.Email
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Status
|
||||
</td>
|
||||
<td>
|
||||
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
|
||||
</td>
|
||||
<td colspan="6">No results to list.</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav class="d-inline-flex">
|
||||
<ul class="pagination">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
|
||||
{
|
||||
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
|
||||
<li class="page-item">
|
||||
<button
|
||||
type="submit"
|
||||
class="page-link"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.PreviousPage">
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
|
||||
{
|
||||
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
|
||||
<li class="page-item">
|
||||
<button class="page-link"
|
||||
else
|
||||
{
|
||||
@for (var i = 0; i < Model.Items.Count; i++)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@{
|
||||
var i0 = i;
|
||||
}
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
|
||||
|
||||
@for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
|
||||
{
|
||||
var i1 = i;
|
||||
var j1 = j;
|
||||
<input
|
||||
type="hidden"
|
||||
asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
|
||||
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
|
||||
}
|
||||
<div class="form-check">
|
||||
|
||||
@{
|
||||
var i2 = i;
|
||||
}
|
||||
<input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Id
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Customer?.Email
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Status
|
||||
</td>
|
||||
<td>
|
||||
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav class="d-inline-flex align-items-center">
|
||||
<ul class="pagination mb-0">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
|
||||
{
|
||||
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
|
||||
<li class="page-item">
|
||||
<button
|
||||
type="submit"
|
||||
class="page-link"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.NextPage">
|
||||
Next
|
||||
value="@StripeSubscriptionsAction.PreviousPage">
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
|
||||
{
|
||||
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
|
||||
<li class="page-item">
|
||||
<button class="page-link"
|
||||
type="submit"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.NextPage">
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Next</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<span id="bulkActions" class="d-none ms-3">
|
||||
<span class="d-inline-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
|
||||
Export
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Next</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<span id="bulkActions" class="d-none ml-2">
|
||||
<span class="d-inline-flex">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary mr-1"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.Export">
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-danger"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.BulkCancel">
|
||||
Bulk Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
|
||||
Bulk Cancel
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</nav>
|
||||
</nav>
|
||||
</form>
|
||||
|
@ -27,20 +27,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" asp-action="TaxRateUpload">
|
||||
<div class="form-group">
|
||||
<input type="file" name="file" />
|
||||
<div class="mb-3">
|
||||
<input type="file" class="form-control" name="file" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" value="Upload" class="btn btn-primary mb-2" />
|
||||
<div class="mb-3">
|
||||
<input type="submit" value="Upload" class="btn btn-primary" />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<hr/>
|
||||
<hr class="my-4">
|
||||
<h2>View & Manage Tax Rates</h2>
|
||||
<a class="btn btn-primary mb-2" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a>
|
||||
<a class="btn btn-primary mb-3" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 190px;">Id</th>
|
||||
@ -97,7 +97,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<nav aria-label="Tax rates pagination">
|
||||
<ul class="pagination">
|
||||
@if(Model.PreviousPage.HasValue)
|
||||
{
|
||||
|
@ -115,8 +115,8 @@
|
||||
<h2>Premium</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxStorageGb"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="MaxStorageGb" class="form-label"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPremium)'>
|
||||
</div>
|
||||
</div>
|
||||
@ -131,14 +131,14 @@
|
||||
<h2>Licensing</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="LicenseKey"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="LicenseKey" class="form-label"></label>
|
||||
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="PremiumExpirationDate"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="PremiumExpirationDate" class="form-label"></label>
|
||||
<input type="datetime-local" class="form-control" asp-for="PremiumExpirationDate" readonly='@(!canEditLicensing)'>
|
||||
</div>
|
||||
</div>
|
||||
@ -149,44 +149,38 @@
|
||||
<h2>Billing</h2>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label asp-for="Gateway"></label>
|
||||
<select class="form-control" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Gateway" class="form-label"></label>
|
||||
<select class="form-select" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewayCustomerId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="GatewayCustomerId" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
|
||||
@if (canLaunchGateway)
|
||||
{
|
||||
<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>
|
||||
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
|
||||
@if (canLaunchGateway)
|
||||
{
|
||||
<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>
|
||||
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@ -196,10 +190,10 @@
|
||||
</form>
|
||||
<div class="d-flex mt-4">
|
||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||
<div class="ml-auto d-flex">
|
||||
<div class="ms-auto d-flex">
|
||||
@if (canUpgradePremium)
|
||||
{
|
||||
<button class="btn btn-secondary mr-2" type="button" id="upgrade-premium">
|
||||
<button class="btn btn-secondary me-2" type="button" id="upgrade-premium">
|
||||
Upgrade Premium
|
||||
</button>
|
||||
}
|
||||
|
@ -5,10 +5,16 @@
|
||||
|
||||
<h1>Users</h1>
|
||||
|
||||
<form class="form-inline mb-2" method="get">
|
||||
<label class="sr-only" asp-for="Email">Email</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="Email" asp-for="Email" name="email">
|
||||
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
|
||||
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="Email">Email</label>
|
||||
<input type="text" class="form-control" placeholder="Email" asp-for="Email" name="email">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary" title="Search">
|
||||
<i class="fa fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
@ -49,7 +55,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-star-o fa-lg fa-fw text-muted" title="Not Premium"></i>
|
||||
<i class="fa fa-star-o fa-lg fa-fw text-body-secondary" title="Not Premium"></i>
|
||||
}
|
||||
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
|
||||
{
|
||||
@ -59,7 +65,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
|
||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-body-secondary"
|
||||
title="No Additional Storage">
|
||||
</i>
|
||||
}
|
||||
@ -69,7 +75,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
|
||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Email Not Verified"></i>
|
||||
}
|
||||
@if (user.TwoFactorEnabled)
|
||||
{
|
||||
@ -77,7 +83,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
35
src/Admin/package-lock.json
generated
35
src/Admin/package-lock.json
generated
@ -9,10 +9,9 @@
|
||||
"version": "0.0.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"bootstrap": "4.6.2",
|
||||
"bootstrap": "5.3.3",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"popper.js": "1.16.1",
|
||||
"toastr": "2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -385,6 +384,17 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
@ -703,9 +713,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
|
||||
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -716,10 +726,8 @@
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"jquery": "1.9.1 - 3",
|
||||
"popper.js": "^1.16.1"
|
||||
"@popperjs/core": "^2.11.8"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@ -1578,17 +1586,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/popper.js": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
|
||||
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.47",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
||||
|
@ -8,10 +8,9 @@
|
||||
"build": "webpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "4.6.2",
|
||||
"bootstrap": "5.3.3",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"popper.js": "1.16.1",
|
||||
"toastr": "2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -13,8 +13,6 @@ module.exports = {
|
||||
entry: {
|
||||
site: [
|
||||
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
||||
|
||||
"popper.js",
|
||||
"bootstrap",
|
||||
"jquery",
|
||||
"font-awesome/css/font-awesome.css",
|
||||
|
@ -2,15 +2,16 @@
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -89,11 +90,34 @@ public class GroupsController : Controller
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId)
|
||||
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroups(Guid orgId)
|
||||
{
|
||||
var authorized =
|
||||
(await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded;
|
||||
if (!authorized)
|
||||
var authResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails))
|
||||
{
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
||||
var responses = groups.Select(g => new GroupDetailsResponseModel(g, []));
|
||||
return new ListResponseModel<GroupDetailsResponseModel>(responses);
|
||||
}
|
||||
|
||||
var groupDetails = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId);
|
||||
var detailResponses = groupDetails.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2));
|
||||
return new ListResponseModel<GroupDetailsResponseModel>(detailResponses);
|
||||
}
|
||||
|
||||
[HttpGet("details")]
|
||||
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroupDetails(Guid orgId)
|
||||
{
|
||||
var authResult = _featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails)
|
||||
? await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAllDetails)
|
||||
: await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
|
@ -252,6 +252,12 @@ public class OrganizationsController : Controller
|
||||
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id))
|
||||
{
|
||||
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.");
|
||||
}
|
||||
|
||||
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
|
||||
}
|
||||
|
||||
@ -283,9 +289,7 @@ public class OrganizationsController : Controller
|
||||
throw new BadRequestException(string.Empty, "User verification failed.");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
if (organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
@ -316,8 +320,7 @@ public class OrganizationsController : Controller
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
if (organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
if (provider.IsBillable())
|
||||
@ -351,7 +354,7 @@ public class OrganizationsController : Controller
|
||||
{
|
||||
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
|
||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||
if (plan.ProductTier != ProductTierType.Enterprise)
|
||||
if (plan.ProductTier is not ProductTierType.Enterprise and not ProductTierType.Teams)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
@ -1,7 +1,11 @@
|
||||
using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
@ -16,7 +20,6 @@ using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
@ -32,6 +35,8 @@ public class PoliciesController : Controller
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IDataProtector _organizationServiceDataProtector;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||
|
||||
public PoliciesController(
|
||||
IPolicyRepository policyRepository,
|
||||
@ -41,7 +46,9 @@ public class PoliciesController : Controller
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IFeatureService featureService,
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||
{
|
||||
_policyRepository = policyRepository;
|
||||
_policyService = policyService;
|
||||
@ -53,10 +60,12 @@ public class PoliciesController : Controller
|
||||
"OrganizationServiceDataProtector");
|
||||
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_featureService = featureService;
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
}
|
||||
|
||||
[HttpGet("{type}")]
|
||||
public async Task<PolicyResponseModel> Get(Guid orgId, int type)
|
||||
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
|
||||
{
|
||||
if (!await _currentContext.ManagePolicies(orgId))
|
||||
{
|
||||
@ -65,10 +74,15 @@ public class PoliciesController : Controller
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
||||
if (policy == null)
|
||||
{
|
||||
return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false });
|
||||
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
|
||||
}
|
||||
|
||||
return new PolicyResponseModel(policy);
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg)
|
||||
{
|
||||
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
|
||||
}
|
||||
|
||||
return new PolicyDetailResponseModel(policy);
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@ -81,8 +95,8 @@ public class PoliciesController : Controller
|
||||
}
|
||||
|
||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
|
||||
var responses = policies.Select(p => new PolicyResponseModel(p));
|
||||
return new ListResponseModel<PolicyResponseModel>(responses);
|
||||
|
||||
return new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p)));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
|
@ -0,0 +1,19 @@
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||
|
||||
public static class PolicyDetailResponses
|
||||
{
|
||||
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
|
||||
{
|
||||
if (policy.Type is not PolicyType.SingleOrg)
|
||||
{
|
||||
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
|
||||
}
|
||||
|
||||
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId));
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public class PolicyDetailResponseModel : PolicyResponseModel
|
||||
{
|
||||
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
|
||||
{
|
||||
}
|
||||
|
||||
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
|
||||
{
|
||||
CanToggleState = canToggleState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the Policy can be enabled/disabled
|
||||
/// </summary>
|
||||
public bool CanToggleState { get; set; } = true;
|
||||
}
|
@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Api.Response;
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public class PolicyResponseModel : ResponseModel
|
||||
{
|
@ -41,14 +41,13 @@ public class PoliciesController : Controller
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Get(PolicyType type)
|
||||
{
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(
|
||||
_currentContext.OrganizationId.Value, type);
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(_currentContext.OrganizationId.Value, type);
|
||||
if (policy == null)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
var response = new PolicyResponseModel(policy);
|
||||
return new JsonResult(response);
|
||||
|
||||
return new JsonResult(new PolicyResponseModel(policy));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -62,9 +61,8 @@ public class PoliciesController : Controller
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
|
||||
var policyResponses = policies.Select(p => new PolicyResponseModel(p));
|
||||
var response = new ListResponseModel<PolicyResponseModel>(policyResponses);
|
||||
return new JsonResult(response);
|
||||
|
||||
return new JsonResult(new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -18,7 +18,6 @@ public abstract class MemberBaseModel
|
||||
|
||||
Type = user.Type;
|
||||
ExternalId = user.ExternalId;
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
|
||||
if (Type == OrganizationUserType.Custom)
|
||||
{
|
||||
@ -35,7 +34,6 @@ public abstract class MemberBaseModel
|
||||
|
||||
Type = user.Type;
|
||||
ExternalId = user.ExternalId;
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
|
||||
if (Type == OrganizationUserType.Custom)
|
||||
{
|
||||
@ -55,11 +53,7 @@ public abstract class MemberBaseModel
|
||||
/// <example>external_id_123456</example>
|
||||
[StringLength(300)]
|
||||
public string ExternalId { get; set; }
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool ResetPasswordEnrolled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
||||
/// default to false.
|
||||
|
@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
||||
Email = user.Email;
|
||||
Status = user.Status;
|
||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
}
|
||||
|
||||
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
|
||||
@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
||||
TwoFactorEnabled = twoFactorEnabled;
|
||||
Status = user.Status;
|
||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
||||
/// The associated collections that this member can access.
|
||||
/// </summary>
|
||||
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool ResetPasswordEnrolled { get; }
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
|
@ -1,10 +1,10 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.Auth.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
|
@ -4,15 +4,14 @@ using Bit.Api.Auth.Models.Response.TwoFactor;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Utilities;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Utilities;
|
||||
using Fido2NetLib;
|
||||
@ -29,11 +28,10 @@ public class TwoFactorController : Controller
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly UserManager<User> _userManager;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IDuoUniversalTokenService _duoUniversalTokenService;
|
||||
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
|
||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
|
||||
|
||||
@ -41,22 +39,20 @@ public class TwoFactorController : Controller
|
||||
IUserService userService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationService organizationService,
|
||||
GlobalSettings globalSettings,
|
||||
UserManager<User> userManager,
|
||||
ICurrentContext currentContext,
|
||||
IVerifyAuthRequestCommand verifyAuthRequestCommand,
|
||||
IFeatureService featureService,
|
||||
IDuoUniversalTokenService duoUniversalConfigService,
|
||||
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector)
|
||||
{
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
_globalSettings = globalSettings;
|
||||
_userManager = userManager;
|
||||
_currentContext = currentContext;
|
||||
_verifyAuthRequestCommand = verifyAuthRequestCommand;
|
||||
_featureService = featureService;
|
||||
_duoUniversalTokenService = duoUniversalConfigService;
|
||||
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
|
||||
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
|
||||
}
|
||||
@ -184,21 +180,7 @@ public class TwoFactorController : Controller
|
||||
public async Task<TwoFactorDuoResponseModel> PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, true);
|
||||
try
|
||||
{
|
||||
// for backwards compatibility - will be removed with PM-8107
|
||||
DuoApi duoApi = null;
|
||||
if (model.ClientId != null && model.ClientSecret != null)
|
||||
{
|
||||
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
|
||||
}
|
||||
else
|
||||
{
|
||||
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
|
||||
}
|
||||
await duoApi.JSONApiCall("GET", "/auth/v2/check");
|
||||
}
|
||||
catch (DuoException)
|
||||
if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
|
||||
@ -241,21 +223,7 @@ public class TwoFactorController : Controller
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException();
|
||||
try
|
||||
{
|
||||
// for backwards compatibility - will be removed with PM-8107
|
||||
DuoApi duoApi = null;
|
||||
if (model.ClientId != null && model.ClientSecret != null)
|
||||
{
|
||||
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
|
||||
}
|
||||
else
|
||||
{
|
||||
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
|
||||
}
|
||||
await duoApi.JSONApiCall("GET", "/auth/v2/check");
|
||||
}
|
||||
catch (DuoException)
|
||||
if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
|
||||
|
@ -2,8 +2,8 @@
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Utilities;
|
||||
using Bit.Core.Entities;
|
||||
using Fido2NetLib;
|
||||
|
||||
@ -43,21 +43,16 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques
|
||||
public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject
|
||||
{
|
||||
/*
|
||||
To support both v2 and v4 we need to remove the required annotation from the properties.
|
||||
todo - the required annotation will be added back in PM-8107.
|
||||
String lengths based on Duo's documentation
|
||||
https://github.com/duosecurity/duo_universal_csharp/blob/main/DuoUniversal/Client.cs
|
||||
*/
|
||||
[StringLength(50)]
|
||||
public string ClientId { get; set; }
|
||||
[StringLength(50)]
|
||||
public string ClientSecret { get; set; }
|
||||
//todo - will remove SKey and IKey with PM-8107
|
||||
[StringLength(50)]
|
||||
public string IntegrationKey { get; set; }
|
||||
//todo - will remove SKey and IKey with PM-8107
|
||||
[StringLength(50)]
|
||||
public string SecretKey { get; set; }
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
[StringLength(20, MinimumLength = 20, ErrorMessage = "Client Id must be exactly 20 characters.")]
|
||||
public string ClientId { get; set; }
|
||||
[Required]
|
||||
[StringLength(40, MinimumLength = 40, ErrorMessage = "Client Secret must be exactly 40 characters.")]
|
||||
public string ClientSecret { get; set; }
|
||||
[Required]
|
||||
public string Host { get; set; }
|
||||
|
||||
public User ToUser(User existingUser)
|
||||
@ -65,22 +60,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
|
||||
var providers = existingUser.GetTwoFactorProviders();
|
||||
if (providers == null)
|
||||
{
|
||||
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
providers = [];
|
||||
}
|
||||
else if (providers.ContainsKey(TwoFactorProviderType.Duo))
|
||||
{
|
||||
providers.Remove(TwoFactorProviderType.Duo);
|
||||
}
|
||||
|
||||
Temporary_SyncDuoParams();
|
||||
|
||||
providers.Add(TwoFactorProviderType.Duo, new TwoFactorProvider
|
||||
{
|
||||
MetaData = new Dictionary<string, object>
|
||||
{
|
||||
//todo - will remove SKey and IKey with PM-8107
|
||||
["SKey"] = SecretKey,
|
||||
["IKey"] = IntegrationKey,
|
||||
["ClientSecret"] = ClientSecret,
|
||||
["ClientId"] = ClientId,
|
||||
["Host"] = Host
|
||||
@ -96,22 +86,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
|
||||
var providers = existingOrg.GetTwoFactorProviders();
|
||||
if (providers == null)
|
||||
{
|
||||
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
providers = [];
|
||||
}
|
||||
else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo))
|
||||
{
|
||||
providers.Remove(TwoFactorProviderType.OrganizationDuo);
|
||||
}
|
||||
|
||||
Temporary_SyncDuoParams();
|
||||
|
||||
providers.Add(TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider
|
||||
{
|
||||
MetaData = new Dictionary<string, object>
|
||||
{
|
||||
//todo - will remove SKey and IKey with PM-8107
|
||||
["SKey"] = SecretKey,
|
||||
["IKey"] = IntegrationKey,
|
||||
["ClientSecret"] = ClientSecret,
|
||||
["ClientId"] = ClientId,
|
||||
["Host"] = Host
|
||||
@ -124,34 +109,22 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
|
||||
|
||||
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (!DuoApi.ValidHost(Host))
|
||||
var results = new List<ValidationResult>();
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
yield return new ValidationResult("Host is invalid.", [nameof(Host)]);
|
||||
results.Add(new ValidationResult("ClientId is required.", [nameof(ClientId)]));
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId) &&
|
||||
string.IsNullOrWhiteSpace(SecretKey) && string.IsNullOrWhiteSpace(IntegrationKey))
|
||||
{
|
||||
yield return new ValidationResult("Neither v2 or v4 values are valid.", [nameof(IntegrationKey), nameof(SecretKey), nameof(ClientSecret), nameof(ClientId)]);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
use this method to ensure that both v2 params and v4 params are in sync
|
||||
todo will be removed in pm-8107
|
||||
*/
|
||||
private void Temporary_SyncDuoParams()
|
||||
{
|
||||
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
|
||||
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
|
||||
if (string.IsNullOrWhiteSpace(ClientSecret))
|
||||
{
|
||||
SecretKey = ClientSecret;
|
||||
IntegrationKey = ClientId;
|
||||
results.Add(new ValidationResult("ClientSecret is required.", [nameof(ClientSecret)]));
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Host) || !DuoUniversalTokenService.ValidDuoHost(Host))
|
||||
{
|
||||
ClientSecret = SecretKey;
|
||||
ClientId = IntegrationKey;
|
||||
results.Add(new ValidationResult("Host is invalid.", [nameof(Host)]));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,37 +13,26 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
||||
public TwoFactorDuoResponseModel(User user)
|
||||
: base(ResponseObj)
|
||||
{
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
Build(provider);
|
||||
}
|
||||
|
||||
public TwoFactorDuoResponseModel(Organization org)
|
||||
public TwoFactorDuoResponseModel(Organization organization)
|
||||
: base(ResponseObj)
|
||||
{
|
||||
if (org == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(org));
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
|
||||
var provider = org.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
Build(provider);
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string Host { get; set; }
|
||||
//TODO - will remove SecretKey with PM-8107
|
||||
public string SecretKey { get; set; }
|
||||
//TODO - will remove IntegrationKey with PM-8107
|
||||
public string IntegrationKey { get; set; }
|
||||
public string ClientSecret { get; set; }
|
||||
public string ClientId { get; set; }
|
||||
|
||||
// updated build to assist in the EDD migration for the Duo 2FA provider
|
||||
private void Build(TwoFactorProvider provider)
|
||||
{
|
||||
if (provider?.MetaData != null && provider.MetaData.Count > 0)
|
||||
@ -54,36 +43,13 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
||||
{
|
||||
Host = (string)host;
|
||||
}
|
||||
|
||||
//todo - will remove SKey and IKey with PM-8107
|
||||
// check Skey and IKey first if they exist
|
||||
if (provider.MetaData.TryGetValue("SKey", out var sKey))
|
||||
{
|
||||
ClientSecret = MaskKey((string)sKey);
|
||||
SecretKey = MaskKey((string)sKey);
|
||||
}
|
||||
if (provider.MetaData.TryGetValue("IKey", out var iKey))
|
||||
{
|
||||
IntegrationKey = (string)iKey;
|
||||
ClientId = (string)iKey;
|
||||
}
|
||||
|
||||
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
|
||||
if (provider.MetaData.TryGetValue("ClientSecret", out var clientSecret))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace((string)clientSecret))
|
||||
{
|
||||
ClientSecret = MaskKey((string)clientSecret);
|
||||
SecretKey = MaskKey((string)clientSecret);
|
||||
}
|
||||
ClientSecret = MaskSecret((string)clientSecret);
|
||||
}
|
||||
if (provider.MetaData.TryGetValue("ClientId", out var clientId))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace((string)clientId))
|
||||
{
|
||||
ClientId = (string)clientId;
|
||||
IntegrationKey = (string)clientId;
|
||||
}
|
||||
ClientId = (string)clientId;
|
||||
}
|
||||
}
|
||||
else
|
||||
@ -92,30 +58,7 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
use this method to ensure that both v2 params and v4 params are in sync
|
||||
todo will be removed in pm-8107
|
||||
*/
|
||||
private void Temporary_SyncDuoParams()
|
||||
{
|
||||
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
|
||||
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
SecretKey = ClientSecret;
|
||||
IntegrationKey = ClientId;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
|
||||
{
|
||||
ClientSecret = SecretKey;
|
||||
ClientId = IntegrationKey;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidDataException("Invalid Duo parameters.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string MaskKey(string key)
|
||||
private static string MaskSecret(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Length <= 6)
|
||||
{
|
||||
|
@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
|
||||
new ErrorResponseModel(message),
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||
TypedResults.Json(
|
||||
new ErrorResponseModel("Unauthorized."),
|
||||
new ErrorResponseModel(message),
|
||||
statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Context;
|
||||
@ -9,7 +8,6 @@ namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
public abstract class BaseProviderController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IProviderRepository providerRepository,
|
||||
IUserService userService) : BaseBillingController
|
||||
@ -26,15 +24,6 @@ public abstract class BaseProviderController(
|
||||
Guid providerId,
|
||||
Func<Guid, bool> checkAuthorization)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
logger.LogError(
|
||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) while feature flag is disabled",
|
||||
providerId);
|
||||
|
||||
return (null, Error.NotFound());
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
|
@ -1,6 +1,9 @@
|
||||
using Bit.Api.Models.Request.Organizations;
|
||||
using Bit.Api.Models.Response.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -31,6 +34,8 @@ public class OrganizationSponsorshipsController : Controller
|
||||
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationSponsorshipsController(
|
||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||
@ -45,7 +50,9 @@ public class OrganizationSponsorshipsController : Controller
|
||||
IRemoveSponsorshipCommand removeSponsorshipCommand,
|
||||
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
|
||||
IUserService userService,
|
||||
ICurrentContext currentContext)
|
||||
ICurrentContext currentContext,
|
||||
IPolicyRepository policyRepository,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_organizationSponsorshipRepository = organizationSponsorshipRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -60,6 +67,8 @@ public class OrganizationSponsorshipsController : Controller
|
||||
_syncSponsorshipsCommand = syncSponsorshipsCommand;
|
||||
_userService = userService;
|
||||
_currentContext = currentContext;
|
||||
_policyRepository = policyRepository;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[Authorize("Application")]
|
||||
@ -94,9 +103,20 @@ public class OrganizationSponsorshipsController : Controller
|
||||
[Authorize("Application")]
|
||||
[HttpPost("validate-token")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<bool> PreValidateSponsorshipToken([FromQuery] string sponsorshipToken)
|
||||
public async Task<PreValidateSponsorshipResponseModel> PreValidateSponsorshipToken([FromQuery] string sponsorshipToken)
|
||||
{
|
||||
return (await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email)).valid;
|
||||
var isFreeFamilyPolicyEnabled = false;
|
||||
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
|
||||
if (isValid && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship) && sponsorship.SponsoringOrganizationId.HasValue)
|
||||
{
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
|
||||
}
|
||||
|
||||
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
[Authorize("Application")]
|
||||
|
@ -19,14 +19,13 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Authorize("Application")]
|
||||
public class ProviderBillingController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
ISubscriberService subscriberService,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
||||
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
|
||||
{
|
||||
[HttpGet("invoices")]
|
||||
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
|
||||
@ -93,7 +92,8 @@ public class ProviderBillingController(
|
||||
subscription,
|
||||
providerPlans,
|
||||
taxInformation,
|
||||
subscriptionSuspension);
|
||||
subscriptionSuspension,
|
||||
provider);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
@ -14,14 +14,13 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Route("providers/{providerId:guid}/clients")]
|
||||
public class ProviderClientsController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderService providerService,
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
||||
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IResult> CreateAsync(
|
||||
@ -102,15 +101,27 @@ public class ProviderClientsController(
|
||||
|
||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
||||
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
|
||||
{
|
||||
await providerBillingService.AssignSeatsToClientOrganization(
|
||||
provider,
|
||||
clientOrganization,
|
||||
requestBody.AssignedSeats);
|
||||
return Error.ServerError();
|
||||
}
|
||||
|
||||
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
|
||||
|
||||
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
|
||||
provider,
|
||||
clientOrganization.PlanType,
|
||||
seatAdjustment);
|
||||
|
||||
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
|
||||
{
|
||||
return Error.Unauthorized("Service users cannot purchase additional seats.");
|
||||
}
|
||||
|
||||
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
|
||||
|
||||
clientOrganization.Name = requestBody.Name;
|
||||
clientOrganization.Seats = requestBody.AssignedSeats;
|
||||
|
||||
await organizationRepository.ReplaceAsync(clientOrganization);
|
||||
|
||||
|
@ -12,7 +12,7 @@ public class CreateClientOrganizationRequestBody
|
||||
[Required(ErrorMessage = "'ownerEmail' must be provided")]
|
||||
public string OwnerEmail { get; set; }
|
||||
|
||||
[EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, ErrorMessage = "'planType' must be Teams (Monthly) or Enterprise (Monthly)")]
|
||||
[EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, PlanType.EnterpriseAnnually, ErrorMessage = "'planType' must be Teams (Monthly), Enterprise (Monthly) or Enterprise (Annually)")]
|
||||
public PlanType PlanType { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue, ErrorMessage = "'seats' must be greater than 0")]
|
||||
|
@ -6,12 +6,14 @@ public record OrganizationMetadataResponse(
|
||||
bool IsEligibleForSelfHost,
|
||||
bool IsManaged,
|
||||
bool IsOnSecretsManagerStandalone,
|
||||
bool IsSubscriptionUnpaid)
|
||||
bool IsSubscriptionUnpaid,
|
||||
bool HasSubscription)
|
||||
{
|
||||
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
|
||||
=> new(
|
||||
metadata.IsEligibleForSelfHost,
|
||||
metadata.IsManaged,
|
||||
metadata.IsOnSecretsManagerStandalone,
|
||||
metadata.IsSubscriptionUnpaid);
|
||||
metadata.IsSubscriptionUnpaid,
|
||||
metadata.HasSubscription);
|
||||
}
|
||||
|
@ -1,4 +1,7 @@
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
@ -14,7 +17,8 @@ public record ProviderSubscriptionResponse(
|
||||
decimal AccountCredit,
|
||||
TaxInformation TaxInformation,
|
||||
DateTime? CancelAt,
|
||||
SubscriptionSuspension Suspension)
|
||||
SubscriptionSuspension Suspension,
|
||||
ProviderType ProviderType)
|
||||
{
|
||||
private const string _annualCadence = "Annual";
|
||||
private const string _monthlyCadence = "Monthly";
|
||||
@ -23,7 +27,8 @@ public record ProviderSubscriptionResponse(
|
||||
Subscription subscription,
|
||||
ICollection<ProviderPlan> providerPlans,
|
||||
TaxInformation taxInformation,
|
||||
SubscriptionSuspension subscriptionSuspension)
|
||||
SubscriptionSuspension subscriptionSuspension,
|
||||
Provider provider)
|
||||
{
|
||||
var providerPlanResponses = providerPlans
|
||||
.Where(providerPlan => providerPlan.IsConfigured())
|
||||
@ -35,6 +40,8 @@ public record ProviderSubscriptionResponse(
|
||||
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
|
||||
return new ProviderPlanResponse(
|
||||
plan.Name,
|
||||
plan.Type,
|
||||
plan.ProductTier,
|
||||
configuredProviderPlan.SeatMinimum,
|
||||
configuredProviderPlan.PurchasedSeats,
|
||||
configuredProviderPlan.AssignedSeats,
|
||||
@ -53,12 +60,15 @@ public record ProviderSubscriptionResponse(
|
||||
accountCredit,
|
||||
taxInformation,
|
||||
subscription.CancelAt,
|
||||
subscriptionSuspension);
|
||||
subscriptionSuspension,
|
||||
provider.Type);
|
||||
}
|
||||
}
|
||||
|
||||
public record ProviderPlanResponse(
|
||||
string PlanName,
|
||||
PlanType Type,
|
||||
ProductTierType ProductTier,
|
||||
int SeatMinimum,
|
||||
int PurchasedSeats,
|
||||
int AssignedSeats,
|
||||
|
@ -23,7 +23,6 @@ using Microsoft.OpenApi.Models;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Auth.UserFeatures;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
@ -32,6 +31,8 @@ using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Tools.ReportFeatures;
|
||||
|
||||
|
||||
#if !OSS
|
||||
@ -176,6 +177,7 @@ public class Startup
|
||||
services.AddOrganizationSubscriptionServices();
|
||||
services.AddCoreLocalizationServices();
|
||||
services.AddBillingOperations();
|
||||
services.AddReportingServices();
|
||||
|
||||
// Authorization Handlers
|
||||
services.AddAuthorizationHandlers();
|
||||
|
@ -1,13 +1,13 @@
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Api.Tools.Models;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Vault.Queries;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Tools.Requests;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers;
|
||||
[Authorize("Application")]
|
||||
public class ReportsController : Controller
|
||||
{
|
||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
|
||||
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
|
||||
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
|
||||
|
||||
public ReportsController(
|
||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||
IGroupRepository groupRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
|
||||
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
|
||||
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
|
||||
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
|
||||
)
|
||||
{
|
||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||
_groupRepository = groupRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_currentContext = currentContext;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
|
||||
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
|
||||
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization member information containing a list of cipher ids
|
||||
/// assigned
|
||||
/// </summary>
|
||||
/// <param name="orgId">Organzation Id</param>
|
||||
/// <returns>IEnumerable of MemberCipherDetailsResponseModel</returns>
|
||||
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||
[HttpGet("member-cipher-details/{orgId}")]
|
||||
public async Task<IEnumerable<MemberCipherDetailsResponseModel>> GetMemberCipherDetails(Guid orgId)
|
||||
{
|
||||
// Using the AccessReports permission here until new permissions
|
||||
// are needed for more control over reports
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||
|
||||
var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access details for an organization member. Includes the member information,
|
||||
/// group collection assignment, and item counts
|
||||
/// </summary>
|
||||
/// <param name="orgId">Organization Id</param>
|
||||
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
|
||||
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||
[HttpGet("member-access/{orgId}")]
|
||||
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||
{
|
||||
@ -52,26 +74,91 @@ public class ReportsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
|
||||
new OrganizationUserUserDetailsQueryRequest
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
IncludeCollections = true,
|
||||
IncludeGroups = true
|
||||
});
|
||||
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||
|
||||
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
|
||||
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId);
|
||||
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId);
|
||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
||||
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
|
||||
|
||||
var reports = MemberAccessReportResponseModel.CreateReport(
|
||||
orgGroups,
|
||||
orgCollectionsWithAccess,
|
||||
orgItems,
|
||||
organizationUsersTwoFactorEnabled,
|
||||
orgAbility);
|
||||
return reports;
|
||||
return responses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains the organization member info, the cipher ids associated with the member,
|
||||
/// and details on their collections, groups, and permissions
|
||||
/// </summary>
|
||||
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param>
|
||||
/// <returns>IEnumerable of MemberAccessCipherDetails</returns>
|
||||
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||
{
|
||||
var memberCipherDetails =
|
||||
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
|
||||
return memberCipherDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the password health report applications for an organization
|
||||
/// </summary>
|
||||
/// <param name="orgId">A valid Organization Id</param>
|
||||
/// <returns>An Enumerable of PasswordHealthReportApplication </returns>
|
||||
/// <exception cref="NotFoundException">If the user lacks access</exception>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
[HttpGet("password-health-report-applications/{orgId}")]
|
||||
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new record into PasswordHealthReportApplication
|
||||
/// </summary>
|
||||
/// <param name="request">A single instance of PasswordHealthReportApplication Model</param>
|
||||
/// <returns>A single instance of PasswordHealthReportApplication</returns>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
/// <exception cref="NotFoundException">If the user lacks access</exception>
|
||||
[HttpPost("password-health-report-application")]
|
||||
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplication(
|
||||
[FromBody] PasswordHealthReportApplicationModel request)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(request.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var commandRequest = new AddPasswordHealthReportApplicationRequest
|
||||
{
|
||||
OrganizationId = request.OrganizationId,
|
||||
Url = request.Url
|
||||
};
|
||||
|
||||
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple records into PasswordHealthReportApplication
|
||||
/// </summary>
|
||||
/// <param name="request">A enumerable of PasswordHealthReportApplicationModel</param>
|
||||
/// <returns>An Enumerable of PasswordHealthReportApplication</returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the OrganizationId</exception>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
[HttpPost("password-health-report-applications")]
|
||||
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
|
||||
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> request)
|
||||
{
|
||||
if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest
|
||||
{
|
||||
OrganizationId = request.OrganizationId,
|
||||
Url = request.Url
|
||||
}).ToList();
|
||||
|
||||
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequests);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
namespace Bit.Api.Tools.Models;
|
||||
|
||||
public class PasswordHealthReportApplicationModel
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
@ -1,30 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Member access details. The individual item for the detailed member access
|
||||
/// report. A collection can be assigned directly to a user without a group or
|
||||
/// the user can be assigned to a collection through a group. Group level permissions
|
||||
/// can override collection level permissions.
|
||||
/// </summary>
|
||||
public class MemberAccessReportAccessDetails
|
||||
{
|
||||
public Guid? CollectionId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public string GroupName { get; set; }
|
||||
public string CollectionName { get; set; }
|
||||
public int ItemCount { get; set; }
|
||||
public bool? ReadOnly { get; set; }
|
||||
public bool? HidePasswords { get; set; }
|
||||
public bool? Manage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains the collections and group collections a user has access to including
|
||||
/// the permission level for the collection and group collection.
|
||||
@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
|
||||
public int TotalItemCount { get; set; }
|
||||
public Guid? UserGuid { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public IEnumerable<MemberAccessReportAccessDetails> AccessDetails { get; set; }
|
||||
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Generates a report for all members of an organization. Containing summary information
|
||||
/// such as item, collection, and group counts. As well as detailed information on the
|
||||
/// user and group collections along with their permissions
|
||||
/// </summary>
|
||||
/// <param name="orgGroups">Organization groups collection</param>
|
||||
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
||||
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
|
||||
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
||||
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
||||
/// <returns>List of the MemberAccessReportResponseModel</returns>;
|
||||
public static IEnumerable<MemberAccessReportResponseModel> CreateReport(
|
||||
ICollection<Group> orgGroups,
|
||||
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
||||
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
||||
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
||||
OrganizationAbility orgAbility)
|
||||
public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||
{
|
||||
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
|
||||
// Create a dictionary to lookup the group names later.
|
||||
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
||||
|
||||
// Get collections grouped and into a dictionary for counts
|
||||
var collectionItems = orgItems
|
||||
.SelectMany(x => x.CollectionIds,
|
||||
(x, b) => new { CipherId = x.Id, CollectionId = b })
|
||||
.GroupBy(y => y.CollectionId,
|
||||
(key, g) => new { CollectionId = key, Ciphers = g });
|
||||
var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count());
|
||||
|
||||
|
||||
// Loop through the org users and populate report and access data
|
||||
var memberAccessReport = new List<MemberAccessReportResponseModel>();
|
||||
foreach (var user in orgUsers)
|
||||
{
|
||||
// Take the collections/groups and create the access details items
|
||||
var groupAccessDetails = new List<MemberAccessReportAccessDetails>();
|
||||
var userCollectionAccessDetails = new List<MemberAccessReportAccessDetails>();
|
||||
foreach (var tCollect in orgCollectionsWithAccess)
|
||||
{
|
||||
var itemCounts = collectionItemCounts.TryGetValue(tCollect.Item1.Id, out var itemCount) ? itemCount : 0;
|
||||
if (tCollect.Item2.Groups.Count() > 0)
|
||||
{
|
||||
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
|
||||
new MemberAccessReportAccessDetails
|
||||
{
|
||||
CollectionId = tCollect.Item1.Id,
|
||||
CollectionName = tCollect.Item1.Name,
|
||||
GroupId = x.Id,
|
||||
GroupName = groupNameDictionary[x.Id],
|
||||
ReadOnly = x.ReadOnly,
|
||||
HidePasswords = x.HidePasswords,
|
||||
Manage = x.Manage,
|
||||
ItemCount = itemCounts,
|
||||
});
|
||||
groupAccessDetails.AddRange(groupDetails);
|
||||
}
|
||||
|
||||
// All collections assigned to users and their permissions
|
||||
if (tCollect.Item2.Users.Count() > 0)
|
||||
{
|
||||
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
|
||||
new MemberAccessReportAccessDetails
|
||||
{
|
||||
CollectionId = tCollect.Item1.Id,
|
||||
CollectionName = tCollect.Item1.Name,
|
||||
ReadOnly = x.ReadOnly,
|
||||
HidePasswords = x.HidePasswords,
|
||||
Manage = x.Manage,
|
||||
ItemCount = itemCounts,
|
||||
});
|
||||
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
||||
}
|
||||
}
|
||||
|
||||
var report = new MemberAccessReportResponseModel
|
||||
{
|
||||
UserName = user.Name,
|
||||
Email = user.Email,
|
||||
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
||||
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
|
||||
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
||||
UserGuid = user.Id,
|
||||
UsesKeyConnector = user.UsesKeyConnector
|
||||
};
|
||||
|
||||
var userAccessDetails = new List<MemberAccessReportAccessDetails>();
|
||||
if (user.Groups.Any())
|
||||
{
|
||||
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
|
||||
userAccessDetails.AddRange(userGroups);
|
||||
}
|
||||
|
||||
// There can be edge cases where groups don't have a collection
|
||||
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
||||
if (groupsWithoutCollections.Count() > 0)
|
||||
{
|
||||
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails
|
||||
{
|
||||
GroupId = x,
|
||||
GroupName = groupNameDictionary[x],
|
||||
ItemCount = 0
|
||||
});
|
||||
userAccessDetails.AddRange(emptyGroups);
|
||||
}
|
||||
|
||||
if (user.Collections.Any())
|
||||
{
|
||||
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
|
||||
userAccessDetails.AddRange(userCollections);
|
||||
}
|
||||
report.AccessDetails = userAccessDetails;
|
||||
|
||||
report.TotalItemCount = collectionItems
|
||||
.Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId))
|
||||
.SelectMany(x => x.Ciphers)
|
||||
.GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault())
|
||||
.Count();
|
||||
|
||||
// Distinct items only
|
||||
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
||||
report.CollectionsCount = distinctItems.Count();
|
||||
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
||||
memberAccessReport.Add(report);
|
||||
}
|
||||
return memberAccessReport;
|
||||
this.UserName = memberAccessCipherDetails.UserName;
|
||||
this.Email = memberAccessCipherDetails.Email;
|
||||
this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
|
||||
this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
|
||||
this.GroupsCount = memberAccessCipherDetails.GroupsCount;
|
||||
this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
|
||||
this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
|
||||
this.UserGuid = memberAccessCipherDetails.UserGuid;
|
||||
this.AccessDetails = memberAccessCipherDetails.AccessDetails;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,24 @@
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
|
||||
public class MemberCipherDetailsResponseModel
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A distinct list of the cipher ids associated with
|
||||
/// the organization member
|
||||
/// </summary>
|
||||
public IEnumerable<string> CipherIds { get; set; }
|
||||
|
||||
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||
{
|
||||
this.UserName = memberAccessCipherDetails.UserName;
|
||||
this.Email = memberAccessCipherDetails.Email;
|
||||
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
|
||||
this.CipherIds = memberAccessCipherDetails.CipherIds;
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
|
@ -1,62 +0,0 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Context;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Handles authorization logic for Group operations.
|
||||
/// This uses new logic implemented in the Flexible Collections initiative.
|
||||
/// </summary>
|
||||
public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequirement>
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public GroupAuthorizationHandler(ICurrentContext currentContext)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||
GroupOperationRequirement requirement)
|
||||
{
|
||||
// Acting user is not authenticated, fail
|
||||
if (!_currentContext.UserId.HasValue)
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requirement.OrganizationId == default)
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
var org = _currentContext.GetOrganization(requirement.OrganizationId);
|
||||
|
||||
switch (requirement)
|
||||
{
|
||||
case not null when requirement.Name == nameof(GroupOperations.ReadAll):
|
||||
await CanReadAllAsync(context, requirement, org);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
|
||||
CurrentContextOrganization? org)
|
||||
{
|
||||
// All users of an organization can read all groups belonging to the organization for collection access management
|
||||
if (org is not null)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow provider users to read all groups if they are a provider for the target organization
|
||||
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||
|
||||
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
|
||||
|
||||
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
||||
{
|
||||
public Guid OrganizationId { get; init; }
|
||||
|
||||
public GroupOperationRequirement(string name, Guid organizationId)
|
||||
{
|
||||
Name = name;
|
||||
OrganizationId = organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
public static class GroupOperations
|
||||
{
|
||||
public static GroupOperationRequirement ReadAll(Guid organizationId)
|
||||
{
|
||||
return new GroupOperationRequirement(nameof(ReadAll), organizationId);
|
||||
}
|
||||
}
|
@ -99,7 +99,10 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
|
||||
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||
|
||||
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/full-details")]
|
||||
@ -600,10 +603,10 @@ public class CiphersController : Controller
|
||||
|
||||
[HttpPut("{id}/collections-admin")]
|
||||
[HttpPost("{id}/collections-admin")]
|
||||
public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
||||
public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
||||
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
|
||||
|
||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
@ -621,6 +624,11 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
|
||||
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
|
||||
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||
|
||||
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
|
||||
}
|
||||
|
||||
[HttpPost("bulk-collections")]
|
||||
|
@ -1,7 +1,9 @@
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -10,6 +12,7 @@ using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -30,6 +33,8 @@ public class SyncController : Controller
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly ISendRepository _sendRepository;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly Version _sshKeyCipherMinimumVersion = new(Constants.SSHKeyCipherMinimumVersion);
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public SyncController(
|
||||
@ -43,6 +48,7 @@ public class SyncController : Controller
|
||||
IPolicyRepository policyRepository,
|
||||
ISendRepository sendRepository,
|
||||
GlobalSettings globalSettings,
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_userService = userService;
|
||||
@ -55,6 +61,7 @@ public class SyncController : Controller
|
||||
_policyRepository = policyRepository;
|
||||
_sendRepository = sendRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
@ -77,7 +84,8 @@ public class SyncController : Controller
|
||||
var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled);
|
||||
|
||||
var folders = await _folderRepository.GetManyByUserIdAsync(user.Id);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs);
|
||||
var allCiphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs);
|
||||
var ciphers = FilterSSHKeys(allCiphers);
|
||||
var sends = await _sendRepository.GetManyByUserIdAsync(user.Id);
|
||||
|
||||
IEnumerable<CollectionDetails> collections = null;
|
||||
@ -101,4 +109,16 @@ public class SyncController : Controller
|
||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||
return response;
|
||||
}
|
||||
|
||||
private ICollection<CipherDetails> FilterSSHKeys(ICollection<CipherDetails> ciphers)
|
||||
{
|
||||
if (_currentContext.ClientVersion >= _sshKeyCipherMinimumVersion)
|
||||
{
|
||||
return ciphers;
|
||||
}
|
||||
else
|
||||
{
|
||||
return ciphers.Where(c => c.Type != Core.Vault.Enums.CipherType.SSHKey).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
26
src/Api/Vault/Models/CipherSSHKeyModel.cs
Normal file
26
src/Api/Vault/Models/CipherSSHKeyModel.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
|
||||
namespace Bit.Api.Vault.Models;
|
||||
|
||||
public class CipherSSHKeyModel
|
||||
{
|
||||
public CipherSSHKeyModel() { }
|
||||
|
||||
public CipherSSHKeyModel(CipherSSHKeyData data)
|
||||
{
|
||||
PrivateKey = data.PrivateKey;
|
||||
PublicKey = data.PublicKey;
|
||||
KeyFingerprint = data.KeyFingerprint;
|
||||
}
|
||||
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(5000)]
|
||||
public string PrivateKey { get; set; }
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(5000)]
|
||||
public string PublicKey { get; set; }
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string KeyFingerprint { get; set; }
|
||||
}
|
@ -37,6 +37,7 @@ public class CipherRequestModel
|
||||
public CipherCardModel Card { get; set; }
|
||||
public CipherIdentityModel Identity { get; set; }
|
||||
public CipherSecureNoteModel SecureNote { get; set; }
|
||||
public CipherSSHKeyModel SSHKey { get; set; }
|
||||
public DateTime? LastKnownRevisionDate { get; set; } = null;
|
||||
|
||||
public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true)
|
||||
@ -82,6 +83,9 @@ public class CipherRequestModel
|
||||
case CipherType.SecureNote:
|
||||
existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull);
|
||||
break;
|
||||
case CipherType.SSHKey:
|
||||
existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException("Unsupported type: " + nameof(Type) + ".");
|
||||
}
|
||||
@ -230,6 +234,21 @@ public class CipherRequestModel
|
||||
Type = SecureNote.Type,
|
||||
};
|
||||
}
|
||||
|
||||
private CipherSSHKeyData ToCipherSSHKeyData()
|
||||
{
|
||||
return new CipherSSHKeyData
|
||||
{
|
||||
Name = Name,
|
||||
Notes = Notes,
|
||||
Fields = Fields?.Select(f => f.ToCipherFieldData()),
|
||||
PasswordHistory = PasswordHistory?.Select(ph => ph.ToCipherPasswordHistoryData()),
|
||||
|
||||
PrivateKey = SSHKey.PrivateKey,
|
||||
PublicKey = SSHKey.PublicKey,
|
||||
KeyFingerprint = SSHKey.KeyFingerprint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class CipherWithIdRequestModel : CipherRequestModel
|
||||
|
@ -48,6 +48,12 @@ public class CipherMiniResponseModel : ResponseModel
|
||||
cipherData = identityData;
|
||||
Identity = new CipherIdentityModel(identityData);
|
||||
break;
|
||||
case CipherType.SSHKey:
|
||||
var sshKeyData = JsonSerializer.Deserialize<CipherSSHKeyData>(cipher.Data);
|
||||
Data = sshKeyData;
|
||||
cipherData = sshKeyData;
|
||||
SSHKey = new CipherSSHKeyModel(sshKeyData);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException("Unsupported " + nameof(Type) + ".");
|
||||
}
|
||||
@ -76,6 +82,7 @@ public class CipherMiniResponseModel : ResponseModel
|
||||
public CipherCardModel Card { get; set; }
|
||||
public CipherIdentityModel Identity { get; set; }
|
||||
public CipherSecureNoteModel SecureNote { get; set; }
|
||||
public CipherSSHKeyModel SSHKey { get; set; }
|
||||
public IEnumerable<CipherFieldModel> Fields { get; set; }
|
||||
public IEnumerable<CipherPasswordHistoryModel> PasswordHistory { get; set; }
|
||||
public IEnumerable<AttachmentResponseModel> Attachments { get; set; }
|
||||
|
@ -1,7 +1,7 @@
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Api;
|
||||
|
@ -1,7 +1,6 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
@ -42,96 +41,64 @@ public class ProviderEventService(
|
||||
case HandledStripeWebhook.InvoiceCreated:
|
||||
{
|
||||
var clients =
|
||||
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId))
|
||||
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
|
||||
await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId);
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
||||
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
var invoiceItems = new List<ProviderInvoiceItem>();
|
||||
|
||||
var teamsProviderPlan =
|
||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
|
||||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
||||
foreach (var client in clients)
|
||||
{
|
||||
logger.LogError("Provider {ProviderID} is missing or has misconfigured provider plans", parsedProviderId);
|
||||
if (client.Status != OrganizationStatusType.Managed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
|
||||
var plan = StaticStore.Plans.Single(x => x.Name == client.Plan && providerPlans.Any(y => y.PlanType == x.Type));
|
||||
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
|
||||
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientId = client.OrganizationId,
|
||||
ClientName = client.OrganizationName,
|
||||
PlanName = client.Plan,
|
||||
AssignedSeats = client.Seats ?? 0,
|
||||
UsedSeats = client.OccupiedSeats ?? 0,
|
||||
Total = (client.Seats ?? 0) * discountedSeatPrice
|
||||
});
|
||||
}
|
||||
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
|
||||
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
|
||||
foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0))
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientId = client.OrganizationId,
|
||||
ClientName = client.OrganizationName,
|
||||
PlanName = client.Plan,
|
||||
AssignedSeats = client.Seats ?? 0,
|
||||
UsedSeats = client.OccupiedSeats ?? 0,
|
||||
Total = client.Plan == enterprisePlan.Name
|
||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
||||
}).ToList();
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
|
||||
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
|
||||
{
|
||||
var enterpriseClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == enterprisePlan.Name)
|
||||
var clientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == plan.Name)
|
||||
.Sum(item => item.AssignedSeats);
|
||||
|
||||
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
|
||||
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
|
||||
|
||||
if (unassignedEnterpriseSeats > 0)
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
|
||||
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = enterprisePlan.Name,
|
||||
AssignedSeats = unassignedEnterpriseSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (teamsProviderPlan.PurchasedSeats is null or 0)
|
||||
{
|
||||
var teamsClientSeats = invoiceItems
|
||||
.Where(item => item.PlanName == teamsPlan.Name)
|
||||
.Sum(item => item.AssignedSeats);
|
||||
|
||||
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
|
||||
|
||||
if (unassignedTeamsSeats > 0)
|
||||
{
|
||||
invoiceItems.Add(new ProviderInvoiceItem
|
||||
{
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = teamsPlan.Name,
|
||||
AssignedSeats = unassignedTeamsSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
|
||||
});
|
||||
}
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientName = "Unassigned seats",
|
||||
PlanName = plan.Name,
|
||||
AssignedSeats = unassignedSeats,
|
||||
UsedSeats = 0,
|
||||
Total = unassignedSeats * discountedSeatPrice
|
||||
});
|
||||
}
|
||||
|
||||
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
||||
|
@ -13,7 +13,7 @@
|
||||
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
||||
required autofocus>
|
||||
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
||||
<small class="form-text text-muted">We'll email you a secure login link.</small>
|
||||
<small class="form-text text-body-secondary">We'll email you a secure login link.</small>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" type="submit">Continue</button>
|
||||
</form>
|
||||
|
@ -15,6 +15,7 @@ public enum PolicyType : byte
|
||||
DisablePersonalVaultExport = 10,
|
||||
ActivateAutofill = 11,
|
||||
AutomaticAppLogIn = 12,
|
||||
FreeFamiliesSponsorshipPolicy = 13
|
||||
}
|
||||
|
||||
public static class PolicyTypeExtensions
|
||||
@ -40,6 +41,7 @@ public static class PolicyTypeExtensions
|
||||
PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
|
||||
PolicyType.ActivateAutofill => "Active auto-fill",
|
||||
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications",
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||
|
||||
public class GroupAuthorizationHandler(ICurrentContext currentContext)
|
||||
: AuthorizationHandler<GroupOperationRequirement, OrganizationScope>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||
GroupOperationRequirement requirement, OrganizationScope organizationScope)
|
||||
{
|
||||
var authorized = requirement switch
|
||||
{
|
||||
not null when requirement.Name == nameof(GroupOperations.ReadAll) =>
|
||||
await CanReadAllAsync(organizationScope),
|
||||
not null when requirement.Name == nameof(GroupOperations.ReadAllDetails) =>
|
||||
await CanViewGroupDetailsAsync(organizationScope),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (requirement is not null && authorized)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CanReadAllAsync(OrganizationScope organizationScope) =>
|
||||
currentContext.GetOrganization(organizationScope) is not null
|
||||
|| await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||
|
||||
private async Task<bool> CanViewGroupDetailsAsync(OrganizationScope organizationScope) =>
|
||||
currentContext.GetOrganization(organizationScope) is
|
||||
{ Type: OrganizationUserType.Owner } or
|
||||
{ Type: OrganizationUserType.Admin } or
|
||||
{
|
||||
Permissions: { ManageGroups: true } or
|
||||
{ ManageUsers: true }
|
||||
} ||
|
||||
await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||
|
||||
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
||||
{
|
||||
public GroupOperationRequirement(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
public static class GroupOperations
|
||||
{
|
||||
public static readonly GroupOperationRequirement ReadAll = new(nameof(ReadAll));
|
||||
public static readonly GroupOperationRequirement ReadAllDetails = new(nameof(ReadAllDetails));
|
||||
}
|
@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
||||
|
||||
public VerifyOrganizationDomainCommand(
|
||||
@ -30,7 +29,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
||||
IGlobalSettings globalSettings,
|
||||
IPolicyService policyService,
|
||||
IFeatureService featureService,
|
||||
IOrganizationService organizationService,
|
||||
ILogger<VerifyOrganizationDomainCommand> logger)
|
||||
{
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
@ -39,7 +37,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
||||
_globalSettings = globalSettings;
|
||||
_policyService = policyService;
|
||||
_featureService = featureService;
|
||||
_organizationService = organizationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
@ -1,5 +1,5 @@
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
@ -7,14 +7,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authoriza
|
||||
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
||||
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
||||
{
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICurrentContext currentContext)
|
||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext)
|
||||
{
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
|
@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand
|
||||
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
||||
{
|
||||
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
||||
.Where(requiredPolicyType =>
|
||||
savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||
.ToList();
|
||||
|
||||
if (missingRequiredPolicyTypes.Count != 0)
|
||||
|
@ -18,5 +18,6 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,46 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
|
||||
public class FreeFamiliesForEnterprisePolicyValidator(
|
||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository)
|
||||
: IPolicyValidator
|
||||
{
|
||||
public PolicyType Type => PolicyType.FreeFamiliesSponsorshipPolicy;
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||
|
||||
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
{
|
||||
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })
|
||||
{
|
||||
await NotifiesUserWithApplicablePoliciesAsync(policyUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifiesUserWithApplicablePoliciesAsync(PolicyUpdate policy)
|
||||
{
|
||||
var organizationSponsorships = (await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(policy.OrganizationId))
|
||||
.Where(p => p.SponsoredOrganizationId is not null)
|
||||
.ToList();
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(policy.OrganizationId);
|
||||
var organizationName = organization?.Name;
|
||||
|
||||
foreach (var org in organizationSponsorships)
|
||||
{
|
||||
var offerAcceptanceDate = org.ValidUntil!.Value.AddDays(-7).ToString("MM/dd/yyyy");
|
||||
await mailService.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(org.FriendlyName, offerAcceptanceDate,
|
||||
org.SponsoredOrganizationId.ToString(), organizationName);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
|
||||
}
|
@ -2,6 +2,7 @@
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.Auth.Enums;
|
||||
@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||
|
||||
public SingleOrgPolicyValidator(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ICurrentContext currentContext,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
|
||||
IFeatureService featureService,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_mailService = mailService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
}
|
||||
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||
@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
if (policyUpdate is not { Enabled: true })
|
||||
{
|
||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
|
||||
return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
||||
|
||||
var validateDecryptionErrorMessage = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(validateDecryptionErrorMessage))
|
||||
{
|
||||
return validateDecryptionErrorMessage;
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
|
||||
{
|
||||
return "The Single organization policy is required for organizations that have enabled domain verification.";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// A typed wrapper for an organization Guid. This is used for authorization checks
|
@ -15,6 +15,7 @@ using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
@ -444,13 +445,6 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup)
|
||||
{
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (!consolidatedBillingEnabled)
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(SignupClientAsync)} is only for use within Consolidated Billing");
|
||||
}
|
||||
|
||||
var plan = StaticStore.GetPlan(signup.Plan);
|
||||
|
||||
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
|
||||
@ -1443,10 +1437,7 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
if (provider is { Enabled: true })
|
||||
{
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (consolidatedBillingEnabled && provider.Type == ProviderType.Msp &&
|
||||
provider.Status == ProviderStatusType.Billable)
|
||||
if (provider.IsBillable())
|
||||
{
|
||||
return (false, "Seat limit has been reached. Please contact your provider to add more seats.");
|
||||
}
|
||||
|
@ -289,7 +289,7 @@ public class PolicyService : IPolicyService
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
|
||||
{
|
||||
throw new BadRequestException("Organization has verified domains.");
|
||||
throw new BadRequestException("The Single organization policy is required for organizations that have enabled domain verification.");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,86 +0,0 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Utilities.Duo;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
|
||||
public class DuoWebTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public DuoWebTokenProvider(
|
||||
IServiceProvider serviceProvider,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var signatureRequest = DuoWeb.SignRequest((string)provider.MetaData["IKey"],
|
||||
(string)provider.MetaData["SKey"], _globalSettings.Duo.AKey, user.Email);
|
||||
return signatureRequest;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = DuoWeb.VerifyResponse((string)provider.MetaData["IKey"], (string)provider.MetaData["SKey"],
|
||||
_globalSettings.Duo.AKey, token);
|
||||
|
||||
return response == user.Email;
|
||||
}
|
||||
|
||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
||||
{
|
||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
|
||||
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
|
||||
}
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Utilities.Duo;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
|
||||
public interface IOrganizationDuoWebTokenProvider : IOrganizationTwoFactorTokenProvider { }
|
||||
|
||||
public class OrganizationDuoWebTokenProvider : IOrganizationDuoWebTokenProvider
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public OrganizationDuoWebTokenProvider(GlobalSettings globalSettings)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
|
||||
{
|
||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
var canGenerate = organization.TwoFactorProviderIsEnabled(TwoFactorProviderType.OrganizationDuo)
|
||||
&& HasProperMetaData(provider);
|
||||
return Task.FromResult(canGenerate);
|
||||
}
|
||||
|
||||
public Task<string> GenerateAsync(Organization organization, User user)
|
||||
{
|
||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
||||
{
|
||||
return Task.FromResult<string>(null);
|
||||
}
|
||||
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return Task.FromResult<string>(null);
|
||||
}
|
||||
|
||||
var signatureRequest = DuoWeb.SignRequest(provider.MetaData["IKey"].ToString(),
|
||||
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, user.Email);
|
||||
return Task.FromResult(signatureRequest);
|
||||
}
|
||||
|
||||
public Task<bool> ValidateAsync(string token, Organization organization, User user)
|
||||
{
|
||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var response = DuoWeb.VerifyResponse(provider.MetaData["IKey"].ToString(),
|
||||
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, token);
|
||||
|
||||
return Task.FromResult(response == user.Email);
|
||||
}
|
||||
|
||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
||||
{
|
||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
|
||||
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
|
||||
}
|
||||
}
|
@ -1,172 +0,0 @@
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Duo = DuoUniversal;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
|
||||
/*
|
||||
PM-5156 addresses tech debt
|
||||
Interface to allow for DI, will end up being removed as part of the removal of the old Duo SDK v2 flows.
|
||||
This service is to support SDK v4 flows for Duo. At some time in the future we will need
|
||||
to combine this service with the DuoWebTokenProvider and OrganizationDuoWebTokenProvider to support SDK v4.
|
||||
*/
|
||||
public interface ITemporaryDuoWebV4SDKService
|
||||
{
|
||||
Task<string> GenerateAsync(TwoFactorProvider provider, User user);
|
||||
Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user);
|
||||
}
|
||||
|
||||
public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory;
|
||||
private readonly ILogger<TemporaryDuoWebV4SDKService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for the DuoUniversalPromptService. Used to supplement v2 implementation of Duo with v4 SDK
|
||||
/// </summary>
|
||||
/// <param name="currentContext">used to fetch initiating Client</param>
|
||||
/// <param name="globalSettings">used to fetch vault URL for Redirect URL</param>
|
||||
public TemporaryDuoWebV4SDKService(
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
ILogger<TemporaryDuoWebV4SDKService> logger)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
_tokenDataFactory = tokenDataFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider agnostic (either Duo or OrganizationDuo) method to generate a Duo Auth URL
|
||||
/// </summary>
|
||||
/// <param name="provider">Either Duo or OrganizationDuo</param>
|
||||
/// <param name="user">self</param>
|
||||
/// <returns>AuthUrl for DUO SDK v4</returns>
|
||||
public async Task<string> GenerateAsync(TwoFactorProvider provider, User user)
|
||||
{
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
if (!HasProperMetaData_SDKV2(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var duoClient = await BuildDuoClientAsync(provider);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var state = _tokenDataFactory.Protect(new DuoUserStateTokenable(user));
|
||||
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates Duo SDK v4 response
|
||||
/// </summary>
|
||||
/// <param name="token">response form Duo</param>
|
||||
/// <param name="provider">TwoFactorProviderType Duo or OrganizationDuo</param>
|
||||
/// <param name="user">self</param>
|
||||
/// <returns>true or false depending on result of verification</returns>
|
||||
public async Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user)
|
||||
{
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
if (!HasProperMetaData_SDKV2(provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var duoClient = await BuildDuoClientAsync(provider);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = token.Split("|");
|
||||
var authCode = parts[0];
|
||||
var state = parts[1];
|
||||
|
||||
_tokenDataFactory.TryUnprotect(state, out var tokenable);
|
||||
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
|
||||
// their authCode with a victims credentials
|
||||
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
|
||||
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
|
||||
return res.AuthResult.Result == "allow";
|
||||
}
|
||||
|
||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
||||
{
|
||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("ClientId") &&
|
||||
provider.MetaData.ContainsKey("ClientSecret") && provider.MetaData.ContainsKey("Host");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the metadata for SDK V2 is present.
|
||||
/// Transitional method to support Duo during v4 database rename
|
||||
/// </summary>
|
||||
/// <param name="provider">The TwoFactorProvider object to check.</param>
|
||||
/// <returns>True if the provider has the proper metadata; otherwise, false.</returns>
|
||||
private bool HasProperMetaData_SDKV2(TwoFactorProvider provider)
|
||||
{
|
||||
if (provider?.MetaData != null &&
|
||||
provider.MetaData.TryGetValue("IKey", out var iKey) &&
|
||||
provider.MetaData.TryGetValue("SKey", out var sKey) &&
|
||||
provider.MetaData.ContainsKey("Host"))
|
||||
{
|
||||
provider.MetaData.Add("ClientId", iKey);
|
||||
provider.MetaData.Add("ClientSecret", sKey);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation
|
||||
/// </summary>
|
||||
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
|
||||
/// <returns>Duo.Client object or null</returns>
|
||||
private async Task<Duo.Client> BuildDuoClientAsync(TwoFactorProvider provider)
|
||||
{
|
||||
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
|
||||
// to redirect back to the initiating client
|
||||
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
|
||||
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
|
||||
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
|
||||
|
||||
var client = new Duo.ClientBuilder(
|
||||
(string)provider.MetaData["ClientId"],
|
||||
(string)provider.MetaData["ClientSecret"],
|
||||
(string)provider.MetaData["Host"],
|
||||
redirectUri).Build();
|
||||
|
||||
if (!await client.DoHealthCheck(true))
|
||||
{
|
||||
_logger.LogError("Unable to connect to Duo. Health check failed.");
|
||||
return null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
@ -6,7 +6,7 @@ using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OtpNet;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
@ -0,0 +1,102 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Duo = DuoUniversal;
|
||||
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class DuoUniversalTokenProvider(
|
||||
IServiceProvider serviceProvider,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
IDuoUniversalTokenService duoUniversalTokenService) : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
/// <summary>
|
||||
/// We need the IServiceProvider to resolve the IUserService. There is a complex dependency dance
|
||||
/// occurring between IUserService, which extends the UserManager<User>, and the usage of the
|
||||
/// UserManager<User> within this class. Trying to resolve the IUserService using the DI pipeline
|
||||
/// will not allow the server to start and it will hang and give no helpful indication as to the problem.
|
||||
/// </summary>
|
||||
private readonly IServiceProvider _serviceProvider = serviceProvider;
|
||||
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory = tokenDataFactory;
|
||||
private readonly IDuoUniversalTokenService _duoUniversalTokenService = duoUniversalTokenService;
|
||||
|
||||
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
var provider = await GetDuoTwoFactorProvider(user, userService);
|
||||
if (provider == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
||||
{
|
||||
var duoClient = await GetDuoClientAsync(user);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return _duoUniversalTokenService.GenerateAuthUrl(duoClient, _tokenDataFactory, user);
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
|
||||
{
|
||||
var duoClient = await GetDuoClientAsync(user);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return await _duoUniversalTokenService.RequestDuoValidationAsync(duoClient, _tokenDataFactory, user, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the Duo Two Factor Provider for the user if they have access to Duo
|
||||
/// </summary>
|
||||
/// <param name="user">Active User</param>
|
||||
/// <returns>null or Duo TwoFactorProvider</returns>
|
||||
private async Task<TwoFactorProvider> GetDuoTwoFactorProvider(User user, IUserService userService)
|
||||
{
|
||||
if (!await userService.CanAccessPremium(user))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
if (!_duoUniversalTokenService.HasProperDuoMetadata(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the User to fetch a valid TwoFactorProvider and use it to create a Duo.Client
|
||||
/// </summary>
|
||||
/// <param name="user">active user</param>
|
||||
/// <returns>null or Duo TwoFactorProvider</returns>
|
||||
private async Task<Duo.Client> GetDuoClientAsync(User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
var provider = await GetDuoTwoFactorProvider(user, userService);
|
||||
if (provider == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var duoClient = await _duoUniversalTokenService.BuildDuoTwoFactorClientAsync(provider);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return duoClient;
|
||||
}
|
||||
}
|
@ -0,0 +1,177 @@
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Duo = DuoUniversal;
|
||||
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
/// <summary>
|
||||
/// OrganizationDuo and Duo TwoFactorProviderTypes both use the same flows so both of those Token Providers will
|
||||
/// have this class injected to utilize these methods
|
||||
/// </summary>
|
||||
public interface IDuoUniversalTokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the Duo Auth URL for the user to be redirected to Duo for 2FA. This
|
||||
/// Auth URL also lets the Duo Service know where to redirect the user back to after
|
||||
/// the 2FA process is complete.
|
||||
/// </summary>
|
||||
/// <param name="duoClient">A not null valid Duo.Client</param>
|
||||
/// <param name="tokenDataFactory">This service creates the state token for added security</param>
|
||||
/// <param name="user">currently active user</param>
|
||||
/// <returns>a URL in string format</returns>
|
||||
string GenerateAuthUrl(
|
||||
Duo.Client duoClient,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
User user);
|
||||
|
||||
/// <summary>
|
||||
/// Makes the request to Duo to validate the authCode and state token
|
||||
/// </summary>
|
||||
/// <param name="duoClient">A not null valid Duo.Client</param>
|
||||
/// <param name="tokenDataFactory">Factory for decrypting the state</param>
|
||||
/// <param name="user">self</param>
|
||||
/// <param name="token">token received from the client</param>
|
||||
/// <returns>boolean based on result from Duo</returns>
|
||||
Task<bool> RequestDuoValidationAsync(
|
||||
Duo.Client duoClient,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
User user,
|
||||
string token);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Duo.Client object for use with Duo SDK v4. This method is to validate a Duo configuration
|
||||
/// when adding or updating the configuration. This method makes a web request to Duo to verify the configuration.
|
||||
/// Throws exception if configuration is invalid.
|
||||
/// </summary>
|
||||
/// <param name="clientSecret">Duo client Secret</param>
|
||||
/// <param name="clientId">Duo client Id</param>
|
||||
/// <param name="host">Duo host</param>
|
||||
/// <returns>Boolean</returns>
|
||||
Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host);
|
||||
|
||||
/// <summary>
|
||||
/// Checks provider for the correct Duo metadata: ClientId, ClientSecret, and Host. Does no validation on the data.
|
||||
/// it is assumed to be correct. The only way to have the data written to the Database is after verification
|
||||
/// occurs.
|
||||
/// </summary>
|
||||
/// <param name="provider">Host being checked for proper data</param>
|
||||
/// <returns>true if all three are present; false if one is missing or the host is incorrect</returns>
|
||||
bool HasProperDuoMetadata(TwoFactorProvider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation.
|
||||
/// This method is made public so that it is easier to test. If the method was private then there would not be an
|
||||
/// easy way to mock the response. Since this makes a web request it is difficult to mock.
|
||||
/// </summary>
|
||||
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
|
||||
/// <returns>Duo.Client object or null</returns>
|
||||
Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider);
|
||||
}
|
||||
|
||||
public class DuoUniversalTokenService(
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings) : IDuoUniversalTokenService
|
||||
{
|
||||
private readonly ICurrentContext _currentContext = currentContext;
|
||||
private readonly GlobalSettings _globalSettings = globalSettings;
|
||||
|
||||
public string GenerateAuthUrl(
|
||||
Duo.Client duoClient,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
User user)
|
||||
{
|
||||
var state = tokenDataFactory.Protect(new DuoUserStateTokenable(user));
|
||||
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
public async Task<bool> RequestDuoValidationAsync(
|
||||
Duo.Client duoClient,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
User user,
|
||||
string token)
|
||||
{
|
||||
var parts = token.Split("|");
|
||||
var authCode = parts[0];
|
||||
var state = parts[1];
|
||||
tokenDataFactory.TryUnprotect(state, out var tokenable);
|
||||
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
|
||||
// their authCode with a victims credentials
|
||||
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
|
||||
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
|
||||
return res.AuthResult.Result == "allow";
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host)
|
||||
{
|
||||
// Do some simple checks to ensure data integrity
|
||||
if (!ValidDuoHost(host) ||
|
||||
string.IsNullOrWhiteSpace(clientSecret) ||
|
||||
string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// The AuthURI is not important for this health check so we pass in a non-empty string
|
||||
var client = new Duo.ClientBuilder(clientId, clientSecret, host, "non-empty").Build();
|
||||
|
||||
// This could throw an exception, the false flag will allow the exception to bubble up
|
||||
return await client.DoHealthCheck(false);
|
||||
}
|
||||
|
||||
public bool HasProperDuoMetadata(TwoFactorProvider provider)
|
||||
{
|
||||
return provider?.MetaData != null &&
|
||||
provider.MetaData.ContainsKey("ClientId") &&
|
||||
provider.MetaData.ContainsKey("ClientSecret") &&
|
||||
provider.MetaData.ContainsKey("Host") &&
|
||||
ValidDuoHost((string)provider.MetaData["Host"]);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks the host string to make sure it meets Duo's Guidelines before attempting to create a Duo.Client.
|
||||
/// </summary>
|
||||
/// <param name="host">string representing the Duo Host</param>
|
||||
/// <returns>true if the host is valid false otherwise</returns>
|
||||
public static bool ValidDuoHost(string host)
|
||||
{
|
||||
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
|
||||
{
|
||||
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
|
||||
uri.Host.StartsWith("api-") &&
|
||||
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider)
|
||||
{
|
||||
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
|
||||
// to redirect back to the initiating client
|
||||
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
|
||||
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
|
||||
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
|
||||
|
||||
var client = new Duo.ClientBuilder(
|
||||
(string)provider.MetaData["ClientId"],
|
||||
(string)provider.MetaData["ClientSecret"],
|
||||
(string)provider.MetaData["Host"],
|
||||
redirectUri).Build();
|
||||
|
||||
if (!await client.DoHealthCheck(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class EmailTwoFactorTokenProvider : EmailTokenProvider
|
||||
{
|
@ -1,7 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public interface IOrganizationTwoFactorTokenProvider
|
||||
{
|
@ -0,0 +1,81 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Tokens;
|
||||
using Duo = DuoUniversal;
|
||||
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public interface IOrganizationDuoUniversalTokenProvider : IOrganizationTwoFactorTokenProvider { }
|
||||
|
||||
public class OrganizationDuoUniversalTokenProvider(
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
IDuoUniversalTokenService duoUniversalTokenService) : IOrganizationDuoUniversalTokenProvider
|
||||
{
|
||||
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory = tokenDataFactory;
|
||||
private readonly IDuoUniversalTokenService _duoUniversalTokenService = duoUniversalTokenService;
|
||||
|
||||
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
|
||||
{
|
||||
var provider = GetDuoTwoFactorProvider(organization);
|
||||
if (provider != null && provider.Enabled)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAsync(Organization organization, User user)
|
||||
{
|
||||
var duoClient = await GetDuoClientAsync(organization);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return _duoUniversalTokenService.GenerateAuthUrl(duoClient, _tokenDataFactory, user);
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(string token, Organization organization, User user)
|
||||
{
|
||||
var duoClient = await GetDuoClientAsync(organization);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return await _duoUniversalTokenService.RequestDuoValidationAsync(duoClient, _tokenDataFactory, user, token);
|
||||
}
|
||||
|
||||
private TwoFactorProvider GetDuoTwoFactorProvider(Organization organization)
|
||||
{
|
||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
if (!_duoUniversalTokenService.HasProperDuoMetadata(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private async Task<Duo.Client> GetDuoClientAsync(Organization organization)
|
||||
{
|
||||
var provider = GetDuoTwoFactorProvider(organization);
|
||||
if (provider == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var duoClient = await _duoUniversalTokenService.BuildDuoTwoFactorClientAsync(provider);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return duoClient;
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class TwoFactorRememberTokenProvider : DataProtectorTokenProvider<User>
|
||||
{
|
@ -10,7 +10,7 @@ using Fido2NetLib.Objects;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using YubicoDotNetClient;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
@ -24,7 +24,7 @@ public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
if (!await userService.CanAccessPremium(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@ -46,7 +46,7 @@ public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
if (!await userService.CanAccessPremium(user))
|
||||
{
|
||||
return false;
|
||||
}
|
@ -1,277 +0,0 @@
|
||||
/*
|
||||
Original source modified from https://github.com/duosecurity/duo_api_csharp
|
||||
|
||||
=============================================================================
|
||||
=============================================================================
|
||||
|
||||
Copyright (c) 2018 Duo Security
|
||||
All rights reserved
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using Bit.Core.Models.Api.Response.Duo;
|
||||
|
||||
namespace Bit.Core.Auth.Utilities;
|
||||
|
||||
public class DuoApi
|
||||
{
|
||||
private const string UrlScheme = "https";
|
||||
private const string UserAgent = "Bitwarden_DuoAPICSharp/1.0 (.NET Core)";
|
||||
|
||||
private readonly string _host;
|
||||
private readonly string _ikey;
|
||||
private readonly string _skey;
|
||||
|
||||
private readonly HttpClient _httpClient = new();
|
||||
|
||||
public DuoApi(string ikey, string skey, string host)
|
||||
{
|
||||
_ikey = ikey;
|
||||
_skey = skey;
|
||||
_host = host;
|
||||
|
||||
if (!ValidHost(host))
|
||||
{
|
||||
throw new DuoException("Invalid Duo host configured.", new ArgumentException(nameof(host)));
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ValidHost(string host)
|
||||
{
|
||||
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
|
||||
{
|
||||
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
|
||||
uri.Host.StartsWith("api-") &&
|
||||
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string CanonicalizeParams(Dictionary<string, string> parameters)
|
||||
{
|
||||
var ret = new List<string>();
|
||||
foreach (var pair in parameters)
|
||||
{
|
||||
var p = string.Format("{0}={1}", HttpUtility.UrlEncode(pair.Key), HttpUtility.UrlEncode(pair.Value));
|
||||
// Signatures require upper-case hex digits.
|
||||
p = Regex.Replace(p, "(%[0-9A-Fa-f][0-9A-Fa-f])", c => c.Value.ToUpperInvariant());
|
||||
// Escape only the expected characters.
|
||||
p = Regex.Replace(p, "([!'()*])", c => "%" + Convert.ToByte(c.Value[0]).ToString("X"));
|
||||
p = p.Replace("%7E", "~");
|
||||
// UrlEncode converts space (" ") to "+". The
|
||||
// signature algorithm requires "%20" instead. Actual
|
||||
// + has already been replaced with %2B.
|
||||
p = p.Replace("+", "%20");
|
||||
ret.Add(p);
|
||||
}
|
||||
|
||||
ret.Sort(StringComparer.Ordinal);
|
||||
return string.Join("&", ret.ToArray());
|
||||
}
|
||||
|
||||
protected string CanonicalizeRequest(string method, string path, string canonParams, string date)
|
||||
{
|
||||
string[] lines = {
|
||||
date,
|
||||
method.ToUpperInvariant(),
|
||||
_host.ToLower(),
|
||||
path,
|
||||
canonParams,
|
||||
};
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
public string Sign(string method, string path, string canonParams, string date)
|
||||
{
|
||||
var canon = CanonicalizeRequest(method, path, canonParams, date);
|
||||
var sig = HmacSign(canon);
|
||||
var auth = string.Concat(_ikey, ':', sig);
|
||||
return string.Concat("Basic ", Encode64(auth));
|
||||
}
|
||||
|
||||
/// <param name="timeout">The request timeout, in milliseconds.
|
||||
/// Specify 0 to use the system-default timeout. Use caution if
|
||||
/// you choose to specify a custom timeout - some API
|
||||
/// calls (particularly in the Auth APIs) will not
|
||||
/// return a response until an out-of-band authentication process
|
||||
/// has completed. In some cases, this may take as much as a
|
||||
/// small number of minutes.</param>
|
||||
private async Task<(string result, HttpStatusCode statusCode)> ApiCall(string method, string path, Dictionary<string, string> parameters, int timeout)
|
||||
{
|
||||
if (parameters == null)
|
||||
{
|
||||
parameters = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var canonParams = CanonicalizeParams(parameters);
|
||||
var query = string.Empty;
|
||||
if (!method.Equals("POST") && !method.Equals("PUT"))
|
||||
{
|
||||
if (parameters.Count > 0)
|
||||
{
|
||||
query = "?" + canonParams;
|
||||
}
|
||||
}
|
||||
var url = $"{UrlScheme}://{_host}{path}{query}";
|
||||
|
||||
var dateString = RFC822UtcNow();
|
||||
var auth = Sign(method, path, canonParams, dateString);
|
||||
|
||||
var request = new HttpRequestMessage
|
||||
{
|
||||
Method = new HttpMethod(method),
|
||||
RequestUri = new Uri(url),
|
||||
};
|
||||
request.Headers.Add("Authorization", auth);
|
||||
request.Headers.Add("X-Duo-Date", dateString);
|
||||
request.Headers.UserAgent.ParseAdd(UserAgent);
|
||||
|
||||
if (timeout > 0)
|
||||
{
|
||||
_httpClient.Timeout = TimeSpan.FromMilliseconds(timeout);
|
||||
}
|
||||
|
||||
if (method.Equals("POST") || method.Equals("PUT"))
|
||||
{
|
||||
request.Content = new StringContent(canonParams, Encoding.UTF8, "application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
var statusCode = response.StatusCode;
|
||||
return (result, statusCode);
|
||||
}
|
||||
|
||||
public async Task<Response> JSONApiCall(string method, string path, Dictionary<string, string> parameters = null)
|
||||
{
|
||||
return await JSONApiCall(method, path, parameters, 0);
|
||||
}
|
||||
|
||||
/// <param name="timeout">The request timeout, in milliseconds.
|
||||
/// Specify 0 to use the system-default timeout. Use caution if
|
||||
/// you choose to specify a custom timeout - some API
|
||||
/// calls (particularly in the Auth APIs) will not
|
||||
/// return a response until an out-of-band authentication process
|
||||
/// has completed. In some cases, this may take as much as a
|
||||
/// small number of minutes.</param>
|
||||
private async Task<Response> JSONApiCall(string method, string path, Dictionary<string, string> parameters, int timeout)
|
||||
{
|
||||
var (res, statusCode) = await ApiCall(method, path, parameters, timeout);
|
||||
try
|
||||
{
|
||||
var obj = JsonSerializer.Deserialize<DuoResponseModel>(res);
|
||||
if (obj.Stat == "OK")
|
||||
{
|
||||
return obj.Response;
|
||||
}
|
||||
|
||||
throw new ApiException(obj.Code ?? 0, (int)statusCode, obj.Message, obj.MessageDetail);
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new BadResponseException((int)statusCode, e);
|
||||
}
|
||||
}
|
||||
|
||||
private int? ToNullableInt(string s)
|
||||
{
|
||||
int i;
|
||||
if (int.TryParse(s, out i))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string HmacSign(string data)
|
||||
{
|
||||
var keyBytes = Encoding.ASCII.GetBytes(_skey);
|
||||
var dataBytes = Encoding.ASCII.GetBytes(data);
|
||||
|
||||
using (var hmac = new HMACSHA1(keyBytes))
|
||||
{
|
||||
var hash = hmac.ComputeHash(dataBytes);
|
||||
var hex = BitConverter.ToString(hash);
|
||||
return hex.Replace("-", string.Empty).ToLower();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Encode64(string plaintext)
|
||||
{
|
||||
var plaintextBytes = Encoding.ASCII.GetBytes(plaintext);
|
||||
return Convert.ToBase64String(plaintextBytes);
|
||||
}
|
||||
|
||||
private static string RFC822UtcNow()
|
||||
{
|
||||
// Can't use the "zzzz" format because it adds a ":"
|
||||
// between the offset's hours and minutes.
|
||||
var dateString = DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
var offset = 0;
|
||||
var zone = "+" + offset.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0');
|
||||
dateString += " " + zone.PadRight(5, '0');
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
public class DuoException : Exception
|
||||
{
|
||||
public int HttpStatus { get; private set; }
|
||||
|
||||
public DuoException(string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{ }
|
||||
|
||||
public DuoException(int httpStatus, string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{
|
||||
HttpStatus = httpStatus;
|
||||
}
|
||||
}
|
||||
|
||||
public class ApiException : DuoException
|
||||
{
|
||||
public int Code { get; private set; }
|
||||
public string ApiMessage { get; private set; }
|
||||
public string ApiMessageDetail { get; private set; }
|
||||
|
||||
public ApiException(int code, int httpStatus, string apiMessage, string apiMessageDetail)
|
||||
: base(httpStatus, FormatMessage(code, apiMessage, apiMessageDetail), null)
|
||||
{
|
||||
Code = code;
|
||||
ApiMessage = apiMessage;
|
||||
ApiMessageDetail = apiMessageDetail;
|
||||
}
|
||||
|
||||
private static string FormatMessage(int code, string apiMessage, string apiMessageDetail)
|
||||
{
|
||||
return string.Format("Duo API Error {0}: '{1}' ('{2}')", code, apiMessage, apiMessageDetail);
|
||||
}
|
||||
}
|
||||
|
||||
public class BadResponseException : DuoException
|
||||
{
|
||||
public BadResponseException(int httpStatus, Exception inner)
|
||||
: base(httpStatus, FormatMessage(httpStatus, inner), inner)
|
||||
{ }
|
||||
|
||||
private static string FormatMessage(int httpStatus, Exception inner)
|
||||
{
|
||||
var innerMessage = "(null)";
|
||||
if (inner != null)
|
||||
{
|
||||
innerMessage = string.Format("'{0}'", inner.Message);
|
||||
}
|
||||
return string.Format("Got error {0} with HTTP Status {1}", innerMessage, httpStatus);
|
||||
}
|
||||
}
|
@ -1,240 +0,0 @@
|
||||
/*
|
||||
Original source modified from https://github.com/duosecurity/duo_dotnet
|
||||
|
||||
=============================================================================
|
||||
=============================================================================
|
||||
|
||||
ref: https://github.com/duosecurity/duo_dotnet/blob/master/LICENSE
|
||||
|
||||
Copyright (c) 2011, Duo Security, Inc.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. The name of the author may not be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Bit.Core.Auth.Utilities.Duo;
|
||||
|
||||
public static class DuoWeb
|
||||
{
|
||||
private const string DuoProfix = "TX";
|
||||
private const string AppPrefix = "APP";
|
||||
private const string AuthPrefix = "AUTH";
|
||||
private const int DuoExpire = 300;
|
||||
private const int AppExpire = 3600;
|
||||
private const int IKeyLength = 20;
|
||||
private const int SKeyLength = 40;
|
||||
private const int AKeyLength = 40;
|
||||
|
||||
public static string ErrorUser = "ERR|The username passed to sign_request() is invalid.";
|
||||
public static string ErrorIKey = "ERR|The Duo integration key passed to sign_request() is invalid.";
|
||||
public static string ErrorSKey = "ERR|The Duo secret key passed to sign_request() is invalid.";
|
||||
public static string ErrorAKey = "ERR|The application secret key passed to sign_request() must be at least " +
|
||||
"40 characters.";
|
||||
public static string ErrorUnknown = "ERR|An unknown error has occurred.";
|
||||
|
||||
// throw on invalid bytes
|
||||
private static Encoding _encoding = new UTF8Encoding(false, true);
|
||||
private static DateTime _epoc = new DateTime(1970, 1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a signed request for Duo authentication.
|
||||
/// The returned value should be passed into the Duo.init() call
|
||||
/// in the rendered web page used for Duo authentication.
|
||||
/// </summary>
|
||||
/// <param name="ikey">Duo integration key</param>
|
||||
/// <param name="skey">Duo secret key</param>
|
||||
/// <param name="akey">Application secret key</param>
|
||||
/// <param name="username">Primary-authenticated username</param>
|
||||
/// <param name="currentTime">(optional) The current UTC time</param>
|
||||
/// <returns>signed request</returns>
|
||||
public static string SignRequest(string ikey, string skey, string akey, string username,
|
||||
DateTime? currentTime = null)
|
||||
{
|
||||
string duoSig;
|
||||
string appSig;
|
||||
|
||||
var currentTimeValue = currentTime ?? DateTime.UtcNow;
|
||||
|
||||
if (username == string.Empty)
|
||||
{
|
||||
return ErrorUser;
|
||||
}
|
||||
if (username.Contains("|"))
|
||||
{
|
||||
return ErrorUser;
|
||||
}
|
||||
if (ikey.Length != IKeyLength)
|
||||
{
|
||||
return ErrorIKey;
|
||||
}
|
||||
if (skey.Length != SKeyLength)
|
||||
{
|
||||
return ErrorSKey;
|
||||
}
|
||||
if (akey.Length < AKeyLength)
|
||||
{
|
||||
return ErrorAKey;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
duoSig = SignVals(skey, username, ikey, DuoProfix, DuoExpire, currentTimeValue);
|
||||
appSig = SignVals(akey, username, ikey, AppPrefix, AppExpire, currentTimeValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ErrorUnknown;
|
||||
}
|
||||
|
||||
return $"{duoSig}:{appSig}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate the signed response returned from Duo.
|
||||
/// Returns the username of the authenticated user, or null.
|
||||
/// </summary>
|
||||
/// <param name="ikey">Duo integration key</param>
|
||||
/// <param name="skey">Duo secret key</param>
|
||||
/// <param name="akey">Application secret key</param>
|
||||
/// <param name="sigResponse">The signed response POST'ed to the server</param>
|
||||
/// <param name="currentTime">(optional) The current UTC time</param>
|
||||
/// <returns>authenticated username, or null</returns>
|
||||
public static string VerifyResponse(string ikey, string skey, string akey, string sigResponse,
|
||||
DateTime? currentTime = null)
|
||||
{
|
||||
string authUser = null;
|
||||
string appUser = null;
|
||||
var currentTimeValue = currentTime ?? DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
var sigs = sigResponse.Split(':');
|
||||
var authSig = sigs[0];
|
||||
var appSig = sigs[1];
|
||||
|
||||
authUser = ParseVals(skey, authSig, AuthPrefix, ikey, currentTimeValue);
|
||||
appUser = ParseVals(akey, appSig, AppPrefix, ikey, currentTimeValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authUser != appUser)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return authUser;
|
||||
}
|
||||
|
||||
private static string SignVals(string key, string username, string ikey, string prefix, long expire,
|
||||
DateTime currentTime)
|
||||
{
|
||||
var ts = (long)(currentTime - _epoc).TotalSeconds;
|
||||
expire = ts + expire;
|
||||
var val = $"{username}|{ikey}|{expire.ToString()}";
|
||||
var cookie = $"{prefix}|{Encode64(val)}";
|
||||
var sig = Sign(key, cookie);
|
||||
return $"{cookie}|{sig}";
|
||||
}
|
||||
|
||||
private static string ParseVals(string key, string val, string prefix, string ikey, DateTime currentTime)
|
||||
{
|
||||
var ts = (long)(currentTime - _epoc).TotalSeconds;
|
||||
|
||||
var parts = val.Split('|');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var uPrefix = parts[0];
|
||||
var uB64 = parts[1];
|
||||
var uSig = parts[2];
|
||||
|
||||
var sig = Sign(key, $"{uPrefix}|{uB64}");
|
||||
if (Sign(key, sig) != Sign(key, uSig))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (uPrefix != prefix)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cookie = Decode64(uB64);
|
||||
var cookieParts = cookie.Split('|');
|
||||
if (cookieParts.Length != 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var username = cookieParts[0];
|
||||
var uIKey = cookieParts[1];
|
||||
var expire = cookieParts[2];
|
||||
|
||||
if (uIKey != ikey)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var expireTs = Convert.ToInt32(expire);
|
||||
if (ts >= expireTs)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
private static string Sign(string skey, string data)
|
||||
{
|
||||
var keyBytes = Encoding.ASCII.GetBytes(skey);
|
||||
var dataBytes = Encoding.ASCII.GetBytes(data);
|
||||
|
||||
using (var hmac = new HMACSHA1(keyBytes))
|
||||
{
|
||||
var hash = hmac.ComputeHash(dataBytes);
|
||||
var hex = BitConverter.ToString(hash);
|
||||
return hex.Replace("-", "").ToLower();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Encode64(string plaintext)
|
||||
{
|
||||
var plaintextBytes = _encoding.GetBytes(plaintext);
|
||||
return Convert.ToBase64String(plaintextBytes);
|
||||
}
|
||||
|
||||
private static string Decode64(string encoded)
|
||||
{
|
||||
var plaintextBytes = Convert.FromBase64String(encoded);
|
||||
return _encoding.GetString(plaintextBytes);
|
||||
}
|
||||
}
|
@ -11,22 +11,29 @@ namespace Bit.Core.Billing.Extensions;
|
||||
public static class BillingExtensions
|
||||
{
|
||||
public static bool IsBillable(this Provider provider) =>
|
||||
provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
|
||||
provider is
|
||||
{
|
||||
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
|
||||
Status: ProviderStatusType.Billable
|
||||
};
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this Provider provider)
|
||||
=> provider.Type is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
|
||||
public static bool IsValidClient(this Organization organization)
|
||||
=> organization is
|
||||
{
|
||||
Seats: not null,
|
||||
Status: OrganizationStatusType.Managed,
|
||||
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly
|
||||
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
public static bool IsStripeEnabled(this ISubscriber subscriber)
|
||||
=> !string.IsNullOrEmpty(subscriber.GatewayCustomerId) &&
|
||||
!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId);
|
||||
=> subscriber is
|
||||
{
|
||||
GatewayCustomerId: not null and not "",
|
||||
GatewaySubscriptionId: not null and not ""
|
||||
};
|
||||
|
||||
public static bool IsUnverifiedBankAccount(this SetupIntent setupIntent) =>
|
||||
setupIntent is
|
||||
@ -43,5 +50,5 @@ public static class BillingExtensions
|
||||
};
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this PlanType planType)
|
||||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
|
||||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually;
|
||||
}
|
||||
|
@ -4,4 +4,5 @@ public record OrganizationMetadata(
|
||||
bool IsEligibleForSelfHost,
|
||||
bool IsManaged,
|
||||
bool IsOnSecretsManagerStandalone,
|
||||
bool IsSubscriptionUnpaid);
|
||||
bool IsSubscriptionUnpaid,
|
||||
bool HasSubscription);
|
||||
|
@ -1,6 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services;
|
||||
public interface IProviderBillingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
|
||||
/// its <paramref name="provider"/>. Seat adjustments for the client organization may autoscale the provider's Stripe
|
||||
/// <see cref="Stripe.Subscription"/> depending on the provider's seat minimum for the client <paramref name="organization"/>'s
|
||||
/// <see cref="PlanType"/>.
|
||||
/// Changes the assigned provider plan for the provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param>
|
||||
/// <param name="organization">The client <see cref="Organization"/> whose <paramref name="seats"/> you want to update.</param>
|
||||
/// <param name="seats">The number of seats to assign to the client organization.</param>
|
||||
Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats);
|
||||
/// <param name="command">The command to change the provider plan.</param>
|
||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||
|
||||
/// <summary>
|
||||
/// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing
|
||||
@ -44,18 +35,6 @@ public interface IProviderBillingService
|
||||
Task<byte[]> GenerateClientInvoiceReport(
|
||||
string invoiceId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
||||
/// </summary>
|
||||
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
|
||||
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
|
||||
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
|
||||
Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
||||
Guid providerId,
|
||||
PlanType planType);
|
||||
|
||||
/// <summary>
|
||||
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
|
||||
/// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the
|
||||
@ -69,6 +48,22 @@ public interface IProviderBillingService
|
||||
PlanType planType,
|
||||
int seatAdjustment);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the provided <paramref name="seatAdjustment"/> will result in a purchase for the <paramref name="provider"/>'s <see cref="planType"/>.
|
||||
/// Seat adjustments that result in purchases include:
|
||||
/// <list type="bullet">
|
||||
/// <item>The <paramref name="provider"/> going from below the seat minimum to above the seat minimum for the provided <paramref name="planType"/></item>
|
||||
/// <item>The <paramref name="provider"/> going from above the seat minimum to further above the seat minimum for the provided <paramref name="planType"/></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to check seat adjustments for.</param>
|
||||
/// <param name="planType">The plan type to check seat adjustments for.</param>
|
||||
/// <param name="seatAdjustment">The change in seats for the <paramref name="provider"/>'s <paramref name="planType"/>.</param>
|
||||
Task<bool> SeatAdjustmentResultsInPurchase(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment);
|
||||
|
||||
/// <summary>
|
||||
/// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
|
||||
/// </summary>
|
||||
@ -90,12 +85,5 @@ public interface IProviderBillingService
|
||||
Task<Subscription> SetupSubscription(
|
||||
Provider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the assigned provider plan for the provider.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to change the provider plan.</param>
|
||||
/// <returns></returns>
|
||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||
|
||||
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user