1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-5718] Fix free organization generating TOTP (#11918)

* [PM-5718] Fix totp generation for free orgs in old add-edit component

* [PM-5718] Fix totp generation for free orgs in view cipher view component

* [PM-5718] Cleanup merge conflicts

* Don't generate totp code for premium users or free orgs

* Added redirect to organization helper page

* Changed text to learn more

* Only show upgrade message to premium users

* Show upgrade message to free users with free orgs as well

---------

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
Co-authored-by: gbubemismith <gsmithwalter@gmail.com>
This commit is contained in:
Shane Melton
2025-01-13 09:58:52 -08:00
committed by GitHub
parent 3bed613a91
commit 459fb1bcf4
9 changed files with 89 additions and 19 deletions

View File

@@ -330,6 +330,20 @@ export class AppComponent implements OnInit, OnDestroy {
} }
break; break;
} }
case "upgradeOrganization": {
const upgradeConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "upgradeOrganization" },
content: { key: "upgradeOrganizationDesc" },
acceptButtonText: { key: "learnMore" },
type: "info",
});
if (upgradeConfirmed) {
this.platformUtilsService.launchUri(
"https://bitwarden.com/help/upgrade-from-individual-to-org/",
);
}
break;
}
case "emailVerificationRequired": { case "emailVerificationRequired": {
const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({ const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "emailVerificationRequired" }, title: { key: "emailVerificationRequired" },

View File

@@ -3474,5 +3474,14 @@
}, },
"changeAcctEmail": { "changeAcctEmail": {
"message": "Change account email" "message": "Change account email"
},
"organizationUpgradeRequired": {
"message": "Organization upgrade required"
},
"upgradeOrganization": {
"message": "Upgrade organization"
},
"upgradeOrganizationDesc": {
"message": "This feature is not available for free organizations. Switch to a paid plan to unlock more features."
} }
} }

View File

@@ -186,6 +186,16 @@
</span> </span>
</div> </div>
</div> </div>
<div class="box-content-row box-content-row-flex totp" *ngIf="showUpgradeRequiredTotp">
<div class="row-main">
<span class="row-label">{{ "verificationCodeTotp" | i18n }}</span>
<span class="row-label">
<a [routerLink]="" (click)="upgradeOrganization()"
>{{ "organizationUpgradeRequired" | i18n }}
</a>
</span>
</div>
</div>
</div> </div>
<!-- Card --> <!-- Card -->
<div *ngIf="cipher.card"> <div *ngIf="cipher.card">

View File

@@ -157,4 +157,10 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
this.messagingService.send("premiumRequired"); this.messagingService.send("premiumRequired");
} }
} }
upgradeOrganization() {
this.messagingService.send("upgradeOrganization", {
organizationId: this.cipher.organizationId,
});
}
} }

View File

@@ -66,7 +66,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
protected messagingService: MessagingService, protected messagingService: MessagingService,
eventCollectionService: EventCollectionService, eventCollectionService: EventCollectionService,
protected policyService: PolicyService, protected policyService: PolicyService,
organizationService: OrganizationService, protected organizationService: OrganizationService,
logService: LogService, logService: LogService,
passwordRepromptService: PasswordRepromptService, passwordRepromptService: PasswordRepromptService,
dialogService: DialogService, dialogService: DialogService,
@@ -307,7 +307,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
this.cipher.type === CipherType.Login && this.cipher.type === CipherType.Login &&
this.cipher.login.totp && this.cipher.login.totp &&
this.organization?.productTierType != ProductTierType.Free && this.organization?.productTierType != ProductTierType.Free &&
(this.cipher.organizationUseTotp || this.canAccessPremium) ((this.canAccessPremium && this.cipher.organizationId == null) ||
this.cipher.organizationUseTotp)
); );
} }

View File

@@ -128,7 +128,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected policyService: PolicyService, protected policyService: PolicyService,
protected logService: LogService, protected logService: LogService,
protected passwordRepromptService: PasswordRepromptService, protected passwordRepromptService: PasswordRepromptService,
private organizationService: OrganizationService, protected organizationService: OrganizationService,
protected dialogService: DialogService, protected dialogService: DialogService,
protected win: Window, protected win: Window,
protected datePipe: DatePipe, protected datePipe: DatePipe,

View File

@@ -65,6 +65,7 @@ export class ViewComponent implements OnDestroy, OnInit {
showPrivateKey: boolean; showPrivateKey: boolean;
canAccessPremium: boolean; canAccessPremium: boolean;
showPremiumRequiredTotp: boolean; showPremiumRequiredTotp: boolean;
showUpgradeRequiredTotp: boolean;
totpCode: string; totpCode: string;
totpCodeFormatted: string; totpCodeFormatted: string;
totpDash: number; totpDash: number;
@@ -151,22 +152,25 @@ export class ViewComponent implements OnDestroy, OnInit {
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId), this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
); );
this.showPremiumRequiredTotp = this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationId;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId, this.collectionId as CollectionId,
]); ]);
this.showUpgradeRequiredTotp =
this.cipher.login.totp && this.cipher.organizationId && !this.cipher.organizationUseTotp;
if (this.cipher.folderId) { if (this.cipher.folderId) {
this.folder = await ( this.folder = await (
await firstValueFrom(this.folderService.folderViews$(activeUserId)) await firstValueFrom(this.folderService.folderViews$(activeUserId))
).find((f) => f.id == this.cipher.folderId); ).find((f) => f.id == this.cipher.folderId);
} }
if ( const canGenerateTotp = this.cipher.organizationId
this.cipher.type === CipherType.Login && ? this.cipher.organizationUseTotp
this.cipher.login.totp && : this.canAccessPremium;
(cipher.organizationUseTotp || this.canAccessPremium)
) { if (this.cipher.type === CipherType.Login && this.cipher.login.totp && canGenerateTotp) {
await this.totpUpdateCode(); await this.totpUpdateCode();
const interval = this.totpService.getTimeInterval(this.cipher.login.totp); const interval = this.totpService.getTimeInterval(this.cipher.login.totp);
await this.totpTick(interval); await this.totpTick(interval);

View File

@@ -116,7 +116,7 @@
<bit-label [appTextDrag]="totpCodeCopyObj?.totpCode" <bit-label [appTextDrag]="totpCodeCopyObj?.totpCode"
>{{ "verificationCodeTotp" | i18n }} >{{ "verificationCodeTotp" | i18n }}
<span <span
*ngIf="!(isPremium$ | async)" *ngIf="!(allowTotpGeneration$ | async)"
bitBadge bitBadge
variant="success" variant="success"
class="tw-ml-2 tw-cursor-pointer" class="tw-ml-2 tw-cursor-pointer"
@@ -130,14 +130,14 @@
id="totp" id="totp"
readonly readonly
bitInput bitInput
[type]="!(isPremium$ | async) ? 'password' : 'text'" [type]="!(allowTotpGeneration$ | async) ? 'password' : 'text'"
[value]="totpCodeCopyObj?.totpCodeFormatted || '*** ***'" [value]="totpCodeCopyObj?.totpCodeFormatted || '*** ***'"
aria-readonly="true" aria-readonly="true"
data-testid="login-totp" data-testid="login-totp"
class="tw-font-mono" class="tw-font-mono"
/> />
<div <div
*ngIf="isPremium$ | async" *ngIf="allowTotpGeneration$ | async"
bitTotpCountdown bitTotpCountdown
[cipher]="cipher" [cipher]="cipher"
bitSuffix bitSuffix
@@ -152,7 +152,7 @@
showToast showToast
[appA11yTitle]="'copyVerificationCode' | i18n" [appA11yTitle]="'copyVerificationCode' | i18n"
data-testid="copy-totp" data-testid="copy-totp"
[disabled]="!(isPremium$ | async)" [disabled]="!(allowTotpGeneration$ | async)"
class="disabled:tw-cursor-default" class="disabled:tw-cursor-default"
></button> ></button>
</bit-form-field> </bit-form-field>

View File

@@ -2,7 +2,15 @@
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule, DatePipe } from "@angular/common"; import { CommonModule, DatePipe } from "@angular/common";
import { Component, inject, Input } from "@angular/core"; import { Component, inject, Input } from "@angular/core";
import { Observable, switchMap } from "rxjs"; import {
BehaviorSubject,
combineLatest,
filter,
map,
Observable,
shareReplay,
switchMap,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
@@ -12,13 +20,13 @@ import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { import {
BadgeModule,
ColorPasswordModule,
FormFieldModule, FormFieldModule,
IconButtonModule,
SectionComponent, SectionComponent,
SectionHeaderComponent, SectionHeaderComponent,
TypographyModule, TypographyModule,
IconButtonModule,
BadgeModule,
ColorPasswordModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
// FIXME: remove `src` and fix import // FIXME: remove `src` and fix import
@@ -51,13 +59,31 @@ type TotpCodeValues = {
], ],
}) })
export class LoginCredentialsViewComponent { export class LoginCredentialsViewComponent {
@Input() cipher: CipherView; @Input()
get cipher(): CipherView {
return this._cipher$.value;
}
set cipher(value: CipherView) {
this._cipher$.next(value);
}
private _cipher$ = new BehaviorSubject<CipherView>(null);
isPremium$: Observable<boolean> = this.accountService.activeAccount$.pipe( private _userHasPremium$: Observable<boolean> = this.accountService.activeAccount$.pipe(
switchMap((account) => switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
), ),
); );
allowTotpGeneration$: Observable<boolean> = combineLatest([
this._userHasPremium$,
this._cipher$.pipe(filter((c) => c != null)),
]).pipe(
map(([userHasPremium, cipher]) => {
// User premium status only applies to personal ciphers, organizationUseTotp applies to organization ciphers
return (userHasPremium && cipher.organizationId == null) || cipher.organizationUseTotp;
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
showPasswordCount: boolean = false; showPasswordCount: boolean = false;
passwordRevealed: boolean = false; passwordRevealed: boolean = false;
totpCodeCopyObj: TotpCodeValues; totpCodeCopyObj: TotpCodeValues;