[AC-358] SelfHosted update subscription page (#5101)

* [AC-358] Add selfHostSubscriptionExpiration property to organization-subscription.response.ts

* [AC-358] Update selfHost org subscription template

- Replace "Subscription" with "SubscriptionExpiration"
- Add question mark help link
- Add helper text for grace period
- Add support for graceful fallback in case of missing grace period in subscription response

* Update libs/common/src/billing/models/response/organization-subscription.response.ts

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>

* [AC-358] Remove unnecessary hypen

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>

* [AC-358] Introduce SelfHostedOrganizationSubscription view
- Encapsulate expiration/grace period logic in the new view object.
- Remove API response getters from the angular component
- Replace the API response object with the new view

* [AC-358] Clarify name for new expiration without grace period field

* [AC-358] Update constructor parameter name

* [AC-358] Simplify new selfhost subscription view

- Make expiration date properties public
- Remove obsolete expiration date getters
- Update the component to use new properties
- Add helper to component for determining if the subscription should be rendered as expired (red text)

* [AC-358] Rename isExpired to isExpiredAndOutsideGracePeriod to be more explicit

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Shane Melton 2023-05-15 07:38:53 -07:00 committed by GitHub
parent 44fd063dc1
commit bcda04ee86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 122 additions and 19 deletions

View File

@ -22,25 +22,45 @@
[providerName]="userOrg.providerName" [providerName]="userOrg.providerName"
></app-org-subscription-hidden> ></app-org-subscription-hidden>
<ng-container *ngIf="sub && firstLoaded"> <ng-container *ngIf="subscription && firstLoaded">
<dl> <dl>
<dt>{{ "billingPlan" | i18n }}</dt> <dt>{{ "billingPlan" | i18n }}</dt>
<dd>{{ sub.plan.name }}</dd> <dd>{{ subscription.planName }}</dd>
<dt>{{ "expiration" | i18n }}</dt>
<dd *ngIf="sub.expiration">
{{ sub.expiration | date : "mediumDate" }}
<span *ngIf="isExpired" class="text-danger ml-2">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
{{ "licenseIsExpired" | i18n }}
</span>
</dd>
<dd *ngIf="!sub.expiration">{{ "neverExpires" | i18n }}</dd>
<ng-container *ngIf="billingSyncSetUp"> <ng-container *ngIf="billingSyncSetUp">
<dt>{{ "lastLicenseSync" | i18n }}</dt> <dt>{{ "lastLicenseSync" | i18n }}</dt>
<dd> <dd>
{{ lastLicenseSync != null ? (lastLicenseSync | date : "medium") : ("never" | i18n) }} {{ lastLicenseSync != null ? (lastLicenseSync | date : "medium") : ("never" | i18n) }}
</dd> </dd>
</ng-container> </ng-container>
<dt>
<span [ngClass]="{ 'tw-text-danger': showAsExpired }">{{
"subscriptionExpiration" | i18n
}}</span>
<a
href="https://bitwarden.com/help/licensing-on-premise/#update-a-renewed-organization-license "
target="_blank"
[appA11yTitle]="'licensePaidFeaturesHelp' | i18n"
rel="noopener"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
<span class="sr-only">{{ "licensePaidFeaturesHelp" | i18n }}</span>
</a>
</dt>
<dd *ngIf="subscription.hasExpiration" [ngClass]="{ 'tw-text-danger': showAsExpired }">
{{
(subscription.hasSeparateGracePeriod
? subscription.expirationWithoutGracePeriod
: subscription.expirationWithGracePeriod
) | date : "mediumDate"
}}
<div *ngIf="subscription.hasSeparateGracePeriod" class="tw-text-muted">
{{
"selfHostGracePeriodHelp"
| i18n : (subscription.expirationWithGracePeriod | date : "mediumDate")
}}
</div>
</dd>
<dd *ngIf="!subscription.hasExpiration">{{ "neverExpires" | i18n }}</dd>
</dl> </dl>
<a <a

View File

@ -1,7 +1,7 @@
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms"; import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil, Subject } from "rxjs"; import { concatMap, Subject, takeUntil } from "rxjs";
import { ModalConfig, ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalConfig, ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -14,7 +14,7 @@ import { OrganizationConnectionType } from "@bitwarden/common/admin-console/enum
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationConnectionResponse } from "@bitwarden/common/admin-console/models/response/organization-connection.response"; import { OrganizationConnectionResponse } from "@bitwarden/common/admin-console/models/response/organization-connection.response";
import { BillingSyncConfigApi } from "@bitwarden/common/billing/models/api/billing-sync-config.api"; import { BillingSyncConfigApi } from "@bitwarden/common/billing/models/api/billing-sync-config.api";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { SelfHostedOrganizationSubscriptionView } from "@bitwarden/common/billing/models/view/self-hosted-organization-subscription.view";
import { import {
BillingSyncKeyComponent, BillingSyncKeyComponent,
@ -31,7 +31,7 @@ enum LicenseOptions {
templateUrl: "organization-subscription-selfhost.component.html", templateUrl: "organization-subscription-selfhost.component.html",
}) })
export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDestroy { export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDestroy {
sub: OrganizationSubscriptionResponse; subscription: SelfHostedOrganizationSubscriptionView;
organizationId: string; organizationId: string;
userOrg: Organization; userOrg: Organization;
@ -65,6 +65,15 @@ export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDest
return this.existingBillingSyncConnection?.enabled; return this.existingBillingSyncConnection?.enabled;
} }
/**
* Render the subscription as expired.
*/
get showAsExpired() {
return this.subscription.hasSeparateGracePeriod
? this.subscription.isExpiredWithoutGracePeriod
: this.subscription.isExpiredAndOutsideGracePeriod;
}
constructor( constructor(
private modalService: ModalService, private modalService: ModalService,
private messagingService: MessagingService, private messagingService: MessagingService,
@ -102,7 +111,10 @@ export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDest
this.loading = true; this.loading = true;
this.userOrg = this.organizationService.get(this.organizationId); this.userOrg = this.organizationService.get(this.organizationId);
if (this.userOrg.canViewSubscription) { if (this.userOrg.canViewSubscription) {
this.sub = await this.organizationApiService.getSubscription(this.organizationId); const subscriptionResponse = await this.organizationApiService.getSubscription(
this.organizationId
);
this.subscription = new SelfHostedOrganizationSubscriptionView(subscriptionResponse);
} }
this.loading = false; this.loading = false;
@ -159,10 +171,6 @@ export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDest
return this.existingBillingSyncConnection?.id != null; return this.existingBillingSyncConnection?.id != null;
} }
get isExpired() {
return this.sub?.expiration != null && new Date(this.sub.expiration) < new Date();
}
get updateMethod() { get updateMethod() {
return this.form.get("updateMethod").value; return this.form.get("updateMethod").value;
} }

View File

@ -6553,6 +6553,18 @@
"billingSyncHelp": { "billingSyncHelp": {
"message": "Billing Sync help" "message": "Billing Sync help"
}, },
"licensePaidFeaturesHelp": {
"message": "License paid features help"
},
"selfHostGracePeriodHelp": {
"message": "After your subscription expires, you have 60 days to apply an updated license file to your organization. Grace period ends $GRACE_PERIOD_END_DATE$.",
"placeholders": {
"GRACE_PERIOD_END_DATE": {
"content": "$1",
"example": "May 12, 2024"
}
}
},
"uploadLicense": { "uploadLicense": {
"message": "Upload license" "message": "Upload license"
}, },

View File

@ -11,6 +11,7 @@ export class OrganizationSubscriptionResponse extends OrganizationResponse {
subscription: BillingSubscriptionResponse; subscription: BillingSubscriptionResponse;
upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse; upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse;
expiration: string; expiration: string;
expirationWithoutGracePeriod: string;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@ -24,5 +25,6 @@ export class OrganizationSubscriptionResponse extends OrganizationResponse {
? null ? null
: new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice); : new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice);
this.expiration = this.getResponseProperty("Expiration"); this.expiration = this.getResponseProperty("Expiration");
this.expirationWithoutGracePeriod = this.getResponseProperty("ExpirationWithoutGracePeriod");
} }
} }

View File

@ -0,0 +1,61 @@
import { View } from "../../../models/view/view";
import { OrganizationSubscriptionResponse } from "../response/organization-subscription.response";
export class SelfHostedOrganizationSubscriptionView implements View {
planName: string;
/**
* Date the subscription expires, including the grace period.
*/
expirationWithGracePeriod?: Date;
/**
* Date the subscription expires, excluding the grace period.
* This will be `null` for older (< v12) license files because they do not include this date.
* In this case, you have to rely on the `expirationWithGracePeriod` instead.
*/
expirationWithoutGracePeriod?: Date;
constructor(response: OrganizationSubscriptionResponse) {
if (response == null) {
return;
}
this.planName = response.plan.name;
this.expirationWithGracePeriod =
response.expiration != null ? new Date(response.expiration) : null;
this.expirationWithoutGracePeriod =
response.expirationWithoutGracePeriod != null
? new Date(response.expirationWithoutGracePeriod)
: null;
}
/**
* The subscription has separate expiration dates for the subscription and the end of grace period.
*/
get hasSeparateGracePeriod() {
return this.expirationWithGracePeriod != null && this.expirationWithoutGracePeriod != null;
}
/**
* True if the subscription has an expiration date.
*/
get hasExpiration() {
return this.expirationWithGracePeriod != null;
}
/**
* True if the subscription has an expiration date that has past, but may still be within the grace period.
* For older licenses (< v12), this will always be false because they do not include the `expirationWithoutGracePeriod`.
*/
get isExpiredWithoutGracePeriod() {
return this.hasSeparateGracePeriod && this.expirationWithoutGracePeriod < new Date();
}
/**
* True if the subscription has an expiration date that has past, including the grace period.
*/
get isExpiredAndOutsideGracePeriod() {
return this.hasExpiration && this.expirationWithGracePeriod < new Date();
}
}