1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-13 00:51:45 +01:00

Merge branch 'main' into autofill/pm-8518-autofill-scripts-do-not-inject-into-sub-frames-on-install

This commit is contained in:
Cesar Gonzalez 2024-06-04 09:54:00 -05:00 committed by GitHub
commit 66873dee95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
142 changed files with 6734 additions and 958 deletions

View File

@ -659,7 +659,7 @@ jobs:
- name: Download artifact from hotfix-rc
if: github.ref == 'refs/heads/hotfix-rc'
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build-browser.yml
workflow_conclusion: success
@ -668,7 +668,7 @@ jobs:
- name: Download artifact from rc
if: github.ref == 'refs/heads/rc'
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build-browser.yml
workflow_conclusion: success
@ -677,7 +677,7 @@ jobs:
- name: Download artifacts from main
if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }}
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build-browser.yml
workflow_conclusion: success
@ -864,7 +864,7 @@ jobs:
- name: Download artifact from hotfix-rc
if: github.ref == 'refs/heads/hotfix-rc'
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build-browser.yml
workflow_conclusion: success
@ -873,7 +873,7 @@ jobs:
- name: Download artifact from rc
if: github.ref == 'refs/heads/rc'
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build-browser.yml
workflow_conclusion: success
@ -882,7 +882,7 @@ jobs:
- name: Download artifact from main
if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }}
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build-browser.yml
workflow_conclusion: success

View File

@ -48,10 +48,12 @@ jobs:
# Tests in apps/ are typechecked when their app is built, so we just do it here for libs/
# See https://bitwarden.atlassian.net/browse/EC-497
- name: Run typechecking
run: npm run test:types --coverage
run: npm run test:types
- name: Run tests
run: npm run test --coverage
# maxWorkers is a workaround for a memory leak that crashes tests in CI:
# https://github.com/facebook/jest/issues/9430#issuecomment-1149882002
run: npm test -- --coverage --maxWorkers=3
- name: Report test results
uses: dorny/test-reporter@eaa763f6ffc21c7a37837f56cd5f9737f27fc6c8 # v1.8.0

View File

@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2024.5.2",
"version": "2024.6.0",
"scripts": {
"build": "cross-env MANIFEST_VERSION=3 webpack",
"build:mv2": "webpack",

View File

@ -763,7 +763,7 @@
"message": "Kilidi aç"
},
"additionalOptions": {
"message": "Additional options"
"message": "Əlavə seçimlər"
},
"enableContextMenuItem": {
"message": "Konteks menyu seçimlərini göstər"
@ -803,7 +803,7 @@
"description": "'Solarized' is a noun and the name of a color scheme. It should not be translated."
},
"exportFrom": {
"message": "Export from"
"message": "Buradan xaricə köçür"
},
"exportVault": {
"message": "Anbarı xaricə köçür"
@ -812,28 +812,28 @@
"message": "Fayl formatı"
},
"fileEncryptedExportWarningDesc": {
"message": "This file export will be password protected and require the file password to decrypt."
"message": "Bu faylın xaricə köçürülməsi, parolla qorunacaq və şifrəsini açmaq üçün fayl parolu tələb olunacaq."
},
"filePassword": {
"message": "File password"
"message": "Fayl parolu"
},
"exportPasswordDescription": {
"message": "This password will be used to export and import this file"
"message": "Bu parol, bu faylı daxilə və xaricə köçürmək üçün istifadə olunacaq"
},
"accountRestrictedOptionDescription": {
"message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account."
"message": "Xaricə köçürməni şifrələmək və daxilə köçürməni yalnız mövcud Bitwarden hesabı ilə məhdudlaşdırmaq üçün hesabınızın istifadəçi adı və Ana Parolundan əldə edilən hesab şifrələmə açarınızı istifadə edin."
},
"passwordProtectedOptionDescription": {
"message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption."
"message": "Xaricə köçürməni şifrələmək üçün bir fayl parolu təyin edin və şifrəni açma parolunu istifadə edərək bunu istənilən Bitwarden hesabına köçürün."
},
"exportTypeHeading": {
"message": "Export type"
"message": "Xaricə köçürmə növü"
},
"accountRestricted": {
"message": "Account restricted"
"message": "Hesab məhdudlaşdırıldı"
},
"filePasswordAndConfirmFilePasswordDoNotMatch": {
"message": "“File password” and “Confirm file password“ do not match."
"message": "\"Fayl parolu\" və \"Fayl parolunu təsdiqlə\" uyuşmur."
},
"warning": {
"message": "XƏBƏRDARLIQ",
@ -2213,10 +2213,10 @@
}
},
"exportingOrganizationVaultTitle": {
"message": "Exporting organization vault"
"message": "Təşkilat anbarını xaricə köçürmə"
},
"exportingOrganizationVaultDesc": {
"message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.",
"message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat anbarı ixrac ediləcək. Fərdi anbardakı və digər təşkilat elementlər daxil edilmir.",
"placeholders": {
"organization": {
"content": "$1",

View File

@ -821,7 +821,7 @@
"message": "Dieses Passwort wird zum Exportieren und Importieren dieser Datei verwendet"
},
"accountRestrictedOptionDescription": {
"message": "Verwende den Verschlüsselungscode deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken."
"message": "Verwende den Verschlüsselungsschlüssel deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken."
},
"passwordProtectedOptionDescription": {
"message": "Lege ein Dateipasswort fest, um den Export zu verschlüsseln und importiere ihn in ein beliebiges Bitwarden-Konto, wobei das Passwort zum Entschlüsseln genutzt wird."

View File

@ -224,7 +224,7 @@
},
"continueToAuthenticatorPageDesc": {
"message": "Bitwarden Authenticator allows you to store authenticator keys and generate TOTP codes for 2-step verification flows. Learn more on the bitwarden.com website"
},
},
"bitwardenSecretsManager": {
"message": "Bitwarden Secrets Manager"
},
@ -599,6 +599,9 @@
"loggedOut": {
"message": "Logged out"
},
"loggedOutDesc": {
"message": "You have been logged out of your account."
},
"loginExpired": {
"message": "Your login session has expired."
},
@ -1107,6 +1110,15 @@
"selfHostedEnvironmentFooter": {
"message": "Specify the base URL of your on-premises hosted Bitwarden installation."
},
"selfHostedBaseUrlHint": {
"message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com"
},
"selfHostedCustomEnvHeader" :{
"message": "For advanced configuration, you can specify the base URL of each service independently."
},
"selfHostedEnvFormInvalid" :{
"message": "You must add either the base Server URL or at least one custom environment."
},
"customEnvironment": {
"message": "Custom environment"
},
@ -1744,6 +1756,12 @@
"ok": {
"message": "Ok"
},
"errorRefreshingAccessToken":{
"message": "Access Token Refresh Error"
},
"errorRefreshingAccessTokenDesc":{
"message": "No refresh token or API keys found. Please try logging out and logging back in."
},
"desktopSyncVerificationTitle": {
"message": "Desktop sync verification"
},
@ -3333,5 +3351,14 @@
"example": "Work"
}
}
},
"itemsWithNoFolder": {
"message": "Items with no folder"
},
"organizationIsDeactivated": {
"message": "Organization is deactivated"
},
"contactYourOrgAdmin": {
"message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance."
}
}

View File

@ -423,7 +423,7 @@
"message": "Kita"
},
"unlockMethods": {
"message": "Unlock options"
"message": "Atrakinti parinktis"
},
"unlockMethodNeededToChangeTimeoutActionDesc": {
"message": "Nustatyk atrakinimo būdą, kad pakeistum saugyklos laiko limito veiksmą."
@ -432,10 +432,10 @@
"message": "Nustatykite nustatymuose atrakinimo metodą"
},
"sessionTimeoutHeader": {
"message": "Session timeout"
"message": "Baigėsi seanso laikas"
},
"otherOptions": {
"message": "Other options"
"message": "Kitos parinktys"
},
"rateExtension": {
"message": "Įvertinkite šį plėtinį"
@ -2274,7 +2274,7 @@
"message": "Sugeneruoti el. pašto slapyvardį su išorine persiuntimo paslauga."
},
"forwarderError": {
"message": "$SERVICENAME$ error: $ERRORMESSAGE$",
"message": "„$SERVICENAME$“ klaida: $ERRORMESSAGE$.",
"description": "Reports an error returned by a forwarding service to the user.",
"placeholders": {
"servicename": {
@ -2288,11 +2288,11 @@
}
},
"forwarderGeneratedBy": {
"message": "Generated by Bitwarden.",
"message": "Sugeneravo „Bitwarden“.",
"description": "Displayed with the address on the forwarding service's configuration screen."
},
"forwarderGeneratedByWithWebsite": {
"message": "Website: $WEBSITE$. Generated by Bitwarden.",
"message": "Svetainė: $WEBSITE$. Sugeneravo „Bitwarden“.",
"description": "Displayed with the address on the forwarding service's configuration screen.",
"placeholders": {
"WEBSITE": {
@ -2302,7 +2302,7 @@
}
},
"forwaderInvalidToken": {
"message": "Invalid $SERVICENAME$ API token",
"message": "Netinkamas „$SERVICENAME$“ API prieigos raktas.",
"description": "Displayed when the user's API token is empty or rejected by the forwarding service.",
"placeholders": {
"servicename": {
@ -2312,7 +2312,7 @@
}
},
"forwaderInvalidTokenWithMessage": {
"message": "Invalid $SERVICENAME$ API token: $ERRORMESSAGE$",
"message": "Netinkamas „$SERVICENAME$“ API prieigos raktas: $ERRORMESSAGE$.",
"description": "Displayed when the user's API token is rejected by the forwarding service with an error message.",
"placeholders": {
"servicename": {
@ -2326,7 +2326,7 @@
}
},
"forwarderNoAccountId": {
"message": "Unable to obtain $SERVICENAME$ masked email account ID.",
"message": "Nepavyksta gauti „$SERVICENAME$“ užmaskuoto el. pašto paskyros ID.",
"description": "Displayed when the forwarding service fails to return an account ID.",
"placeholders": {
"servicename": {
@ -2336,7 +2336,7 @@
}
},
"forwarderNoDomain": {
"message": "Invalid $SERVICENAME$ domain.",
"message": "Netinkamas „$SERVICENAME$“ domenas.",
"description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.",
"placeholders": {
"servicename": {
@ -2346,7 +2346,7 @@
}
},
"forwarderNoUrl": {
"message": "Invalid $SERVICENAME$ url.",
"message": "Netinkamas „$SERVICENAME$“ URL.",
"description": "Displayed when the url of the forwarding service wasn't supplied.",
"placeholders": {
"servicename": {
@ -2356,7 +2356,7 @@
}
},
"forwarderUnknownError": {
"message": "Unknown $SERVICENAME$ error occurred.",
"message": "Įvyko nežinoma „$SERVICENAME$“ klaida.",
"description": "Displayed when the forwarding service failed due to an unknown error.",
"placeholders": {
"servicename": {
@ -2366,7 +2366,7 @@
}
},
"forwarderUnknownForwarder": {
"message": "Unknown forwarder: '$SERVICENAME$'.",
"message": "Nežinomas persiuntėjas: „$SERVICENAME$“.",
"description": "Displayed when the forwarding service is not supported.",
"placeholders": {
"servicename": {
@ -3287,13 +3287,13 @@
"message": "Administratoriaus konsolės"
},
"accountSecurity": {
"message": "Account security"
"message": "Paskyros saugumas"
},
"notifications": {
"message": "Notifications"
"message": "Pranešimai"
},
"appearance": {
"message": "Appearance"
"message": "Išvaizda"
},
"errorAssigningTargetCollection": {
"message": "Klaida priskiriant tikslinę kolekciją."

View File

@ -763,7 +763,7 @@
"message": "Lås upp"
},
"additionalOptions": {
"message": "Additional options"
"message": "Ytterligare alternativ"
},
"enableContextMenuItem": {
"message": "Visa alternativ för snabbmenyn"
@ -803,7 +803,7 @@
"description": "'Solarized' is a noun and the name of a color scheme. It should not be translated."
},
"exportFrom": {
"message": "Export from"
"message": "Exportera från"
},
"exportVault": {
"message": "Exportera valv"
@ -815,7 +815,7 @@
"message": "This file export will be password protected and require the file password to decrypt."
},
"filePassword": {
"message": "File password"
"message": "Fillösenord"
},
"exportPasswordDescription": {
"message": "This password will be used to export and import this file"
@ -827,7 +827,7 @@
"message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption."
},
"exportTypeHeading": {
"message": "Export type"
"message": "Exporttyp"
},
"accountRestricted": {
"message": "Account restricted"

View File

@ -763,7 +763,7 @@
"message": "Розблокувати"
},
"additionalOptions": {
"message": "Additional options"
"message": "Додаткові налаштування"
},
"enableContextMenuItem": {
"message": "Показувати в контекстному меню"
@ -803,7 +803,7 @@
"description": "'Solarized' is a noun and the name of a color scheme. It should not be translated."
},
"exportFrom": {
"message": "Export from"
"message": "Експортувати з"
},
"exportVault": {
"message": "Експортувати сховище"
@ -812,28 +812,28 @@
"message": "Формат файлу"
},
"fileEncryptedExportWarningDesc": {
"message": "This file export will be password protected and require the file password to decrypt."
"message": "Цей експортований файл буде захищений паролем, який необхідно ввести для його розшифрування."
},
"filePassword": {
"message": "File password"
"message": "Пароль файлу"
},
"exportPasswordDescription": {
"message": "This password will be used to export and import this file"
"message": "Цей пароль буде використано для експортування та імпортування цього файлу"
},
"accountRestrictedOptionDescription": {
"message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account."
"message": "Використовуйте ключ шифрування свого облікового запису, створений на основі імені користувача й головного пароля, щоб зашифрувати експортовані дані та обмежити можливість імпортування лише до поточного облікового запису Bitwarden."
},
"passwordProtectedOptionDescription": {
"message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption."
"message": "Встановіть пароль файлу, щоб зашифрувати експортовані дані та імпортувати до будь-якого облікового запису Bitwarden за допомогою цього пароля."
},
"exportTypeHeading": {
"message": "Export type"
"message": "Тип експорту"
},
"accountRestricted": {
"message": "Account restricted"
"message": "Обмежено обліковим записом"
},
"filePasswordAndConfirmFilePasswordDoNotMatch": {
"message": "“File password” and “Confirm file password“ do not match."
"message": "Пароль файлу та підтвердження пароля відрізняються."
},
"warning": {
"message": "ПОПЕРЕДЖЕННЯ",
@ -2213,10 +2213,10 @@
}
},
"exportingOrganizationVaultTitle": {
"message": "Exporting organization vault"
"message": "Експортування сховища організації"
},
"exportingOrganizationVaultDesc": {
"message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.",
"message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Елементи особистих сховищ або інших організацій не будуть включені.",
"placeholders": {
"organization": {
"content": "$1",

View File

@ -821,10 +821,10 @@
"message": "此密码将用于导出和导入此文件"
},
"accountRestrictedOptionDescription": {
"message": "使用衍生自您账户的用户名和主密码的加密密钥,以加密此导出并限制只能导入到当前的 Bitwarden 账户。"
"message": "使用衍生自您账户的用户名和主密码的账户加密密钥,以加密此导出并限制只能导入到当前的 Bitwarden 账户。"
},
"passwordProtectedOptionDescription": {
"message": "设置一个密码用来加密导出的数据,并使用此密码解密以导入到任意 Bitwarden 账户。"
"message": "设置一个文件密码用来加密导出,并使用此密码解密以导入到任意 Bitwarden 账户。"
},
"exportTypeHeading": {
"message": "导出类型"

View File

@ -9,6 +9,7 @@ import {
AuthRequestService,
LoginEmailServiceAbstraction,
LoginEmailService,
LogoutReason,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@ -375,8 +376,17 @@ export default class MainBackground {
}
};
const logoutCallback = async (expired: boolean, userId?: UserId) =>
await this.logout(expired, userId);
const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) =>
await this.logout(logoutReason, userId);
const refreshAccessTokenErrorCallback = () => {
// Send toast to popup
this.messagingService.send("showToast", {
type: "error",
title: this.i18nService.t("errorRefreshingAccessToken"),
message: this.i18nService.t("errorRefreshingAccessTokenDesc"),
});
};
const isDev = process.env.ENV === "development";
this.logService = new ConsoleLogService(isDev);
@ -523,6 +533,7 @@ export default class MainBackground {
this.keyGenerationService,
this.encryptService,
this.logService,
logoutCallback,
);
const migrationRunner = new MigrationRunner(
@ -608,9 +619,12 @@ export default class MainBackground {
this.platformUtilsService,
this.environmentService,
this.appIdService,
refreshAccessTokenErrorCallback,
this.logService,
(logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId),
this.vaultTimeoutSettingsService,
(expired: boolean) => this.logout(expired),
);
this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider);
this.fileUploadService = new FileUploadService(this.logService);
this.cipherFileUploadService = new CipherFileUploadService(
@ -1283,7 +1297,7 @@ export default class MainBackground {
}
}
async logout(expired: boolean, userId?: UserId) {
async logout(logoutReason: LogoutReason, userId?: UserId) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(
map((a) => a?.id),
@ -1349,7 +1363,7 @@ export default class MainBackground {
await logoutPromise;
this.messagingService.send("doneLoggingOut", {
expired: expired,
logoutReason: logoutReason,
userId: userBeingLoggedOut,
});

View File

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.5.2",
"version": "2024.6.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.5.2",
"version": "2024.6.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angula
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@ -10,7 +11,12 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
import {
DialogService,
SimpleDialogOptions,
ToastOptions,
ToastService,
} from "@bitwarden/components";
import { BrowserApi } from "../platform/browser/browser-api";
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
@ -83,13 +89,10 @@ export class AppComponent implements OnInit, OnDestroy {
.pipe(
tap((msg: any) => {
if (msg.command === "doneLoggingOut") {
// TODO: PM-8544 - why do we call logout in the popup after receiving the doneLoggingOut message? Hasn't this already completeted logout?
this.authService.logOut(async () => {
if (msg.expired) {
this.toastService.showToast({
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
});
if (msg.logoutReason) {
await this.displayLogoutReason(msg.logoutReason);
}
});
this.changeDetectorRef.detectChanges();
@ -233,4 +236,23 @@ export class AppComponent implements OnInit, OnDestroy {
this.browserSendStateService.setBrowserSendTypeComponentState(null),
]);
}
// Displaying toasts isn't super useful on the popup due to the reloads we do.
// However, it is visible for a moment on the FF sidebar logout.
private async displayLogoutReason(logoutReason: LogoutReason) {
let toastOptions: ToastOptions;
switch (logoutReason) {
case "invalidSecurityStamp":
case "sessionExpired": {
toastOptions = {
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
};
break;
}
}
this.toastService.showToast(toastOptions);
}
}

View File

@ -0,0 +1,39 @@
<form [formGroup]="filterForm" class="tw-flex tw-flex-wrap tw-gap-2 tw-mb-6 tw-mt-2">
<ng-container *ngIf="organizations$ | async as organizations">
<bit-chip-select
*ngIf="organizations.length"
formControlName="organization"
placeholderIcon="bwi-vault"
[placeholderText]="'vault' | i18n"
[options]="organizations"
>
</bit-chip-select>
</ng-container>
<ng-container *ngIf="collections$ | async as collections">
<bit-chip-select
*ngIf="collections.length"
formControlName="collection"
placeholderIcon="bwi-collection"
[placeholderText]="'collections' | i18n"
[options]="collections"
>
</bit-chip-select>
</ng-container>
<ng-container *ngIf="folders$ | async as folders">
<bit-chip-select
*ngIf="folders.length"
placeholderIcon="bwi-folder"
formControlName="folder"
[placeholderText]="'folder' | i18n"
[options]="folders"
>
</bit-chip-select>
</ng-container>
<bit-chip-select
formControlName="cipherType"
placeholderIcon="bwi-list"
[placeholderText]="'types' | i18n"
[options]="cipherTypes"
>
</bit-chip-select>
</form>

View File

@ -0,0 +1,28 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ChipSelectComponent } from "@bitwarden/components";
import { VaultPopupListFiltersService } from "../../../services/vault-popup-list-filters.service";
@Component({
standalone: true,
selector: "app-vault-list-filters",
templateUrl: "./vault-list-filters.component.html",
imports: [CommonModule, JslibModule, ChipSelectComponent, ReactiveFormsModule],
})
export class VaultListFiltersComponent implements OnDestroy {
protected filterForm = this.vaultPopupListFiltersService.filterForm;
protected organizations$ = this.vaultPopupListFiltersService.organizations$;
protected collections$ = this.vaultPopupListFiltersService.collections$;
protected folders$ = this.vaultPopupListFiltersService.folders$;
protected cipherTypes = this.vaultPopupListFiltersService.cipherTypes;
constructor(private vaultPopupListFiltersService: VaultPopupListFiltersService) {}
ngOnDestroy(): void {
this.vaultPopupListFiltersService.resetFilterForm();
}
}

View File

@ -22,13 +22,13 @@
</div>
<ng-container *ngIf="!(showEmptyState$ | async)">
<!-- TODO: Filter/search Section in PM-6824 and PM-6826.-->
<app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)">
</app-vault-v2-search>
<app-vault-list-filters></app-vault-list-filters>
<div
*ngIf="showNoResultsState$ | async"
*ngIf="(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items>
@ -37,7 +37,17 @@
</bit-no-items>
</div>
<ng-container *ngIf="!(showNoResultsState$ | async)">
<div
*ngIf="showDeactivatedOrg$ | async"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items [icon]="deactivatedIcon">
<ng-container slot="title">{{ "organizationIsDeactivated" | i18n }}</ng-container>
<ng-container slot="description">{{ "contactYourOrgAdmin" | i18n }}</ng-container>
</bit-no-items>
</div>
<ng-container *ngIf="!(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)">
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"

View File

@ -11,6 +11,7 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2";
import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component";
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component";
@Component({
@ -27,6 +28,7 @@ import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search
CommonModule,
AutofillVaultListItemsComponent,
VaultListItemsContainerComponent,
VaultListFiltersComponent,
ButtonModule,
RouterLink,
VaultV2SearchComponent,
@ -38,8 +40,10 @@ export class VaultV2Component implements OnInit, OnDestroy {
protected showEmptyState$ = this.vaultPopupItemsService.emptyVault$;
protected showNoResultsState$ = this.vaultPopupItemsService.noFilteredResults$;
protected showDeactivatedOrg$ = this.vaultPopupItemsService.showDeactivatedOrg$;
protected vaultIcon = Icons.Vault;
protected deactivatedIcon = Icons.DeactivatedOrg;
constructor(
private vaultPopupItemsService: VaultPopupItemsService,

View File

@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
@ -12,6 +13,7 @@ import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { VaultPopupItemsService } from "./vault-popup-items.service";
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
describe("VaultPopupItemsService", () => {
let service: VaultPopupItemsService;
@ -20,6 +22,8 @@ describe("VaultPopupItemsService", () => {
const cipherServiceMock = mock<CipherService>();
const vaultSettingsServiceMock = mock<VaultSettingsService>();
const organizationServiceMock = mock<OrganizationService>();
const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>();
const searchService = mock<SearchService>();
beforeEach(() => {
@ -40,6 +44,18 @@ describe("VaultPopupItemsService", () => {
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers);
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable();
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable();
vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({
organization: null,
collection: null,
cipherType: null,
folder: null,
});
// Return all ciphers, `filterFunction$` will be tested in `VaultPopupListFiltersService`
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
(ciphers: CipherView[]) => ciphers,
);
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest
.spyOn(BrowserApi, "getTabFromCurrentWindow")
@ -47,6 +63,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
});
@ -55,6 +73,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
expect(service).toBeTruthy();
@ -87,6 +107,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
@ -117,6 +139,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
@ -228,6 +252,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
service.emptyVault$.subscribe((empty) => {

View File

@ -2,6 +2,8 @@ import { Injectable } from "@angular/core";
import {
BehaviorSubject,
combineLatest,
distinctUntilKeyChanged,
from,
map,
Observable,
of,
@ -12,6 +14,7 @@ import {
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@ -20,6 +23,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
/**
* Service for managing the various item lists on the new Vault tab in the browser popup.
*/
@ -72,7 +77,15 @@ export class VaultPopupItemsService {
shareReplay({ refCount: false, bufferSize: 1 }),
);
private _filteredCipherList$ = combineLatest([this._cipherList$, this.searchText$]).pipe(
private _filteredCipherList$: Observable<CipherView[]> = combineLatest([
this._cipherList$,
this.searchText$,
this.vaultPopupListFiltersService.filterFunction$,
]).pipe(
map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [
filterFunction(ciphers),
searchText,
]),
switchMap(([ciphers, searchText]) =>
this.searchService.searchCiphers(searchText, null, ciphers),
),
@ -137,10 +150,19 @@ export class VaultPopupItemsService {
/**
* Observable that indicates whether a filter is currently applied to the ciphers.
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
*/
hasFilterApplied$: Observable<boolean> = this.searchText$.pipe(
switchMap((text) => this.searchService.isSearchable(text)),
hasFilterApplied$ = combineLatest([
this.searchText$,
this.vaultPopupListFiltersService.filters$,
]).pipe(
switchMap(([searchText, filters]) => {
return from(this.searchService.isSearchable(searchText)).pipe(
map(
(isSearchable) =>
isSearchable || Object.values(filters).some((filter) => filter !== null),
),
);
}),
);
/**
@ -156,15 +178,31 @@ export class VaultPopupItemsService {
/**
* Observable that indicates whether there are no ciphers to show with the current filter.
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
*/
noFilteredResults$: Observable<boolean> = this._filteredCipherList$.pipe(
map((ciphers) => !ciphers.length),
);
/** Observable that indicates when the user should see the deactivated org state */
showDeactivatedOrg$: Observable<boolean> = combineLatest([
this.vaultPopupListFiltersService.filters$.pipe(distinctUntilKeyChanged("organization")),
this.organizationService.organizations$,
]).pipe(
map(([filters, orgs]) => {
if (!filters.organization || filters.organization.id === MY_VAULT_ID) {
return false;
}
const org = orgs.find((o) => o.id === filters.organization.id);
return org ? !org.enabled : false;
}),
);
constructor(
private cipherService: CipherService,
private vaultSettingsService: VaultSettingsService,
private vaultPopupListFiltersService: VaultPopupListFiltersService,
private organizationService: OrganizationService,
private searchService: SearchService,
) {}

View File

@ -0,0 +1,298 @@
import { TestBed } from "@angular/core/testing";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skipWhile } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
describe("VaultPopupListFiltersService", () => {
let service: VaultPopupListFiltersService;
const memberOrganizations$ = new BehaviorSubject<{ name: string; id: string }[]>([]);
const folderViews$ = new BehaviorSubject([]);
const cipherViews$ = new BehaviorSubject({});
const decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]);
const collectionService = {
decryptedCollections$,
getAllNested: () => Promise.resolve([]),
} as unknown as CollectionService;
const folderService = {
folderViews$,
} as unknown as FolderService;
const cipherService = {
cipherViews$,
} as unknown as CipherService;
const organizationService = {
memberOrganizations$,
} as unknown as OrganizationService;
const i18nService = {
t: (key: string) => key,
} as I18nService;
beforeEach(() => {
memberOrganizations$.next([]);
decryptedCollections$.next([]);
collectionService.getAllNested = () => Promise.resolve([]);
TestBed.configureTestingModule({
providers: [
{
provide: FolderService,
useValue: folderService,
},
{
provide: CipherService,
useValue: cipherService,
},
{
provide: OrganizationService,
useValue: organizationService,
},
{
provide: I18nService,
useValue: i18nService,
},
{
provide: CollectionService,
useValue: collectionService,
},
{ provide: FormBuilder, useClass: FormBuilder },
],
});
service = TestBed.inject(VaultPopupListFiltersService);
});
describe("cipherTypes", () => {
it("returns all cipher types", () => {
expect(service.cipherTypes.map((c) => c.value)).toEqual([
CipherType.Login,
CipherType.Card,
CipherType.Identity,
CipherType.SecureNote,
]);
});
});
describe("organizations$", () => {
it('does not add "myVault" to the list of organizations when there are no organizations', (done) => {
memberOrganizations$.next([]);
service.organizations$.subscribe((organizations) => {
expect(organizations.map((o) => o.label)).toEqual([]);
done();
});
});
it('adds "myVault" to the list of organizations when there are other organizations', (done) => {
memberOrganizations$.next([{ name: "bobby's org", id: "1234-3323-23223" }]);
service.organizations$.subscribe((organizations) => {
expect(organizations.map((o) => o.label)).toEqual(["myVault", "bobby's org"]);
done();
});
});
it("sorts organizations by name", (done) => {
memberOrganizations$.next([
{ name: "bobby's org", id: "1234-3323-23223" },
{ name: "alice's org", id: "2223-4343-99888" },
]);
service.organizations$.subscribe((organizations) => {
expect(organizations.map((o) => o.label)).toEqual([
"myVault",
"alice's org",
"bobby's org",
]);
done();
});
});
});
describe("collections$", () => {
const testCollection = {
id: "14cbf8e9-7a2a-4105-9bf6-b15c01203cef",
name: "Test collection",
organizationId: "3f860945-b237-40bc-a51e-b15c01203ccf",
} as CollectionView;
const testCollection2 = {
id: "b15c0120-7a2a-4105-9bf6-b15c01203ceg",
name: "Test collection 2",
organizationId: "1203ccf-2432-123-acdd-b15c01203ccf",
} as CollectionView;
const testCollections = [testCollection, testCollection2];
beforeEach(() => {
decryptedCollections$.next(testCollections);
collectionService.getAllNested = () =>
Promise.resolve(
testCollections.map((c) => ({
children: [],
node: c,
parent: null,
})),
);
});
it("returns all collections", (done) => {
service.collections$.subscribe((collections) => {
expect(collections.map((c) => c.label)).toEqual(["Test collection", "Test collection 2"]);
done();
});
});
it("filters out collections that do not belong to an organization", () => {
service.filterForm.patchValue({
organization: { id: testCollection2.organizationId } as Organization,
});
service.collections$.subscribe((collections) => {
expect(collections.map((c) => c.label)).toEqual(["Test collection 2"]);
});
});
});
describe("folders$", () => {
it('returns no folders when "No Folder" is the only option', (done) => {
folderViews$.next([{ id: null, name: "No Folder" }]);
service.folders$.subscribe((folders) => {
expect(folders).toEqual([]);
done();
});
});
it('moves "No Folder" to the end of the list', (done) => {
folderViews$.next([
{ id: null, name: "No Folder" },
{ id: "2345", name: "Folder 2" },
{ id: "1234", name: "Folder 1" },
]);
service.folders$.subscribe((folders) => {
expect(folders.map((f) => f.label)).toEqual(["Folder 1", "Folder 2", "itemsWithNoFolder"]);
done();
});
});
it("returns all folders when MyVault is selected", (done) => {
service.filterForm.patchValue({
organization: { id: MY_VAULT_ID } as Organization,
});
folderViews$.next([
{ id: "1234", name: "Folder 1" },
{ id: "2345", name: "Folder 2" },
]);
service.folders$.subscribe((folders) => {
expect(folders.map((f) => f.label)).toEqual(["Folder 1", "Folder 2"]);
done();
});
});
it("returns folders that have ciphers within the selected organization", (done) => {
service.folders$.pipe(skipWhile((folders) => folders.length === 2)).subscribe((folders) => {
expect(folders.map((f) => f.label)).toEqual(["Folder 1"]);
done();
});
service.filterForm.patchValue({
organization: { id: "1234" } as Organization,
});
folderViews$.next([
{ id: "1234", name: "Folder 1" },
{ id: "2345", name: "Folder 2" },
]);
cipherViews$.next({
"1": { folderId: "1234", organizationId: "1234" },
"2": { folderId: "2345", organizationId: "56789" },
});
});
});
describe("filterFunction$", () => {
const ciphers = [
{ type: CipherType.Login, collectionIds: [], organizationId: null },
{ type: CipherType.Card, collectionIds: ["1234"], organizationId: "8978" },
{ type: CipherType.Identity, collectionIds: [], folderId: "5432", organizationId: null },
{ type: CipherType.SecureNote, collectionIds: [], organizationId: null },
] as CipherView[];
it("filters by cipherType", (done) => {
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[0]]);
done();
});
service.filterForm.patchValue({ cipherType: CipherType.Login });
});
it("filters by collection", (done) => {
const collection = { id: "1234" } as Collection;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[1]]);
done();
});
service.filterForm.patchValue({ collection });
});
it("filters by folder", (done) => {
const folder = { id: "5432" } as FolderView;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[2]]);
done();
});
service.filterForm.patchValue({ folder });
});
describe("organizationId", () => {
it("filters out ciphers that belong to an organization when MyVault is selected", (done) => {
const organization = { id: MY_VAULT_ID } as Organization;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[0], ciphers[2], ciphers[3]]);
done();
});
service.filterForm.patchValue({ organization });
});
it("filters out ciphers that do not belong to the selected organization", (done) => {
const organization = { id: "8978" } as Organization;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[1]]);
done();
});
service.filterForm.patchValue({ organization });
});
});
});
});

View File

@ -0,0 +1,371 @@
import { Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder } from "@angular/forms";
import {
Observable,
combineLatest,
distinctUntilChanged,
map,
startWith,
switchMap,
tap,
} from "rxjs";
import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { ChipSelectOption } from "@bitwarden/components";
/** All available cipher filters */
export type PopupListFilter = {
organization: Organization | null;
collection: Collection | null;
folder: FolderView | null;
cipherType: CipherType | null;
};
/** Delimiter that denotes a level of nesting */
const NESTING_DELIMITER = "/";
/** Id assigned to the "My vault" organization */
export const MY_VAULT_ID = "MyVault";
const INITIAL_FILTERS: PopupListFilter = {
organization: null,
collection: null,
folder: null,
cipherType: null,
};
@Injectable({
providedIn: "root",
})
export class VaultPopupListFiltersService {
/**
* UI form for all filters
*/
filterForm = this.formBuilder.group<PopupListFilter>(INITIAL_FILTERS);
/**
* Observable for `filterForm` value
*/
filters$ = this.filterForm.valueChanges.pipe(
startWith(INITIAL_FILTERS),
) as Observable<PopupListFilter>;
/**
* Static list of ciphers views used in synchronous context
*/
private cipherViews: CipherView[] = [];
/**
* Observable of cipher views
*/
private cipherViews$: Observable<CipherView[]> = this.cipherService.cipherViews$.pipe(
tap((cipherViews) => {
this.cipherViews = Object.values(cipherViews);
}),
map((ciphers) => Object.values(ciphers)),
);
constructor(
private folderService: FolderService,
private cipherService: CipherService,
private organizationService: OrganizationService,
private i18nService: I18nService,
private collectionService: CollectionService,
private formBuilder: FormBuilder,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(this.validateOrganizationChange.bind(this));
}
/**
* Observable whose value is a function that filters an array of `CipherView` objects based on the current filters
*/
filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = this.filters$.pipe(
map(
(filters) => (ciphers: CipherView[]) =>
ciphers.filter((cipher) => {
if (filters.cipherType !== null && cipher.type !== filters.cipherType) {
return false;
}
if (
filters.collection !== null &&
!cipher.collectionIds.includes(filters.collection.id)
) {
return false;
}
if (filters.folder !== null && cipher.folderId !== filters.folder.id) {
return false;
}
const isMyVault = filters.organization?.id === MY_VAULT_ID;
if (isMyVault) {
if (cipher.organizationId !== null) {
return false;
}
} else if (filters.organization !== null) {
if (cipher.organizationId !== filters.organization.id) {
return false;
}
}
return true;
}),
),
);
/**
* All available cipher types
*/
readonly cipherTypes: ChipSelectOption<CipherType>[] = [
{
value: CipherType.Login,
label: this.i18nService.t("logins"),
icon: "bwi-globe",
},
{
value: CipherType.Card,
label: this.i18nService.t("cards"),
icon: "bwi-credit-card",
},
{
value: CipherType.Identity,
label: this.i18nService.t("identities"),
icon: "bwi-id-card",
},
{
value: CipherType.SecureNote,
label: this.i18nService.t("notes"),
icon: "bwi-sticky-note",
},
];
/** Resets `filterForm` to the original state */
resetFilterForm(): void {
this.filterForm.reset(INITIAL_FILTERS);
}
/**
* Organization array structured to be directly passed to `ChipSelectComponent`
*/
organizations$: Observable<ChipSelectOption<Organization>[]> =
this.organizationService.memberOrganizations$.pipe(
map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))),
map((orgs) => {
if (!orgs.length) {
return [];
}
return [
// When the user is a member of an organization, make the "My Vault" option available
{
value: { id: MY_VAULT_ID } as Organization,
label: this.i18nService.t("myVault"),
icon: "bwi-user",
},
...orgs.map((org) => {
let icon = "bwi-business";
if (!org.enabled) {
// Show a warning icon if the organization is deactivated
icon = "bwi-exclamation-triangle tw-text-danger";
} else if (org.planProductType === ProductType.Families) {
// Show a family icon if the organization is a family org
icon = "bwi-family";
}
return {
value: org,
label: org.name,
icon,
};
}),
];
}),
);
/**
* Folder array structured to be directly passed to `ChipSelectComponent`
*/
folders$: Observable<ChipSelectOption<string>[]> = combineLatest([
this.filters$.pipe(
distinctUntilChanged(
(previousFilter, currentFilter) =>
// Only update the collections when the organizationId filter changes
previousFilter.organization?.id === currentFilter.organization?.id,
),
),
this.folderService.folderViews$,
this.cipherViews$,
]).pipe(
map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => {
if (folders.length === 1 && folders[0].id === null) {
// Do not display folder selections when only the "no folder" option is available.
return [filters, [], cipherViews];
}
// Sort folders by alphabetic name
folders.sort(Utils.getSortFunction(this.i18nService, "name"));
let arrangedFolders = folders;
const noFolder = folders.find((f) => f.id === null);
if (noFolder) {
// Update `name` of the "no folder" option to "Items with no folder"
noFolder.name = this.i18nService.t("itemsWithNoFolder");
// Move the "no folder" option to the end of the list
arrangedFolders = [...folders.filter((f) => f.id !== null), noFolder];
}
return [filters, arrangedFolders, cipherViews];
}),
map(([filters, folders, cipherViews]) => {
const organizationId = filters.organization?.id ?? null;
// When no org or "My vault" is selected, return all folders
if (organizationId === null || organizationId === MY_VAULT_ID) {
return folders;
}
const orgCiphers = cipherViews.filter((c) => c.organizationId === organizationId);
// Return only the folders that have ciphers within the filtered organization
return folders.filter((f) => orgCiphers.some((oc) => oc.folderId === f.id));
}),
map((folders) => {
const nestedFolders = this.getAllFoldersNested(folders);
return new DynamicTreeNode<FolderView>({
fullList: folders,
nestedList: nestedFolders,
});
}),
map((folders) => folders.nestedList.map(this.convertToChipSelectOption.bind(this))),
);
/**
* Collection array structured to be directly passed to `ChipSelectComponent`
*/
collections$: Observable<ChipSelectOption<string>[]> = combineLatest([
this.filters$.pipe(
distinctUntilChanged(
(previousFilter, currentFilter) =>
// Only update the collections when the organizationId filter changes
previousFilter.organization?.id === currentFilter.organization?.id,
),
),
this.collectionService.decryptedCollections$,
]).pipe(
map(([filters, allCollections]) => {
const organizationId = filters.organization?.id ?? null;
// When the organization filter is selected, filter out collections that do not belong to the selected organization
const collections =
organizationId === null
? allCollections
: allCollections.filter((c) => c.organizationId === organizationId);
return collections;
}),
switchMap(async (collections) => {
const nestedCollections = await this.collectionService.getAllNested(collections);
return new DynamicTreeNode<CollectionView>({
fullList: collections,
nestedList: nestedCollections,
});
}),
map((collections) => collections.nestedList.map(this.convertToChipSelectOption.bind(this))),
);
/**
* Converts the given item into the `ChipSelectOption` structure
*/
private convertToChipSelectOption<T extends ITreeNodeObject>(
item: TreeNode<T>,
): ChipSelectOption<T> {
return {
value: item.node,
label: item.node.name,
icon: "bwi-folder", // Organization & Folder icons are the same
children: item.children
? item.children.map(this.convertToChipSelectOption.bind(this))
: undefined,
};
}
/**
* Returns a nested folder structure based on the input FolderView array
*/
private getAllFoldersNested(folders: FolderView[]): TreeNode<FolderView>[] {
const nodes: TreeNode<FolderView>[] = [];
folders.forEach((f) => {
const folderCopy = new FolderView();
folderCopy.id = f.id;
folderCopy.revisionDate = f.revisionDate;
// Remove "/" from beginning and end of the folder name
// then split the folder name by the delimiter
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NESTING_DELIMITER) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NESTING_DELIMITER);
});
return nodes;
}
/**
* Validate collection & folder filters when the organization filter changes
*/
private validateOrganizationChange(organization: Organization | null): void {
if (!organization) {
return;
}
const currentFilters = this.filterForm.getRawValue();
// When the organization filter changes and a collection is already selected,
// reset the collection filter if the collection does not belong to the new organization filter
if (currentFilters.collection && currentFilters.collection.organizationId !== organization.id) {
this.filterForm.get("collection").setValue(null);
}
// When the organization filter changes and a folder is already selected,
// reset the folder filter if the folder does not belong to the new organization filter
if (
currentFilters.folder &&
currentFilters.folder.id !== null &&
organization.id !== MY_VAULT_ID
) {
// Get all ciphers that belong to the new organization
const orgCiphers = this.cipherViews.filter((c) => c.organizationId === organization.id);
// Find any ciphers within the organization that belong to the current folder
const newOrgContainsFolder = orgCiphers.some(
(oc) => oc.folderId === currentFilters.folder.id,
);
// If the new organization does not contain the current folder, reset the folder filter
if (!newOrgContainsFolder) {
this.filterForm.get("folder").setValue(null);
}
}
}
}

View File

@ -1,7 +1,7 @@
{
"name": "@bitwarden/cli",
"description": "A secure and free password manager for all of your devices.",
"version": "2024.5.0",
"version": "2024.6.0",
"keywords": [
"bitwarden",
"password",

View File

@ -6,6 +6,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ApiService } from "@bitwarden/common/services/api.service";
@ -21,8 +22,10 @@ export class NodeApiService extends ApiService {
platformUtilsService: PlatformUtilsService,
environmentService: EnvironmentService,
appIdService: AppIdService,
refreshAccessTokenErrorCallback: () => Promise<void>,
logService: LogService,
logoutCallback: () => Promise<void>,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
logoutCallback: (expired: boolean) => Promise<void>,
customUserAgent: string = null,
) {
super(
@ -30,8 +33,10 @@ export class NodeApiService extends ApiService {
platformUtilsService,
environmentService,
appIdService,
vaultTimeoutSettingsService,
refreshAccessTokenErrorCallback,
logService,
logoutCallback,
vaultTimeoutSettingsService,
customUserAgent,
);
}

View File

@ -255,6 +255,8 @@ export class ServiceContainer {
p = path.join(process.env.HOME, ".config/Bitwarden CLI");
}
const logoutCallback = async () => await this.logout();
this.platformUtilsService = new CliPlatformUtilsService(ClientType.Cli, packageJson);
this.logService = new ConsoleLogService(
this.platformUtilsService.isDev(),
@ -337,6 +339,7 @@ export class ServiceContainer {
this.keyGenerationService,
this.encryptService,
this.logService,
logoutCallback,
);
const migrationRunner = new MigrationRunner(
@ -421,13 +424,19 @@ export class ServiceContainer {
VaultTimeoutStringType.Never, // default vault timeout
);
const refreshAccessTokenErrorCallback = () => {
throw new Error("Refresh Access token error");
};
this.apiService = new NodeApiService(
this.tokenService,
this.platformUtilsService,
this.environmentService,
this.appIdService,
refreshAccessTokenErrorCallback,
this.logService,
logoutCallback,
this.vaultTimeoutSettingsService,
async (expired: boolean) => await this.logout(),
customUserAgent,
);
@ -485,7 +494,7 @@ export class ServiceContainer {
this.logService,
this.organizationService,
this.keyGenerationService,
async (expired: boolean) => await this.logout(),
logoutCallback,
this.stateProvider,
);
@ -660,7 +669,7 @@ export class ServiceContainer {
this.sendApiService,
this.userDecryptionOptionsService,
this.avatarService,
async (expired: boolean) => await this.logout(),
logoutCallback,
this.billingAccountProfileStateService,
this.tokenService,
this.authService,

View File

@ -39,9 +39,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.80"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "arboard"
@ -83,9 +83,9 @@ dependencies = [
[[package]]
name = "base64"
version = "0.22.0"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"

View File

@ -14,9 +14,9 @@ manual_test = []
[dependencies]
aes = "=0.8.4"
anyhow = "=1.0.80"
anyhow = "=1.0.86"
arboard = { version = "=3.3.2", default-features = false, features = ["wayland-data-control"] }
base64 = "=0.22.0"
base64 = "=0.22.1"
cbc = { version = "=0.1.2", features = ["alloc"] }
napi = { version = "=2.16.0", features = ["async"] }
napi-derive = "=2.16.0"

View File

@ -18,7 +18,7 @@
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "18.19.29",
"@types/node": "20.14.1",
"@types/node-ipc": "9.2.3",
"typescript": "4.7.4"
}
@ -98,9 +98,10 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="
},
"node_modules/@types/node": {
"version": "18.19.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.29.tgz",
"integrity": "sha512-5pAX7ggTmWZdhUrhRWLPf+5oM7F80bcKVCBbr0zwEkTNzTJL2CWQjznpFgHYy6GrzkYi2Yjy7DHKoynFxqPV8g==",
"version": "20.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.1.tgz",
"integrity": "sha512-T2MzSGEu+ysB/FkWfqmhV3PLyQlowdptmmgD20C6QxsS8Fmv5SjpZ1ayXaEC0S21/h5UJ9iA6W/5vSNU5l00OA==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}

View File

@ -23,7 +23,7 @@
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "18.19.29",
"@types/node": "20.14.1",
"@types/node-ipc": "9.2.3",
"typescript": "4.7.4"
},

View File

@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2024.5.1",
"version": "2024.6.0",
"keywords": [
"bitwarden",
"password",

View File

@ -1,3 +1,4 @@
import { DialogRef } from "@angular/cdk/dialog";
import {
Component,
NgZone,
@ -13,6 +14,7 @@ import { filter, firstValueFrom, map, Subject, takeUntil, timeout } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
import { LogoutReason } from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@ -48,7 +50,7 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogService, ToastOptions, ToastService } from "@bitwarden/components";
import { DeleteAccountComponent } from "../auth/delete-account.component";
import { LoginApprovalComponent } from "../auth/login/login-approval.component";
@ -108,6 +110,7 @@ export class AppComponent implements OnInit, OnDestroy {
private idleTimer: number = null;
private isIdle = false;
private activeUserId: UserId = null;
private activeSimpleDialog: DialogRef<boolean> = null;
private destroy$ = new Subject<void>();
@ -207,7 +210,7 @@ export class AppComponent implements OnInit, OnDestroy {
break;
case "logout":
this.loading = message.userId == null || message.userId === this.activeUserId;
await this.logOut(!!message.expired, message.userId);
await this.logOut(message.logoutReason, message.userId);
this.loading = false;
break;
case "lockVault":
@ -545,9 +548,73 @@ export class AppComponent implements OnInit, OnDestroy {
this.messagingService.send("updateAppMenu", { updateRequest: updateRequest });
}
private async displayLogoutReason(logoutReason: LogoutReason) {
let toastOptions: ToastOptions;
switch (logoutReason) {
case "invalidSecurityStamp":
case "sessionExpired": {
toastOptions = {
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
};
break;
}
// We don't expect these scenarios to be common, but we want the user to
// understand why they are being logged out before a process reload.
case "accessTokenUnableToBeDecrypted": {
// Don't create multiple dialogs if this fires multiple times
if (this.activeSimpleDialog) {
// Let the caller of this function listen for the dialog to close
return firstValueFrom(this.activeSimpleDialog.closed);
}
this.activeSimpleDialog = this.dialogService.openSimpleDialogRef({
title: { key: "loggedOut" },
content: { key: "accessTokenUnableToBeDecrypted" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
await firstValueFrom(this.activeSimpleDialog.closed);
this.activeSimpleDialog = null;
break;
}
case "refreshTokenSecureStorageRetrievalFailure": {
// Don't create multiple dialogs if this fires multiple times
if (this.activeSimpleDialog) {
// Let the caller of this function listen for the dialog to close
return firstValueFrom(this.activeSimpleDialog.closed);
}
this.activeSimpleDialog = this.dialogService.openSimpleDialogRef({
title: { key: "loggedOut" },
content: { key: "refreshTokenSecureStorageRetrievalFailure" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
await firstValueFrom(this.activeSimpleDialog.closed);
this.activeSimpleDialog = null;
break;
}
}
if (toastOptions) {
this.toastService.showToast(toastOptions);
}
}
// Even though the userId parameter is no longer optional doesn't mean a message couldn't be
// passing null-ish values to us.
private async logOut(expired: boolean, userId: UserId) {
private async logOut(logoutReason: LogoutReason, userId: UserId) {
await this.displayLogoutReason(logoutReason);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
@ -620,15 +687,7 @@ export class AppComponent implements OnInit, OnDestroy {
// This must come last otherwise the logout will prematurely trigger
// a process reload before all the state service user data can be cleaned up
if (userBeingLoggedOut === activeUserId) {
this.authService.logOut(async () => {
if (expired) {
this.platformUtilsService.showToast(
"warning",
this.i18nService.t("loggedOut"),
this.i18nService.t("loginExpired"),
);
}
});
this.authService.logOut(async () => {});
}
}
@ -710,7 +769,7 @@ export class AppComponent implements OnInit, OnDestroy {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
options[1] === "logOut"
? this.logOut(false, userId as UserId)
? this.logOut("vaultTimeout", userId as UserId)
: await this.vaultTimeoutService.lock(userId);
}
}

View File

@ -1300,7 +1300,7 @@
"description": "ex. Date this password was updated"
},
"exportFrom": {
"message": "Export from"
"message": "Buradan xaricə köçür"
},
"exportVault": {
"message": "Anbarı xaricə köçür"
@ -1309,31 +1309,31 @@
"message": "Fayl formatı"
},
"fileEncryptedExportWarningDesc": {
"message": "This file export will be password protected and require the file password to decrypt."
"message": "Bu faylın xaricə köçürülməsi, parolla qorunacaq və şifrəsini açmaq üçün fayl parolu tələb olunacaq."
},
"filePassword": {
"message": "File password"
"message": "Fayl parolu"
},
"exportPasswordDescription": {
"message": "This password will be used to export and import this file"
"message": "Bu parol, bu faylı daxilə və xaricə köçürmək üçün istifadə olunacaq"
},
"accountRestrictedOptionDescription": {
"message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account."
"message": "Xaricə köçürməni şifrələmək və daxilə köçürməni yalnız mövcud Bitwarden hesabı ilə məhdudlaşdırmaq üçün hesabınızın istifadəçi adı və Ana Parolundan əldə edilən hesab şifrələmə açarınızı istifadə edin."
},
"passwordProtected": {
"message": "Password protected"
"message": "Parolla qorunan"
},
"passwordProtectedOptionDescription": {
"message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption."
"message": "Xaricə köçürməni şifrələmək üçün bir fayl parolu təyin edin və şifrəni açma parolunu istifadə edərək bunu istənilən Bitwarden hesabına köçürün."
},
"exportTypeHeading": {
"message": "Export type"
"message": "Xaricə köçürmə növü"
},
"accountRestricted": {
"message": "Account restricted"
"message": "Hesab məhdudlaşdırıldı"
},
"filePasswordAndConfirmFilePasswordDoNotMatch": {
"message": "“File password” and “Confirm file password“ do not match."
"message": "\"Fayl parolu\" və \"Fayl parolunu təsdiqlə\" uyuşmur."
},
"hCaptchaUrl": {
"message": "hCaptcha ünvanı",
@ -2102,10 +2102,10 @@
}
},
"exportingOrganizationVaultTitle": {
"message": "Exporting organization vault"
"message": "Təşkilat anbarını xaricə köçürmə"
},
"exportingOrganizationVaultDesc": {
"message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.",
"message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat anbarı ixrac ediləcək. Fərdi anbardakı və digər təşkilat elementlər daxil edilmir.",
"placeholders": {
"organization": {
"content": "$1",

View File

@ -1318,7 +1318,7 @@
"message": "Dieses Passwort wird zum Exportieren und Importieren dieser Datei verwendet"
},
"accountRestrictedOptionDescription": {
"message": "Verwende den Verschlüsselungscode deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken."
"message": "Verwende den Verschlüsselungsschlüssel deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken."
},
"passwordProtected": {
"message": "Passwortgeschützt"

View File

@ -695,6 +695,15 @@
"selfHostedEnvironmentFooter": {
"message": "Specify the base URL of your on-premises hosted Bitwarden installation."
},
"selfHostedBaseUrlHint": {
"message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com"
},
"selfHostedCustomEnvHeader" :{
"message": "For advanced configuration, you can specify the base URL of each service independently."
},
"selfHostedEnvFormInvalid" :{
"message": "You must add either the base Server URL or at least one custom environment."
},
"customEnvironment": {
"message": "Custom environment"
},
@ -743,6 +752,9 @@
"loggedOut": {
"message": "Logged out"
},
"loggedOutDesc": {
"message": "You have been logged out of your account."
},
"loginExpired": {
"message": "Your login session has expired."
},
@ -1212,6 +1224,12 @@
}
}
},
"errorRefreshingAccessToken":{
"message": "Access Token Refresh Error"
},
"errorRefreshingAccessTokenDesc":{
"message": "No refresh token or API keys found. Please try logging out and logging back in."
},
"help": {
"message": "Help"
},
@ -2474,6 +2492,12 @@
"important": {
"message": "Important:"
},
"accessTokenUnableToBeDecrypted": {
"message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue."
},
"refreshTokenSecureStorageRetrievalFailure": {
"message": "You have been logged out because your refresh token could not be retrieved. Please log in again to resolve this issue."
},
"masterPasswordHint": {
"message": "Your master password cannot be recovered if you forget it!"
},

View File

@ -1300,7 +1300,7 @@
"description": "ex. Date this password was updated"
},
"exportFrom": {
"message": "Export from"
"message": "Exportera från"
},
"exportVault": {
"message": "Exportera valv"

View File

@ -1300,7 +1300,7 @@
"description": "ex. Date this password was updated"
},
"exportFrom": {
"message": "Export from"
"message": "Експортувати з"
},
"exportVault": {
"message": "Експортувати сховище"
@ -1309,31 +1309,31 @@
"message": "Формат файлу"
},
"fileEncryptedExportWarningDesc": {
"message": "This file export will be password protected and require the file password to decrypt."
"message": "Цей експортований файл буде захищений паролем, який необхідно ввести для його розшифрування."
},
"filePassword": {
"message": "File password"
"message": "Пароль файлу"
},
"exportPasswordDescription": {
"message": "This password will be used to export and import this file"
"message": "Цей пароль буде використано для експортування та імпортування цього файлу"
},
"accountRestrictedOptionDescription": {
"message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account."
"message": "Використовуйте ключ шифрування свого облікового запису, створений на основі імені користувача й головного пароля, щоб зашифрувати експортовані дані та обмежити можливість імпортування лише до поточного облікового запису Bitwarden."
},
"passwordProtected": {
"message": "Password protected"
"message": "Захищено паролем"
},
"passwordProtectedOptionDescription": {
"message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption."
"message": "Встановіть пароль файлу, щоб зашифрувати експортовані дані та імпортувати до будь-якого облікового запису Bitwarden за допомогою цього пароля."
},
"exportTypeHeading": {
"message": "Export type"
"message": "Тип експорту"
},
"accountRestricted": {
"message": "Account restricted"
"message": "Обмежено обліковим записом"
},
"filePasswordAndConfirmFilePasswordDoNotMatch": {
"message": "“File password” and “Confirm file password“ do not match."
"message": "Пароль файлу та підтвердження пароля відрізняються."
},
"hCaptchaUrl": {
"message": "URL-адреса hCaptcha",
@ -2102,10 +2102,10 @@
}
},
"exportingOrganizationVaultTitle": {
"message": "Exporting organization vault"
"message": "Експортування сховища організації"
},
"exportingOrganizationVaultDesc": {
"message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.",
"message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Елементи особистих сховищ або інших організацій не будуть включені.",
"placeholders": {
"organization": {
"content": "$1",

View File

@ -1300,7 +1300,7 @@
"description": "ex. Date this password was updated"
},
"exportFrom": {
"message": "Export from"
"message": "导出自"
},
"exportVault": {
"message": "导出密码库"
@ -1309,31 +1309,31 @@
"message": "文件格式"
},
"fileEncryptedExportWarningDesc": {
"message": "This file export will be password protected and require the file password to decrypt."
"message": "此文件导出将受密码保护,需要文件密码才能解密。"
},
"filePassword": {
"message": "File password"
"message": "文件密码"
},
"exportPasswordDescription": {
"message": "This password will be used to export and import this file"
"message": "此密码将用于导出和导入此文件"
},
"accountRestrictedOptionDescription": {
"message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account."
"message": "使用衍生自您账户的用户名和主密码的账户加密密钥,以加密此导出并限制只能导入到当前的 Bitwarden 账户。"
},
"passwordProtected": {
"message": "Password protected"
"message": "密码保护"
},
"passwordProtectedOptionDescription": {
"message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption."
"message": "设置一个文件密码用来加密此导出,并使用此密码解密以导入到任意 Bitwarden 账户。"
},
"exportTypeHeading": {
"message": "Export type"
"message": "导出类型"
},
"accountRestricted": {
"message": "Account restricted"
"message": "账户受限"
},
"filePasswordAndConfirmFilePasswordDoNotMatch": {
"message": "“File password” and “Confirm file password“ do not match."
"message": "「文件密码」与「确认文件密码」不一致。"
},
"hCaptchaUrl": {
"message": "hCaptcha URL",
@ -2102,10 +2102,10 @@
}
},
"exportingOrganizationVaultTitle": {
"message": "Exporting organization vault"
"message": "正在导出组织密码库"
},
"exportingOrganizationVaultDesc": {
"message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.",
"message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库数据。不包括个人密码库和其他组织中的项目。",
"placeholders": {
"organization": {
"content": "$1",

View File

@ -3,6 +3,7 @@ import * as path from "path";
import { app } from "electron";
import { Subject, firstValueFrom } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { TokenService } from "@bitwarden/common/auth/services/token.service";
@ -31,6 +32,7 @@ import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider";
import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
import { UserId } from "@bitwarden/common/types/guid";
/* eslint-enable import/no-restricted-paths */
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
@ -182,6 +184,7 @@ export class Main {
this.keyGenerationService,
this.encryptService,
this.logService,
async (logoutReason: LogoutReason, userId?: UserId) => {},
);
this.migrationRunner = new MigrationRunner(
@ -207,11 +210,9 @@ export class Main {
);
this.desktopSettingsService = new DesktopSettingsService(stateProvider);
const biometricStateService = new DefaultBiometricStateService(stateProvider);
this.windowMain = new WindowMain(
this.stateService,
biometricStateService,
this.logService,
this.storageService,

View File

@ -6,7 +6,6 @@ import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "elect
import { firstValueFrom } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
@ -38,7 +37,6 @@ export class WindowMain {
readonly defaultHeight = 600;
constructor(
private stateService: StateService,
private biometricStateService: BiometricStateService,
private logService: LogService,
private storageService: AbstractStorageService,

View File

@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2024.5.1",
"version": "2024.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2024.5.1",
"version": "2024.6.0",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-native": "file:../desktop_native",

View File

@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
"version": "2024.5.1",
"version": "2024.6.0",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2024.5.0",
"version": "2024.6.0",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@ -52,7 +52,7 @@
*ngIf="canShowBillingTab(organization)"
>
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
<ng-container *ngIf="showPaymentAndHistory$ | async">
<ng-container *ngIf="(showPaymentAndHistory$ | async) && (organizationIsUnmanaged$ | async)">
<bit-nav-item [text]="'paymentMethod' | i18n" route="billing/payment-method"></bit-nav-item>
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</ng-container>

View File

@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { map, mergeMap, Observable, Subject, takeUntil } from "rxjs";
import { combineLatest, map, mergeMap, Observable, Subject, switchMap, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@ -16,7 +16,8 @@ import {
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@ -55,9 +56,14 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
organization$: Observable<Organization>;
showPaymentAndHistory$: Observable<boolean>;
hideNewOrgButton$: Observable<boolean>;
organizationIsUnmanaged$: Observable<boolean>;
private _destroy = new Subject<void>();
protected consolidatedBillingEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.EnableConsolidatedBilling,
);
protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$(
FeatureFlag.ShowPaymentMethodWarningBanners,
);
@ -68,6 +74,7 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
private platformUtilsService: PlatformUtilsService,
private configService: ConfigService,
private policyService: PolicyService,
private providerService: ProviderService,
) {}
async ngOnInit() {
@ -94,6 +101,24 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
);
this.hideNewOrgButton$ = this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg);
const provider$ = this.organization$.pipe(
switchMap((organization) => this.providerService.get$(organization.providerId)),
);
this.organizationIsUnmanaged$ = combineLatest([
this.consolidatedBillingEnabled$,
this.organization$,
provider$,
]).pipe(
map(
([consolidatedBillingEnabled, organization, provider]) =>
!consolidatedBillingEnabled ||
!organization.hasProvider ||
!provider ||
provider.providerStatus !== ProviderStatusType.Billable,
),
);
}
ngOnDestroy() {

View File

@ -14,6 +14,7 @@ import {
timer,
} from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@ -40,7 +41,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogService, ToastOptions, ToastService } from "@bitwarden/components";
import { PolicyListService } from "./admin-console/core/policy-list.service";
import {
@ -148,7 +149,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.router.navigate(["/"]);
break;
case "logout":
await this.logOut(!!message.expired, message.redirect);
await this.logOut(message.logoutReason, message.redirect);
break;
case "lockVault":
await this.vaultTimeoutService.lock();
@ -278,7 +279,34 @@ export class AppComponent implements OnDestroy, OnInit {
this.destroy$.complete();
}
private async logOut(expired: boolean, redirect = true) {
private async displayLogoutReason(logoutReason: LogoutReason) {
let toastOptions: ToastOptions;
switch (logoutReason) {
case "invalidSecurityStamp":
case "sessionExpired": {
toastOptions = {
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
};
break;
}
default: {
toastOptions = {
variant: "info",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loggedOutDesc"),
};
break;
}
}
this.toastService.showToast(toastOptions);
}
private async logOut(logoutReason: LogoutReason, redirect = true) {
await this.displayLogoutReason(logoutReason);
await this.eventUploadService.uploadEvents();
const userId = (await this.stateService.getUserId()) as UserId;
@ -308,14 +336,6 @@ export class AppComponent implements OnDestroy, OnInit {
await this.searchService.clearIndex();
this.authService.logOut(async () => {
if (expired) {
this.platformUtilsService.showToast(
"warning",
this.i18nService.t("loggedOut"),
this.i18nService.t("loginExpired"),
);
}
await this.stateService.clean({ userId: userId });
await this.accountService.clean(userId);

View File

@ -20,8 +20,8 @@ export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthen
}
this.response = {
attestationObject: Utils.fromBufferToB64(credential.response.attestationObject),
clientDataJson: Utils.fromBufferToB64(credential.response.clientDataJSON),
attestationObject: Utils.fromBufferToUrlB64(credential.response.attestationObject),
clientDataJson: Utils.fromBufferToUrlB64(credential.response.clientDataJSON),
};
}
}

View File

@ -1,8 +1,8 @@
<h1 class="tw-text-3xl !tw-text-alt2">Start your 7-day free trial of Bitwarden</h1>
<h1 class="tw-text-3xl !tw-text-alt2">Start your 7-day Enterprise free trial</h1>
<div class="tw-pt-20">
<h2 class="tw-text-2xl">
Strengthen business security with the password manager designed for seamless administration and
employee usability.
Bitwarden is the most trusted password manager designed for seamless administration and employee
usability.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main tw-list-none tw-pl-0">
@ -15,14 +15,14 @@
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Strengthen employee security practices through centralized administrative control and
>Strengthen company-wide security through centralized administrative control and
policies</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Streamline user onboarding and automate account provisioning with turnkey SSO and SCIM
>Streamline user onboarding and automate account provisioning with flexible SSO and SCIM
integrations</span
>
</li>
@ -35,14 +35,7 @@
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Save time and increase productivity with autofill and instant device syncing</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Empower employees to secure their digital life at home, at work, and on the go by offering a
free Families plan to all Enterprise users</span
>Give all Enterprise users the gift of 360º security with a free Families plan</span
>
</li>
</ul>

View File

@ -1,34 +1,44 @@
<h1 class="tw-text-3xl !tw-text-alt2">The Password Manager Trusted by Millions</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">Everything enterprises need out of a password manager:</h2>
<h1 class="tw-text-3xl !tw-text-alt2">Start your 7-day Enterprise free trial</h1>
<div class="tw-pt-20">
<h2 class="tw-text-2xl">
Bitwarden is the most trusted password manager designed for seamless administration and employee
usability.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main tw-list-none tw-pl-0">
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Secure password sharing</li>
<li>
<i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Easy, flexible SSO and SCIM integrations
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Instantly and securely share credentials with the groups and individuals who need them</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Strengthen company-wide security through centralized administrative control and
policies</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Streamline user onboarding and automate account provisioning with flexible SSO and SCIM
integrations</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Migrate to Bitwarden in minutes with comprehensive import options</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Give all Enterprise users the gift of 360º security with a free Families plan</span
>
</li>
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Free families plan for users</li>
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Quick import and migration tools</li>
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Simple, streamlined user experience</li>
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Priority support and trainers</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-cnet-5-stars></app-logo-cnet-5-stars>
<div class="tw-flex tw-items-end tw-gap-8">
<review-logo
logoClass="tw-w-8"
logoSrc="../../images/register-layout/g2-logo.svg"
logoAlt="G2 Logo"
></review-logo>
<review-logo
logoClass="tw-w-28"
logoSrc="../../images/register-layout/capterra-logo.svg"
logoAlt="Capterra Logo"
></review-logo>
<review-logo
logoClass="tw-w-28"
logoSrc="../../images/register-layout/get-app-logo.svg"
logoAlt="Get App Logo"
></review-logo>
</div>
<app-logo-badges></app-logo-badges>
</div>

View File

@ -1,34 +1,44 @@
<h1 class="tw-text-3xl !tw-text-alt2">The Password Manager Trusted by Millions</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">Everything enterprises need out of a password manager:</h2>
<h1 class="tw-text-3xl !tw-text-alt2">Start your 7-day Enterprise free trial</h1>
<div class="tw-pt-20">
<h2 class="tw-text-2xl">
Bitwarden is the most trusted password manager designed for seamless administration and employee
usability.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main tw-list-none tw-pl-0">
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Secure password sharing</li>
<li>
<i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Easy, flexible SSO and SCIM integrations
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Instantly and securely share credentials with the groups and individuals who need them</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Strengthen company-wide security through centralized administrative control and
policies</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Streamline user onboarding and automate account provisioning with flexible SSO and SCIM
integrations</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Migrate to Bitwarden in minutes with comprehensive import options</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Give all Enterprise users the gift of 360º security with a free Families plan</span
>
</li>
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Free families plan for users</li>
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Quick import and migration tools</li>
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Simple, streamlined user experience</li>
<li><i class="bwi bwi-lg bwi-check-circle tw-mr-4"></i>Priority support and trainers</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-cnet-5-stars></app-logo-cnet-5-stars>
<div class="tw-flex tw-items-end tw-gap-8">
<review-logo
logoClass="tw-w-8"
logoSrc="../../images/register-layout/g2-logo.svg"
logoAlt="G2 Logo"
></review-logo>
<review-logo
logoClass="tw-w-28"
logoSrc="../../images/register-layout/capterra-logo.svg"
logoAlt="Capterra Logo"
></review-logo>
<review-logo
logoClass="tw-w-28"
logoSrc="../../images/register-layout/get-app-logo.svg"
logoAlt="Get App Logo"
></review-logo>
</div>
<app-logo-badges></app-logo-badges>
</div>

View File

@ -1,6 +1,5 @@
<h1 class="tw-text-4xl !tw-text-alt2">Start your 7-day free trial for Teams</h1>
<div class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-16"></div>
<div class="tw-pt-10">
<h1 class="tw-text-3xl !tw-text-alt2">Start your 7-day free trial for Teams</h1>
<div class="tw-pt-20">
<h2 class="tw-text-2xl">
Strengthen business security with an easy-to-use password manager your team will love.
</h2>

View File

@ -1,17 +1,35 @@
<h1 class="tw-text-4xl !tw-text-alt2">Start Your Free Trial Now</h1>
<div class="tw-pt-32">
<h1 class="tw-text-3xl !tw-text-alt2">Start your 7-day free trial for Teams</h1>
<div class="tw-pt-20">
<h2 class="tw-text-2xl">
Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password
storage and sharing.
Strengthen business security with an easy-to-use password manager your team will love.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Collaborate and share securely</li>
<li>Deploy and manage quickly and easily</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main tw-list-none tw-pl-0">
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Instantly and securely share credentials with the groups and individuals who need them</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Migrate to Bitwarden in minutes with comprehensive import options</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Save time and increase productivity with autofill and instant device syncing</span
>
</li>
<li class="tw-flex tw-items-center">
<i class="bwi bwi-lg bwi-check-circle tw-mr-4 tw-flex-none"></i
><span class="tw-flex-auto"
>Enhance security practices across your team with easy user management</span
>
</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
<app-logo-badges></app-logo-badges>
</div>

View File

@ -0,0 +1,36 @@
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn } from "@angular/router";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderStatusType } from "@bitwarden/common/admin-console/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
export const organizationIsUnmanaged: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
const configService = inject(ConfigService);
const organizationService = inject(OrganizationService);
const providerService = inject(ProviderService);
const consolidatedBillingEnabled = await configService.getFeatureFlag(
FeatureFlag.EnableConsolidatedBilling,
);
if (!consolidatedBillingEnabled) {
return true;
}
const organization = await organizationService.get(route.params.organizationId);
if (!organization.hasProvider) {
return true;
}
const provider = await providerService.get(organization.providerId);
if (!provider) {
return true;
}
return provider.providerStatus !== ProviderStatusType.Billable;
};

View File

@ -5,6 +5,7 @@ import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstraction
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard";
import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard";
import { WebPlatformUtilsService } from "../../core/web-platform-utils.service";
import { PaymentMethodComponent } from "../shared";
@ -29,7 +30,7 @@ const routes: Routes = [
{
path: "payment-method",
component: PaymentMethodComponent,
canActivate: [OrganizationPermissionsGuard],
canActivate: [OrganizationPermissionsGuard, organizationIsUnmanaged],
data: {
titleId: "paymentMethod",
organizationPermissions: (org: Organization) => org.canEditPaymentMethods,
@ -38,7 +39,7 @@ const routes: Routes = [
{
path: "history",
component: OrgBillingHistoryViewComponent,
canActivate: [OrganizationPermissionsGuard],
canActivate: [OrganizationPermissionsGuard, organizationIsUnmanaged],
data: {
titleId: "billingHistory",
organizationPermissions: (org: Organization) => org.canViewBillingHistory,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -587,6 +587,9 @@
"loggedOut": {
"message": "Logged out"
},
"loggedOutDesc": {
"message": "You have been logged out of your account."
},
"loginExpired": {
"message": "Your login session has expired."
},
@ -1050,6 +1053,12 @@
"copyUuid": {
"message": "Copy UUID"
},
"errorRefreshingAccessToken":{
"message": "Access Token Refresh Error"
},
"errorRefreshingAccessTokenDesc":{
"message": "No refresh token or API keys found. Please try logging out and logging back in."
},
"warning": {
"message": "Warning"
},
@ -5586,6 +5595,39 @@
"rotateBillingSyncTokenTitle": {
"message": "Rotating the billing sync token will invalidate the previous token."
},
"selfHostedServer": {
"message": "self-hosted"
},
"customEnvironment": {
"message": "Custom environment"
},
"selfHostedBaseUrlHint": {
"message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com"
},
"selfHostedCustomEnvHeader" :{
"message": "For advanced configuration, you can specify the base URL of each service independently."
},
"selfHostedEnvFormInvalid" :{
"message": "You must add either the base Server URL or at least one custom environment."
},
"apiUrl": {
"message": "API server URL"
},
"webVaultUrl": {
"message": "Web vault server URL"
},
"identityUrl": {
"message": "Identity server URL"
},
"notificationsUrl": {
"message": "Notifications server URL"
},
"iconsUrl": {
"message": "Icons server URL"
},
"environmentSaved": {
"message": "Environment URLs saved"
},
"selfHostingTitle": {
"message": "Self-hosting"
},
@ -8297,5 +8339,20 @@
},
"allLoginRequestsApproved": {
"message": "All login requests approved"
},
"payPal": {
"message": "PayPal"
},
"bitcoin": {
"message": "Bitcoin"
},
"updatedTaxInformation": {
"message": "Updated tax information"
},
"unverified": {
"message": "Unverified"
},
"verified": {
"message": "Verified"
}
}

View File

@ -2105,7 +2105,7 @@
"message": "Bitwarden 家庭版计划。"
},
"addons": {
"message": "附加项目"
"message": "插件"
},
"premiumAccess": {
"message": "高级会员"

View File

@ -1,4 +1,11 @@
{
"presets": ["@babel/preset-env"],
"presets": [
[
"@babel/preset-env",
{
"bugfixes": true
}
]
],
"plugins": ["@angular/compiler-cli/linker/babel"]
}

View File

@ -1,9 +1,52 @@
import { firstValueFrom } from "rxjs";
import { OrganizationAuthRequestService } from "@bitwarden/bit-common/admin-console/auth-requests";
import { Response } from "@bitwarden/cli/models/response";
import { MessageResponse } from "@bitwarden/cli/models/response/message.response";
import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
export class ApproveAllCommand {
constructor() {}
constructor(
private organizationAuthRequestService: OrganizationAuthRequestService,
private organizationService: OrganizationService,
) {}
async run(organizationId: string): Promise<Response> {
throw new Error("Not implemented");
if (organizationId != null) {
organizationId = organizationId.toLowerCase();
}
if (!Utils.isGuid(organizationId)) {
return Response.badRequest("`" + organizationId + "` is not a GUID.");
}
const organization = await firstValueFrom(this.organizationService.get$(organizationId));
if (!organization?.canManageUsersPassword) {
return Response.error(
"You do not have permission to approve pending device authorization requests.",
);
}
try {
const pendingApprovals =
await this.organizationAuthRequestService.listPendingRequests(organizationId);
if (pendingApprovals.length == 0) {
const res = new MessageResponse(
"No pending device authorization requests to approve.",
null,
);
return Response.success(res);
}
await this.organizationAuthRequestService.approvePendingRequests(
organizationId,
pendingApprovals,
);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -1,9 +1,54 @@
import { firstValueFrom } from "rxjs";
import { Response } from "@bitwarden/cli/models/response";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests";
export class ApproveCommand {
constructor() {}
constructor(
private organizationService: OrganizationService,
private organizationAuthRequestService: OrganizationAuthRequestService,
) {}
async run(id: string): Promise<Response> {
throw new Error("Not implemented");
async run(organizationId: string, id: string): Promise<Response> {
if (organizationId != null) {
organizationId = organizationId.toLowerCase();
}
if (!Utils.isGuid(organizationId)) {
return Response.badRequest("`" + organizationId + "` is not a GUID.");
}
if (id != null) {
id = id.toLowerCase();
}
if (!Utils.isGuid(id)) {
return Response.badRequest("`" + id + "` is not a GUID.");
}
const organization = await firstValueFrom(this.organizationService.get$(organizationId));
if (!organization?.canManageUsersPassword) {
return Response.error(
"You do not have permission to approve pending device authorization requests.",
);
}
try {
const pendingRequests =
await this.organizationAuthRequestService.listPendingRequests(organizationId);
const request = pendingRequests.find((r) => r.id == id);
if (request == null) {
return Response.error("Invalid request id");
}
await this.organizationAuthRequestService.approvePendingRequest(organizationId, request);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -1,9 +1,49 @@
import { firstValueFrom } from "rxjs";
import { Response } from "@bitwarden/cli/models/response";
import { MessageResponse } from "@bitwarden/cli/models/response/message.response";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests";
export class DenyAllCommand {
constructor() {}
constructor(
private organizationService: OrganizationService,
private organizationAuthRequestService: OrganizationAuthRequestService,
) {}
async run(organizationId: string): Promise<Response> {
throw new Error("Not implemented");
if (organizationId != null) {
organizationId = organizationId.toLowerCase();
}
if (!Utils.isGuid(organizationId)) {
return Response.badRequest("`" + organizationId + "` is not a GUID.");
}
const organization = await firstValueFrom(this.organizationService.get$(organizationId));
if (!organization?.canManageUsersPassword) {
return Response.error(
"You do not have permission to approve pending device authorization requests.",
);
}
try {
const pendingRequests =
await this.organizationAuthRequestService.listPendingRequests(organizationId);
if (pendingRequests.length == 0) {
const res = new MessageResponse("No pending device authorization requests to deny.", null);
return Response.success(res);
}
await this.organizationAuthRequestService.denyPendingRequests(
organizationId,
...pendingRequests.map((r) => r.id),
);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -1,9 +1,46 @@
import { firstValueFrom } from "rxjs";
import { Response } from "@bitwarden/cli/models/response";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests";
export class DenyCommand {
constructor() {}
constructor(
private organizationService: OrganizationService,
private organizationAuthRequestService: OrganizationAuthRequestService,
) {}
async run(id: string): Promise<Response> {
throw new Error("Not implemented");
async run(organizationId: string, id: string): Promise<Response> {
if (organizationId != null) {
organizationId = organizationId.toLowerCase();
}
if (!Utils.isGuid(organizationId)) {
return Response.badRequest("`" + organizationId + "` is not a GUID.");
}
if (id != null) {
id = id.toLowerCase();
}
if (!Utils.isGuid(id)) {
return Response.badRequest("`" + id + "` is not a GUID.");
}
const organization = await firstValueFrom(this.organizationService.get$(organizationId));
if (!organization?.canManageUsersPassword) {
return Response.error(
"You do not have permission to approve pending device authorization requests.",
);
}
try {
await this.organizationAuthRequestService.denyPendingRequests(organizationId, id);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -3,6 +3,8 @@ import { program, Command } from "commander";
import { BaseProgram } from "@bitwarden/cli/base-program";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ServiceContainer } from "../../service-container";
import { ApproveAllCommand } from "./approve-all.command";
import { ApproveCommand } from "./approve.command";
import { DenyAllCommand } from "./deny-all.command";
@ -10,6 +12,10 @@ import { DenyCommand } from "./deny.command";
import { ListCommand } from "./list.command";
export class DeviceApprovalProgram extends BaseProgram {
constructor(protected serviceContainer: ServiceContainer) {
super(serviceContainer);
}
register() {
program.addCommand(this.deviceApprovalCommand());
}
@ -32,7 +38,10 @@ export class DeviceApprovalProgram extends BaseProgram {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new ListCommand();
const cmd = new ListCommand(
this.serviceContainer.organizationAuthRequestService,
this.serviceContainer.organizationService,
);
const response = await cmd.run(organizationId);
this.processResponse(response);
});
@ -40,27 +49,34 @@ export class DeviceApprovalProgram extends BaseProgram {
private approveCommand(): Command {
return new Command("approve")
.argument("<id>")
.argument("<organizationId>", "The id of the organization")
.argument("<requestId>", "The id of the request to approve")
.description("Approve a pending request")
.action(async (id: string) => {
.action(async (organizationId: string, id: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new ApproveCommand();
const response = await cmd.run(id);
const cmd = new ApproveCommand(
this.serviceContainer.organizationService,
this.serviceContainer.organizationAuthRequestService,
);
const response = await cmd.run(organizationId, id);
this.processResponse(response);
});
}
private approveAllCommand(): Command {
return new Command("approveAll")
return new Command("approve-all")
.description("Approve all pending requests for an organization")
.argument("<organizationId>")
.action(async (organizationId: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new ApproveAllCommand();
const cmd = new ApproveAllCommand(
this.serviceContainer.organizationAuthRequestService,
this.serviceContainer.organizationService,
);
const response = await cmd.run(organizationId);
this.processResponse(response);
});
@ -68,27 +84,34 @@ export class DeviceApprovalProgram extends BaseProgram {
private denyCommand(): Command {
return new Command("deny")
.argument("<id>")
.argument("<organizationId>", "The id of the organization")
.argument("<requestId>", "The id of the request to deny")
.description("Deny a pending request")
.action(async (id: string) => {
.action(async (organizationId: string, id: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new DenyCommand();
const response = await cmd.run(id);
const cmd = new DenyCommand(
this.serviceContainer.organizationService,
this.serviceContainer.organizationAuthRequestService,
);
const response = await cmd.run(organizationId, id);
this.processResponse(response);
});
}
private denyAllCommand(): Command {
return new Command("denyAll")
return new Command("deny-all")
.description("Deny all pending requests for an organization")
.argument("<organizationId>")
.action(async (organizationId: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new DenyAllCommand();
const cmd = new DenyAllCommand(
this.serviceContainer.organizationService,
this.serviceContainer.organizationAuthRequestService,
);
const response = await cmd.run(organizationId);
this.processResponse(response);
});

View File

@ -1,9 +1,42 @@
import { firstValueFrom } from "rxjs";
import { OrganizationAuthRequestService } from "@bitwarden/bit-common/admin-console/auth-requests";
import { Response } from "@bitwarden/cli/models/response";
import { ListResponse } from "@bitwarden/cli/models/response/list.response";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PendingAuthRequestResponse } from "./pending-auth-request.response";
export class ListCommand {
constructor() {}
constructor(
private organizationAuthRequestService: OrganizationAuthRequestService,
private organizationService: OrganizationService,
) {}
async run(organizationId: string): Promise<Response> {
throw new Error("Not implemented");
if (organizationId != null) {
organizationId = organizationId.toLowerCase();
}
if (!Utils.isGuid(organizationId)) {
return Response.badRequest("`" + organizationId + "` is not a GUID.");
}
const organization = await firstValueFrom(this.organizationService.get$(organizationId));
if (!organization?.canManageUsersPassword) {
return Response.error(
"You do not have permission to approve pending device authorization requests.",
);
}
try {
const requests =
await this.organizationAuthRequestService.listPendingRequests(organizationId);
const res = new ListResponse(requests.map((r) => new PendingAuthRequestResponse(r)));
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -0,0 +1,26 @@
import { PendingAuthRequestView } from "@bitwarden/bit-common/admin-console/auth-requests/";
import { BaseResponse } from "@bitwarden/cli/models/response/base.response";
export class PendingAuthRequestResponse implements BaseResponse {
object = "auth-request";
id: string;
userId: string;
organizationUserId: string;
email: string;
requestDeviceIdentifier: string;
requestDeviceType: string;
requestIpAddress: string;
creationDate: Date;
constructor(authRequest: PendingAuthRequestView) {
this.id = authRequest.id;
this.userId = authRequest.userId;
this.organizationUserId = authRequest.organizationUserId;
this.email = authRequest.email;
this.requestDeviceIdentifier = authRequest.requestDeviceIdentifier;
this.requestDeviceType = authRequest.requestDeviceType;
this.requestIpAddress = authRequest.requestIpAddress;
this.creationDate = authRequest.creationDate;
}
}

View File

@ -1,7 +1,24 @@
import {
OrganizationAuthRequestService,
OrganizationAuthRequestApiService,
} from "@bitwarden/bit-common/admin-console/auth-requests";
import { ServiceContainer as OssServiceContainer } from "@bitwarden/cli/service-container";
/**
* Instantiates services and makes them available for dependency injection.
* Any Bitwarden-licensed services should be registered here.
*/
export class ServiceContainer extends OssServiceContainer {}
export class ServiceContainer extends OssServiceContainer {
organizationAuthRequestApiService: OrganizationAuthRequestApiService;
organizationAuthRequestService: OrganizationAuthRequestService;
constructor() {
super();
this.organizationAuthRequestApiService = new OrganizationAuthRequestApiService(this.apiService);
this.organizationAuthRequestService = new OrganizationAuthRequestService(
this.organizationAuthRequestApiService,
this.cryptoService,
this.organizationUserService,
);
}
}

View File

@ -21,7 +21,8 @@
"@bitwarden/vault-export-core": [
"../../libs/tools/export/vault-export/vault-export-core/src"
],
"@bitwarden/node/*": ["../../libs/node/src/*"]
"@bitwarden/node/*": ["../../libs/node/src/*"],
"@bitwarden/bit-common/*": ["../../bitwarden_license/bit-common/src/*"]
}
},
"include": ["src", "src/**/*.spec.ts"]

View File

@ -1,2 +1,4 @@
export * from "./pending-organization-auth-request.response";
export * from "./organization-auth-request.service";
export * from "./organization-auth-request-api.service";
export * from "./pending-auth-request.view";

View File

@ -33,6 +33,7 @@
*ngIf="canAccessBilling$ | async"
>
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
<bit-nav-item [text]="'paymentMethod' | i18n" route="billing/payment-method"></bit-nav-item>
</bit-nav-group>
<bit-nav-item
icon="bwi-cogs"

View File

@ -7,8 +7,12 @@ import { ProvidersComponent } from "@bitwarden/web-vault/app/admin-console/provi
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
import { ProviderSubscriptionComponent, hasConsolidatedBilling } from "../../billing/providers";
import { ManageClientOrganizationsComponent } from "../../billing/providers/clients";
import {
ManageClientOrganizationsComponent,
ProviderSubscriptionComponent,
hasConsolidatedBilling,
ProviderPaymentMethodComponent,
} from "../../billing/providers";
import { ClientsComponent } from "./clients/clients.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
@ -118,6 +122,13 @@ const routes: Routes = [
titleId: "subscription",
},
},
{
path: "payment-method",
component: ProviderPaymentMethodComponent,
data: {
titleId: "paymentMethod",
},
},
],
},
{

View File

@ -9,13 +9,15 @@ import { OrganizationPlansComponent, TaxInfoComponent } from "@bitwarden/web-vau
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
import { ProviderSubscriptionComponent } from "../../billing/providers";
import {
CreateClientOrganizationComponent,
ManageClientOrganizationsComponent,
ManageClientOrganizationNameComponent,
ManageClientOrganizationsComponent,
ManageClientOrganizationSubscriptionComponent,
} from "../../billing/providers/clients";
ProviderPaymentMethodComponent,
ProviderSelectPaymentMethodDialogComponent,
ProviderSubscriptionComponent,
} from "../../billing/providers";
import { AddOrganizationComponent } from "./clients/add-organization.component";
import { ClientsComponent } from "./clients/clients.component";
@ -66,6 +68,8 @@ import { SetupComponent } from "./setup/setup.component";
ManageClientOrganizationNameComponent,
ManageClientOrganizationSubscriptionComponent,
ProviderSubscriptionComponent,
ProviderSelectPaymentMethodDialogComponent,
ProviderPaymentMethodComponent,
],
providers: [WebProviderService, ProviderPermissionsGuard],
})

View File

@ -1,2 +1,8 @@
export * from "./clients/create-client-organization.component";
export * from "./clients/manage-client-organization-name.component";
export * from "./clients/manage-client-organization-subscription.component";
export * from "./clients/manage-client-organizations.component";
export * from "./guards/has-consolidated-billing.guard";
export * from "./provider-subscription.component";
export * from "./payment-method/provider-select-payment-method-dialog.component";
export * from "./payment-method/provider-payment-method.component";
export * from "./subscription/provider-subscription.component";

View File

@ -0,0 +1,52 @@
<app-header></app-header>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-container *ngIf="!loading">
<!-- Account Credit -->
<ng-container>
<h2 bitTypography="h2">
{{ "accountCredit" | i18n }}
</h2>
<p class="tw-text-lg tw-font-bold">{{ accountCredit | currency: "$" }}</p>
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit">
{{ "addCredit" | i18n }}
</button>
</ng-container>
<!-- Payment Method -->
<ng-container>
<h2 class="spaced-header">{{ "paymentMethod" | i18n }}</h2>
<p *ngIf="!hasPaymentMethod">{{ "noPaymentMethod" | i18n }}</p>
<app-verify-bank-account
[onSubmit]="verifyBankAccount"
(verificationSubmitted)="onDataUpdated()"
*ngIf="hasUnverifiedPaymentMethod"
/>
<ng-container *ngIf="hasPaymentMethod">
<p>
<i class="bwi bwi-fw" [ngClass]="paymentMethodClass"></i>
{{ paymentMethodDescription }}
</p>
</ng-container>
<button type="button" bitButton buttonType="secondary" [bitAction]="changePaymentMethod">
{{ (hasPaymentMethod ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</button>
</ng-container>
<!-- Tax Information -->
<ng-container>
<h2 class="spaced-header">{{ "taxInformation" | i18n }}</h2>
<p>{{ "taxInformationDesc" | i18n }}</p>
<app-manage-tax-information
*ngIf="taxInformation"
[taxInformation]="taxInformation"
[onSubmit]="updateTaxInformation"
(taxInformationUpdated)="onDataUpdated()"
/>
</ng-container>
</bit-container>

View File

@ -0,0 +1,140 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { from, lastValueFrom, Subject, switchMap } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { openAddAccountCreditDialog } from "@bitwarden/angular/billing/components";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { MaskedPaymentMethod, TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import {
openProviderSelectPaymentMethodDialog,
ProviderSelectPaymentMethodDialogResultType,
} from "./provider-select-payment-method-dialog.component";
@Component({
selector: "app-provider-payment-method",
templateUrl: "./provider-payment-method.component.html",
})
export class ProviderPaymentMethodComponent implements OnInit, OnDestroy {
protected providerId: string;
protected loading: boolean;
protected accountCredit: number;
protected maskedPaymentMethod: MaskedPaymentMethod;
protected taxInformation: TaxInformation;
private destroy$ = new Subject<void>();
constructor(
private activatedRoute: ActivatedRoute,
private billingApiService: BillingApiServiceAbstraction,
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
) {}
addAccountCredit = () =>
openAddAccountCreditDialog(this.dialogService, {
data: {
providerId: this.providerId,
},
});
changePaymentMethod = async () => {
const dialogRef = openProviderSelectPaymentMethodDialog(this.dialogService, {
data: {
providerId: this.providerId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result == ProviderSelectPaymentMethodDialogResultType.Submitted) {
await this.load();
}
};
async load() {
this.loading = true;
const paymentInformation = await this.billingApiService.getProviderPaymentInformation(
this.providerId,
);
this.accountCredit = paymentInformation.accountCredit;
this.maskedPaymentMethod = MaskedPaymentMethod.from(paymentInformation.paymentMethod);
this.taxInformation = TaxInformation.from(paymentInformation.taxInformation);
this.loading = false;
}
onDataUpdated = async () => await this.load();
updateTaxInformation = async (taxInformation: TaxInformation) => {
const request = ExpandedTaxInfoUpdateRequest.From(taxInformation);
await this.billingApiService.updateProviderTaxInformation(this.providerId, request);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedTaxInformation"),
});
};
verifyBankAccount = async (amount1: number, amount2: number) => {
const request = new VerifyBankAccountRequest(amount1, amount2);
await this.billingApiService.verifyProviderBankAccount(this.providerId, request);
};
ngOnInit() {
this.activatedRoute.params
.pipe(
switchMap(({ providerId }) => {
this.providerId = providerId;
return from(this.load());
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected get hasPaymentMethod(): boolean {
return !!this.maskedPaymentMethod;
}
protected get hasUnverifiedPaymentMethod(): boolean {
return !!this.maskedPaymentMethod && this.maskedPaymentMethod.needsVerification;
}
protected get paymentMethodClass(): string[] {
switch (this.maskedPaymentMethod.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.PayPal:
return ["bwi-paypal tw-text-primary"];
default:
return [];
}
}
protected get paymentMethodDescription(): string {
let description = this.maskedPaymentMethod.description;
if (this.maskedPaymentMethod.type === PaymentMethodType.BankAccount) {
if (this.hasUnverifiedPaymentMethod) {
description += " - " + this.i18nService.t("unverified");
} else {
description += " - " + this.i18nService.t("verified");
}
}
return description;
}
}

View File

@ -0,0 +1,18 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large">
<span bitDialogTitle class="tw-font-semibold">
{{ "addPaymentMethod" | i18n }}
</span>
<ng-container bitDialogContent>
<app-select-payment-method [showAccountCredit]="false" />
</ng-container>
<ng-container bitDialogFooter>
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,60 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, EventEmitter, Inject, Output, ViewChild } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { SelectPaymentMethodComponent } from "@bitwarden/angular/billing/components";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { TokenizedPaymentMethodRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-method.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
type ProviderSelectPaymentMethodDialogParams = {
providerId: string;
};
export enum ProviderSelectPaymentMethodDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
export const openProviderSelectPaymentMethodDialog = (
dialogService: DialogService,
dialogConfig: DialogConfig<ProviderSelectPaymentMethodDialogParams>,
) =>
dialogService.open<
ProviderSelectPaymentMethodDialogResultType,
ProviderSelectPaymentMethodDialogParams
>(ProviderSelectPaymentMethodDialogComponent, dialogConfig);
@Component({
templateUrl: "provider-select-payment-method-dialog.component.html",
})
export class ProviderSelectPaymentMethodDialogComponent {
@ViewChild(SelectPaymentMethodComponent)
selectPaymentMethodComponent: SelectPaymentMethodComponent;
@Output() providerPaymentMethodUpdated = new EventEmitter();
protected readonly formGroup = new FormGroup({});
protected readonly ResultType = ProviderSelectPaymentMethodDialogResultType;
constructor(
private billingApiService: BillingApiServiceAbstraction,
@Inject(DIALOG_DATA) private dialogParams: ProviderSelectPaymentMethodDialogParams,
private dialogRef: DialogRef<ProviderSelectPaymentMethodDialogResultType>,
private i18nService: I18nService,
private toastService: ToastService,
) {}
submit = async () => {
const tokenizedPaymentMethod = await this.selectPaymentMethodComponent.tokenizePaymentMethod();
const request = TokenizedPaymentMethodRequest.From(tokenizedPaymentMethod);
await this.billingApiService.updateProviderPaymentMethod(this.dialogParams.providerId, request);
this.providerPaymentMethodUpdated.emit();
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedPaymentMethod"),
});
this.dialogRef.close(this.ResultType.Submitted);
};
}

View File

@ -78,9 +78,11 @@
</bit-table>
<ng-template #empty>
<div class="tw-mt-4 tw-text-center">
{{ emptyMessage }}
</div>
<tr bitRow>
<td bitCell colspan="3" class="tw-text-center">
{{ emptyMessage }}
</td>
</tr>
</ng-template>
<ng-template #buttonMode>

View File

@ -0,0 +1,55 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [title]="'addCredit' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p>
<div class="tw-grid tw-grid-cols-2">
<bit-radio-group formControlName="paymentMethod">
<bit-radio-button [value]="paymentMethodType.PayPal">
<bit-label> <i class="bwi bwi-paypal"></i>{{ "payPal" | i18n }}</bit-label>
</bit-radio-button>
<bit-radio-button [value]="paymentMethodType.BitPay">
<bit-label> <i class="bwi bwi-bitcoin"></i>{{ "bitcoin" | i18n }}</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<div class="tw-grid tw-grid-cols-2">
<bit-form-field>
<bit-label>{{ "amount" | i18n }}</bit-label>
<input bitInput type="number" formControlName="creditAmount" step="0.01" required />
<span bitPrefix>$USD</span>
</bit-form-field>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="ResultType.Closed"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<form #payPalForm action="{{ payPalConfig.buttonAction }}" method="post" target="_top">
<input type="hidden" name="cmd" value="_xclick" />
<input type="hidden" name="business" value="{{ payPalConfig.businessId }}" />
<input type="hidden" name="button_subtype" value="services" />
<input type="hidden" name="no_note" value="1" />
<input type="hidden" name="no_shipping" value="1" />
<input type="hidden" name="rm" value="1" />
<input type="hidden" name="return" value="{{ payPalConfig.returnUrl }}" />
<input type="hidden" name="cancel_return" value="{{ payPalConfig.returnUrl }}" />
<input type="hidden" name="currency_code" value="USD" />
<input type="hidden" name="image_url" value="https://bitwarden.com/images/paypal-banner.png" />
<input type="hidden" name="bn" value="PP-BuyNowBF:btn_buynow_LG.gif:NonHosted" />
<input type="hidden" name="amount" value="{{ formGroup.get('creditAmount').value }}" />
<input type="hidden" name="custom" value="{{ payPalConfig.customField }}" />
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
<input type="hidden" name="item_number" value="{{ payPalConfig.subject }}" />
</form>

View File

@ -0,0 +1,153 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
export type AddAccountCreditDialogParams = {
organizationId?: string;
providerId?: string;
};
export enum AddAccountCreditDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
export const openAddAccountCreditDialog = (
dialogService: DialogService,
dialogConfig: DialogConfig<AddAccountCreditDialogParams>,
) =>
dialogService.open<AddAccountCreditDialogResultType, AddAccountCreditDialogParams>(
AddAccountCreditDialogComponent,
dialogConfig,
);
type PayPalConfig = {
businessId?: string;
buttonAction?: string;
returnUrl?: string;
customField?: string;
subject?: string;
};
@Component({
templateUrl: "./add-account-credit-dialog.component.html",
})
export class AddAccountCreditDialogComponent implements OnInit {
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm: ElementRef;
protected formGroup = new FormGroup({
paymentMethod: new FormControl<PaymentMethodType>(PaymentMethodType.PayPal),
creditAmount: new FormControl<number>(null, [Validators.required, Validators.min(0.01)]),
});
protected payPalConfig: PayPalConfig;
protected ResultType = AddAccountCreditDialogResultType;
private organization?: Organization;
private provider?: Provider;
private user?: { id: UserId } & AccountInfo;
constructor(
private accountService: AccountService,
private apiService: ApiService,
private configService: ConfigService,
@Inject(DIALOG_DATA) private dialogParams: AddAccountCreditDialogParams,
private dialogRef: DialogRef<AddAccountCreditDialogResultType>,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private providerService: ProviderService,
) {
this.payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig;
}
protected readonly paymentMethodType = PaymentMethodType;
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
if (this.formGroup.value.paymentMethod === PaymentMethodType.PayPal) {
this.payPalForm.nativeElement.submit();
return;
}
if (this.formGroup.value.paymentMethod === PaymentMethodType.BitPay) {
const request = this.getBitPayInvoiceRequest();
const bitPayUrl = await this.apiService.postBitPayInvoice(request);
this.platformUtilsService.launchUri(bitPayUrl);
return;
}
this.dialogRef.close(AddAccountCreditDialogResultType.Submitted);
};
async ngOnInit(): Promise<void> {
let payPalCustomField: string;
if (this.dialogParams.organizationId) {
this.formGroup.patchValue({
creditAmount: 20.0,
});
this.organization = await this.organizationService.get(this.dialogParams.organizationId);
payPalCustomField = "organization_id:" + this.organization.id;
this.payPalConfig.subject = this.organization.name;
} else if (this.dialogParams.providerId) {
this.formGroup.patchValue({
creditAmount: 20.0,
});
this.provider = await this.providerService.get(this.dialogParams.providerId);
payPalCustomField = "provider_id:" + this.provider.id;
this.payPalConfig.subject = this.provider.name;
} else {
this.formGroup.patchValue({
creditAmount: 10.0,
});
this.user = await firstValueFrom(this.accountService.activeAccount$);
payPalCustomField = "user_id:" + this.user.id;
this.payPalConfig.subject = this.user.email;
}
const region = await firstValueFrom(this.configService.cloudRegion$);
payPalCustomField += ",account_credit:1";
payPalCustomField += `,region:${region}`;
this.payPalConfig.customField = payPalCustomField;
this.payPalConfig.returnUrl = window.location.href;
}
getBitPayInvoiceRequest(): BitPayInvoiceRequest {
const request = new BitPayInvoiceRequest();
if (this.organization) {
request.name = this.organization.name;
request.organizationId = this.organization.id;
} else if (this.provider) {
request.name = this.provider.name;
request.providerId = this.provider.id;
} else {
request.email = this.user.email;
request.userId = this.user.id;
}
request.credit = true;
request.amount = this.formGroup.value.creditAmount;
request.returnUrl = window.location.href;
return request;
}
}

View File

@ -0,0 +1,4 @@
export * from "./add-account-credit-dialog/add-account-credit-dialog.component";
export * from "./manage-tax-information/manage-tax-information.component";
export * from "./select-payment-method/select-payment-method.component";
export * from "./verify-bank-account/verify-bank-account.component";

View File

@ -0,0 +1,72 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "country" | i18n }}</bit-label>
<bit-select formControlName="country">
<bit-option
*ngFor="let country of countries"
[value]="country.value"
[disabled]="country.disabled"
[label]="country.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
<input bitInput type="text" formControlName="postalCode" autocomplete="postal-code" />
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="selectionSupportsAdditionalOptions">
<bit-form-control>
<input bitCheckbox type="checkbox" formControlName="includeTaxId" />
<bit-label>{{ "includeVAT" | i18n }}</bit-label>
</bit-form-control>
</div>
</div>
<div
class="tw-grid tw-grid-cols-12 tw-gap-4"
*ngIf="selectionSupportsAdditionalOptions && includeTaxIdIsSelected"
>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
<input bitInput type="text" formControlName="taxId" />
</bit-form-field>
</div>
</div>
<div
class="tw-grid tw-grid-cols-12 tw-gap-4"
*ngIf="selectionSupportsAdditionalOptions && includeTaxIdIsSelected"
>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "address1" | i18n }}</bit-label>
<input bitInput type="text" formControlName="line1" autocomplete="address-line1" />
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "address2" | i18n }}</bit-label>
<input bitInput type="text" formControlName="line2" autocomplete="address-line2" />
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "cityTown" | i18n }}</bit-label>
<input bitInput type="text" formControlName="city" autocomplete="address-level2" />
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input bitInput type="text" formControlName="state" autocomplete="address-level1" />
</bit-form-field>
</div>
</div>
<button *ngIf="!!onSubmit" bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
</form>

View File

@ -0,0 +1,406 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
type Country = {
name: string;
value: string;
disabled: boolean;
};
@Component({
selector: "app-manage-tax-information",
templateUrl: "./manage-tax-information.component.html",
})
export class ManageTaxInformationComponent implements OnInit {
@Input({ required: true }) taxInformation: TaxInformation;
@Input() onSubmit?: (taxInformation: TaxInformation) => Promise<void>;
@Output() taxInformationUpdated = new EventEmitter();
protected formGroup = this.formBuilder.group({
country: ["", Validators.required],
postalCode: ["", Validators.required],
includeTaxId: false,
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
});
constructor(private formBuilder: FormBuilder) {}
submit = async () => {
await this.onSubmit({
country: this.formGroup.value.country,
postalCode: this.formGroup.value.postalCode,
taxId: this.formGroup.value.taxId,
line1: this.formGroup.value.line1,
line2: this.formGroup.value.line2,
city: this.formGroup.value.city,
state: this.formGroup.value.state,
});
this.taxInformationUpdated.emit();
};
async ngOnInit() {
if (this.taxInformation) {
this.formGroup.patchValue({
...this.taxInformation,
includeTaxId:
this.countrySupportsTax(this.taxInformation.country) &&
(!!this.taxInformation.taxId ||
!!this.taxInformation.line1 ||
!!this.taxInformation.line2 ||
!!this.taxInformation.city ||
!!this.taxInformation.state),
});
}
}
protected countrySupportsTax(countryCode: string) {
return this.taxSupportedCountryCodes.includes(countryCode);
}
protected get includeTaxIdIsSelected() {
return this.formGroup.value.includeTaxId;
}
protected get selectionSupportsAdditionalOptions() {
return (
this.formGroup.value.country !== "US" && this.countrySupportsTax(this.formGroup.value.country)
);
}
protected countries: Country[] = [
{ name: "-- Select --", value: "", disabled: false },
{ name: "United States", value: "US", disabled: false },
{ name: "China", value: "CN", disabled: false },
{ name: "France", value: "FR", disabled: false },
{ name: "Germany", value: "DE", disabled: false },
{ name: "Canada", value: "CA", disabled: false },
{ name: "United Kingdom", value: "GB", disabled: false },
{ name: "Australia", value: "AU", disabled: false },
{ name: "India", value: "IN", disabled: false },
{ name: "", value: "-", disabled: true },
{ name: "Afghanistan", value: "AF", disabled: false },
{ name: "Åland Islands", value: "AX", disabled: false },
{ name: "Albania", value: "AL", disabled: false },
{ name: "Algeria", value: "DZ", disabled: false },
{ name: "American Samoa", value: "AS", disabled: false },
{ name: "Andorra", value: "AD", disabled: false },
{ name: "Angola", value: "AO", disabled: false },
{ name: "Anguilla", value: "AI", disabled: false },
{ name: "Antarctica", value: "AQ", disabled: false },
{ name: "Antigua and Barbuda", value: "AG", disabled: false },
{ name: "Argentina", value: "AR", disabled: false },
{ name: "Armenia", value: "AM", disabled: false },
{ name: "Aruba", value: "AW", disabled: false },
{ name: "Austria", value: "AT", disabled: false },
{ name: "Azerbaijan", value: "AZ", disabled: false },
{ name: "Bahamas", value: "BS", disabled: false },
{ name: "Bahrain", value: "BH", disabled: false },
{ name: "Bangladesh", value: "BD", disabled: false },
{ name: "Barbados", value: "BB", disabled: false },
{ name: "Belarus", value: "BY", disabled: false },
{ name: "Belgium", value: "BE", disabled: false },
{ name: "Belize", value: "BZ", disabled: false },
{ name: "Benin", value: "BJ", disabled: false },
{ name: "Bermuda", value: "BM", disabled: false },
{ name: "Bhutan", value: "BT", disabled: false },
{ name: "Bolivia, Plurinational State of", value: "BO", disabled: false },
{ name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false },
{ name: "Bosnia and Herzegovina", value: "BA", disabled: false },
{ name: "Botswana", value: "BW", disabled: false },
{ name: "Bouvet Island", value: "BV", disabled: false },
{ name: "Brazil", value: "BR", disabled: false },
{ name: "British Indian Ocean Territory", value: "IO", disabled: false },
{ name: "Brunei Darussalam", value: "BN", disabled: false },
{ name: "Bulgaria", value: "BG", disabled: false },
{ name: "Burkina Faso", value: "BF", disabled: false },
{ name: "Burundi", value: "BI", disabled: false },
{ name: "Cambodia", value: "KH", disabled: false },
{ name: "Cameroon", value: "CM", disabled: false },
{ name: "Cape Verde", value: "CV", disabled: false },
{ name: "Cayman Islands", value: "KY", disabled: false },
{ name: "Central African Republic", value: "CF", disabled: false },
{ name: "Chad", value: "TD", disabled: false },
{ name: "Chile", value: "CL", disabled: false },
{ name: "Christmas Island", value: "CX", disabled: false },
{ name: "Cocos (Keeling) Islands", value: "CC", disabled: false },
{ name: "Colombia", value: "CO", disabled: false },
{ name: "Comoros", value: "KM", disabled: false },
{ name: "Congo", value: "CG", disabled: false },
{ name: "Congo, the Democratic Republic of the", value: "CD", disabled: false },
{ name: "Cook Islands", value: "CK", disabled: false },
{ name: "Costa Rica", value: "CR", disabled: false },
{ name: "Côte d'Ivoire", value: "CI", disabled: false },
{ name: "Croatia", value: "HR", disabled: false },
{ name: "Cuba", value: "CU", disabled: false },
{ name: "Curaçao", value: "CW", disabled: false },
{ name: "Cyprus", value: "CY", disabled: false },
{ name: "Czech Republic", value: "CZ", disabled: false },
{ name: "Denmark", value: "DK", disabled: false },
{ name: "Djibouti", value: "DJ", disabled: false },
{ name: "Dominica", value: "DM", disabled: false },
{ name: "Dominican Republic", value: "DO", disabled: false },
{ name: "Ecuador", value: "EC", disabled: false },
{ name: "Egypt", value: "EG", disabled: false },
{ name: "El Salvador", value: "SV", disabled: false },
{ name: "Equatorial Guinea", value: "GQ", disabled: false },
{ name: "Eritrea", value: "ER", disabled: false },
{ name: "Estonia", value: "EE", disabled: false },
{ name: "Ethiopia", value: "ET", disabled: false },
{ name: "Falkland Islands (Malvinas)", value: "FK", disabled: false },
{ name: "Faroe Islands", value: "FO", disabled: false },
{ name: "Fiji", value: "FJ", disabled: false },
{ name: "Finland", value: "FI", disabled: false },
{ name: "French Guiana", value: "GF", disabled: false },
{ name: "French Polynesia", value: "PF", disabled: false },
{ name: "French Southern Territories", value: "TF", disabled: false },
{ name: "Gabon", value: "GA", disabled: false },
{ name: "Gambia", value: "GM", disabled: false },
{ name: "Georgia", value: "GE", disabled: false },
{ name: "Ghana", value: "GH", disabled: false },
{ name: "Gibraltar", value: "GI", disabled: false },
{ name: "Greece", value: "GR", disabled: false },
{ name: "Greenland", value: "GL", disabled: false },
{ name: "Grenada", value: "GD", disabled: false },
{ name: "Guadeloupe", value: "GP", disabled: false },
{ name: "Guam", value: "GU", disabled: false },
{ name: "Guatemala", value: "GT", disabled: false },
{ name: "Guernsey", value: "GG", disabled: false },
{ name: "Guinea", value: "GN", disabled: false },
{ name: "Guinea-Bissau", value: "GW", disabled: false },
{ name: "Guyana", value: "GY", disabled: false },
{ name: "Haiti", value: "HT", disabled: false },
{ name: "Heard Island and McDonald Islands", value: "HM", disabled: false },
{ name: "Holy See (Vatican City State)", value: "VA", disabled: false },
{ name: "Honduras", value: "HN", disabled: false },
{ name: "Hong Kong", value: "HK", disabled: false },
{ name: "Hungary", value: "HU", disabled: false },
{ name: "Iceland", value: "IS", disabled: false },
{ name: "Indonesia", value: "ID", disabled: false },
{ name: "Iran, Islamic Republic of", value: "IR", disabled: false },
{ name: "Iraq", value: "IQ", disabled: false },
{ name: "Ireland", value: "IE", disabled: false },
{ name: "Isle of Man", value: "IM", disabled: false },
{ name: "Israel", value: "IL", disabled: false },
{ name: "Italy", value: "IT", disabled: false },
{ name: "Jamaica", value: "JM", disabled: false },
{ name: "Japan", value: "JP", disabled: false },
{ name: "Jersey", value: "JE", disabled: false },
{ name: "Jordan", value: "JO", disabled: false },
{ name: "Kazakhstan", value: "KZ", disabled: false },
{ name: "Kenya", value: "KE", disabled: false },
{ name: "Kiribati", value: "KI", disabled: false },
{ name: "Korea, Democratic People's Republic of", value: "KP", disabled: false },
{ name: "Korea, Republic of", value: "KR", disabled: false },
{ name: "Kuwait", value: "KW", disabled: false },
{ name: "Kyrgyzstan", value: "KG", disabled: false },
{ name: "Lao People's Democratic Republic", value: "LA", disabled: false },
{ name: "Latvia", value: "LV", disabled: false },
{ name: "Lebanon", value: "LB", disabled: false },
{ name: "Lesotho", value: "LS", disabled: false },
{ name: "Liberia", value: "LR", disabled: false },
{ name: "Libya", value: "LY", disabled: false },
{ name: "Liechtenstein", value: "LI", disabled: false },
{ name: "Lithuania", value: "LT", disabled: false },
{ name: "Luxembourg", value: "LU", disabled: false },
{ name: "Macao", value: "MO", disabled: false },
{ name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false },
{ name: "Madagascar", value: "MG", disabled: false },
{ name: "Malawi", value: "MW", disabled: false },
{ name: "Malaysia", value: "MY", disabled: false },
{ name: "Maldives", value: "MV", disabled: false },
{ name: "Mali", value: "ML", disabled: false },
{ name: "Malta", value: "MT", disabled: false },
{ name: "Marshall Islands", value: "MH", disabled: false },
{ name: "Martinique", value: "MQ", disabled: false },
{ name: "Mauritania", value: "MR", disabled: false },
{ name: "Mauritius", value: "MU", disabled: false },
{ name: "Mayotte", value: "YT", disabled: false },
{ name: "Mexico", value: "MX", disabled: false },
{ name: "Micronesia, Federated States of", value: "FM", disabled: false },
{ name: "Moldova, Republic of", value: "MD", disabled: false },
{ name: "Monaco", value: "MC", disabled: false },
{ name: "Mongolia", value: "MN", disabled: false },
{ name: "Montenegro", value: "ME", disabled: false },
{ name: "Montserrat", value: "MS", disabled: false },
{ name: "Morocco", value: "MA", disabled: false },
{ name: "Mozambique", value: "MZ", disabled: false },
{ name: "Myanmar", value: "MM", disabled: false },
{ name: "Namibia", value: "NA", disabled: false },
{ name: "Nauru", value: "NR", disabled: false },
{ name: "Nepal", value: "NP", disabled: false },
{ name: "Netherlands", value: "NL", disabled: false },
{ name: "New Caledonia", value: "NC", disabled: false },
{ name: "New Zealand", value: "NZ", disabled: false },
{ name: "Nicaragua", value: "NI", disabled: false },
{ name: "Niger", value: "NE", disabled: false },
{ name: "Nigeria", value: "NG", disabled: false },
{ name: "Niue", value: "NU", disabled: false },
{ name: "Norfolk Island", value: "NF", disabled: false },
{ name: "Northern Mariana Islands", value: "MP", disabled: false },
{ name: "Norway", value: "NO", disabled: false },
{ name: "Oman", value: "OM", disabled: false },
{ name: "Pakistan", value: "PK", disabled: false },
{ name: "Palau", value: "PW", disabled: false },
{ name: "Palestinian Territory, Occupied", value: "PS", disabled: false },
{ name: "Panama", value: "PA", disabled: false },
{ name: "Papua New Guinea", value: "PG", disabled: false },
{ name: "Paraguay", value: "PY", disabled: false },
{ name: "Peru", value: "PE", disabled: false },
{ name: "Philippines", value: "PH", disabled: false },
{ name: "Pitcairn", value: "PN", disabled: false },
{ name: "Poland", value: "PL", disabled: false },
{ name: "Portugal", value: "PT", disabled: false },
{ name: "Puerto Rico", value: "PR", disabled: false },
{ name: "Qatar", value: "QA", disabled: false },
{ name: "Réunion", value: "RE", disabled: false },
{ name: "Romania", value: "RO", disabled: false },
{ name: "Russian Federation", value: "RU", disabled: false },
{ name: "Rwanda", value: "RW", disabled: false },
{ name: "Saint Barthélemy", value: "BL", disabled: false },
{ name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false },
{ name: "Saint Kitts and Nevis", value: "KN", disabled: false },
{ name: "Saint Lucia", value: "LC", disabled: false },
{ name: "Saint Martin (French part)", value: "MF", disabled: false },
{ name: "Saint Pierre and Miquelon", value: "PM", disabled: false },
{ name: "Saint Vincent and the Grenadines", value: "VC", disabled: false },
{ name: "Samoa", value: "WS", disabled: false },
{ name: "San Marino", value: "SM", disabled: false },
{ name: "Sao Tome and Principe", value: "ST", disabled: false },
{ name: "Saudi Arabia", value: "SA", disabled: false },
{ name: "Senegal", value: "SN", disabled: false },
{ name: "Serbia", value: "RS", disabled: false },
{ name: "Seychelles", value: "SC", disabled: false },
{ name: "Sierra Leone", value: "SL", disabled: false },
{ name: "Singapore", value: "SG", disabled: false },
{ name: "Sint Maarten (Dutch part)", value: "SX", disabled: false },
{ name: "Slovakia", value: "SK", disabled: false },
{ name: "Slovenia", value: "SI", disabled: false },
{ name: "Solomon Islands", value: "SB", disabled: false },
{ name: "Somalia", value: "SO", disabled: false },
{ name: "South Africa", value: "ZA", disabled: false },
{ name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false },
{ name: "South Sudan", value: "SS", disabled: false },
{ name: "Spain", value: "ES", disabled: false },
{ name: "Sri Lanka", value: "LK", disabled: false },
{ name: "Sudan", value: "SD", disabled: false },
{ name: "Suriname", value: "SR", disabled: false },
{ name: "Svalbard and Jan Mayen", value: "SJ", disabled: false },
{ name: "Swaziland", value: "SZ", disabled: false },
{ name: "Sweden", value: "SE", disabled: false },
{ name: "Switzerland", value: "CH", disabled: false },
{ name: "Syrian Arab Republic", value: "SY", disabled: false },
{ name: "Taiwan", value: "TW", disabled: false },
{ name: "Tajikistan", value: "TJ", disabled: false },
{ name: "Tanzania, United Republic of", value: "TZ", disabled: false },
{ name: "Thailand", value: "TH", disabled: false },
{ name: "Timor-Leste", value: "TL", disabled: false },
{ name: "Togo", value: "TG", disabled: false },
{ name: "Tokelau", value: "TK", disabled: false },
{ name: "Tonga", value: "TO", disabled: false },
{ name: "Trinidad and Tobago", value: "TT", disabled: false },
{ name: "Tunisia", value: "TN", disabled: false },
{ name: "Turkey", value: "TR", disabled: false },
{ name: "Turkmenistan", value: "TM", disabled: false },
{ name: "Turks and Caicos Islands", value: "TC", disabled: false },
{ name: "Tuvalu", value: "TV", disabled: false },
{ name: "Uganda", value: "UG", disabled: false },
{ name: "Ukraine", value: "UA", disabled: false },
{ name: "United Arab Emirates", value: "AE", disabled: false },
{ name: "United States Minor Outlying Islands", value: "UM", disabled: false },
{ name: "Uruguay", value: "UY", disabled: false },
{ name: "Uzbekistan", value: "UZ", disabled: false },
{ name: "Vanuatu", value: "VU", disabled: false },
{ name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false },
{ name: "Viet Nam", value: "VN", disabled: false },
{ name: "Virgin Islands, British", value: "VG", disabled: false },
{ name: "Virgin Islands, U.S.", value: "VI", disabled: false },
{ name: "Wallis and Futuna", value: "WF", disabled: false },
{ name: "Western Sahara", value: "EH", disabled: false },
{ name: "Yemen", value: "YE", disabled: false },
{ name: "Zambia", value: "ZM", disabled: false },
{ name: "Zimbabwe", value: "ZW", disabled: false },
];
private taxSupportedCountryCodes: string[] = [
"CN",
"FR",
"DE",
"CA",
"GB",
"AU",
"IN",
"AD",
"AR",
"AT",
"BE",
"BO",
"BR",
"BG",
"CL",
"CO",
"CR",
"HR",
"CY",
"CZ",
"DK",
"DO",
"EC",
"EG",
"SV",
"EE",
"FI",
"GE",
"GR",
"HK",
"HU",
"IS",
"ID",
"IQ",
"IE",
"IL",
"IT",
"JP",
"KE",
"KR",
"LV",
"LI",
"LT",
"LU",
"MY",
"MT",
"MX",
"NL",
"NZ",
"NO",
"PE",
"PH",
"PL",
"PT",
"RO",
"RU",
"SA",
"RS",
"SG",
"SK",
"SI",
"ZA",
"ES",
"SE",
"CH",
"TW",
"TH",
"TR",
"UA",
"AE",
"UY",
"VE",
"VN",
];
}

View File

@ -0,0 +1,151 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-mb-4 tw-text-lg">
<bit-radio-group formControlName="paymentMethod">
<bit-radio-button id="card-payment-method" [value]="PaymentMethodType.Card">
<bit-label>
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i>
{{ "creditCard" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="bank-payment-method"
[value]="PaymentMethodType.BankAccount"
*ngIf="showBankAccount"
>
<bit-label>
<i class="bwi bwi-fw bwi-bank" aria-hidden="true"></i>
{{ "bankAccount" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="paypal-payment-method"
[value]="PaymentMethodType.PayPal"
*ngIf="showPayPal"
>
<bit-label>
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i>
{{ "payPal" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="credit-payment-method"
[value]="PaymentMethodType.Credit"
*ngIf="showAccountCredit"
>
<bit-label>
<i class="bwi bwi-fw bwi-dollar" aria-hidden="true"></i>
{{ "accountCredit" | i18n }}
</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<!-- Card -->
<ng-container *ngIf="usingCard">
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4">
<div class="tw-col-span-1">
<label for="stripe-card-number">{{ "number" | i18n }}</label>
<div id="stripe-card-number" class="form-control stripe-form-control"></div>
</div>
<div class="tw-col-span-1 tw-flex tw-items-end">
<img
src="../../images/cards.png"
alt="Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay"
class="tw-max-w-full"
/>
</div>
<div class="tw-col-span-1">
<label for="stripe-card-expiry">{{ "expiration" | i18n }}</label>
<div id="stripe-card-expiry" class="form-control stripe-form-control"></div>
</div>
<div class="tw-col-span-1">
<div class="tw-flex">
<label for="stripe-card-cvc">
{{ "securityCode" | i18n }}
</label>
<a
href="https://www.cvvnumber.com/cvv.html"
tabindex="-1"
target="_blank"
rel="noreferrer"
class="ml-auto"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</div>
<div id="stripe-card-cvc" class="form-control stripe-form-control"></div>
</div>
</div>
</ng-container>
<!-- Bank Account -->
<ng-container *ngIf="showBankAccount && usingBankAccount">
<app-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "verifyBankAccountInitialDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}
</app-callout>
<div class="tw-grid tw-grid-cols-2 tw-gap-4" formGroupName="bankInformation">
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "routingNumber" | i18n }}</bit-label>
<input
bitInput
id="routingNumber"
type="text"
formControlName="routingNumber"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "accountNumber" | i18n }}</bit-label>
<input
bitInput
id="accountNumber"
type="text"
formControlName="accountNumber"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "accountHolderName" | i18n }}</bit-label>
<input
id="accountHolderName"
bitInput
type="text"
formControlName="accountHolderName"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "bankAccountType" | i18n }}</bit-label>
<bit-select id="accountHolderType" formControlName="accountHolderType" required>
<bit-option [value]="''" label="-- {{ 'select' | i18n }} --"></bit-option>
<bit-option
[value]="'company'"
label="{{ 'bankAccountTypeCompany' | i18n }}"
></bit-option>
<bit-option
[value]="'individual'"
label="{{ 'bankAccountTypeIndividual' | i18n }}"
></bit-option>
</bit-select>
</bit-form-field>
</div>
</ng-container>
<!-- PayPal -->
<ng-container *ngIf="showPayPal && usingPayPal">
<div class="tw-mb-3">
<div id="braintree-container" class="tw-mb-1 tw-content-center"></div>
<small class="tw-text-muted">{{ "paypalClickSubmit" | i18n }}</small>
</div>
</ng-container>
<!-- Account Credit -->
<ng-container *ngIf="showAccountCredit && usingAccountCredit">
<app-callout type="note">
{{ "makeSureEnoughCredit" | i18n }}
</app-callout>
</ng-container>
<button *ngIf="!!onSubmit" bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
</form>

View File

@ -0,0 +1,159 @@
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import {
BillingApiServiceAbstraction,
BraintreeServiceAbstraction,
StripeServiceAbstraction,
} from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { TokenizedPaymentMethod } from "@bitwarden/common/billing/models/domain";
@Component({
selector: "app-select-payment-method",
templateUrl: "./select-payment-method.component.html",
})
export class SelectPaymentMethodComponent implements OnInit, OnDestroy {
@Input() protected showAccountCredit: boolean = true;
@Input() protected showBankAccount: boolean = true;
@Input() protected showPayPal: boolean = true;
@Input() private startWith: PaymentMethodType = PaymentMethodType.Card;
@Input() protected onSubmit: (tokenizedPaymentMethod: TokenizedPaymentMethod) => Promise<void>;
private destroy$ = new Subject<void>();
protected formGroup = this.formBuilder.group({
paymentMethod: [this.startWith],
bankInformation: this.formBuilder.group({
routingNumber: ["", [Validators.required]],
accountNumber: ["", [Validators.required]],
accountHolderName: ["", [Validators.required]],
accountHolderType: ["", [Validators.required]],
}),
});
protected PaymentMethodType = PaymentMethodType;
constructor(
private billingApiService: BillingApiServiceAbstraction,
private braintreeService: BraintreeServiceAbstraction,
private formBuilder: FormBuilder,
private stripeService: StripeServiceAbstraction,
) {}
async tokenizePaymentMethod(): Promise<TokenizedPaymentMethod> {
const type = this.selected;
if (this.usingStripe) {
const clientSecret = await this.billingApiService.createSetupIntent(type);
if (this.usingBankAccount) {
const token = await this.stripeService.setupBankAccountPaymentMethod(clientSecret, {
accountHolderName: this.formGroup.value.bankInformation.accountHolderName,
routingNumber: this.formGroup.value.bankInformation.routingNumber,
accountNumber: this.formGroup.value.bankInformation.accountNumber,
accountHolderType: this.formGroup.value.bankInformation.accountHolderType,
});
return {
type,
token,
};
}
if (this.usingCard) {
const token = await this.stripeService.setupCardPaymentMethod(clientSecret);
return {
type,
token,
};
}
}
if (this.usingPayPal) {
const token = await this.braintreeService.requestPaymentMethod();
return {
type,
token,
};
}
return null;
}
submit = async () => {
const tokenizedPaymentMethod = await this.tokenizePaymentMethod();
await this.onSubmit(tokenizedPaymentMethod);
};
ngOnInit(): void {
this.stripeService.loadStripe(
{
cardNumber: "#stripe-card-number",
cardExpiry: "#stripe-card-expiry",
cardCvc: "#stripe-card-cvc",
},
this.startWith === PaymentMethodType.Card,
);
if (this.showPayPal) {
this.braintreeService.loadBraintree(
"#braintree-container",
this.startWith === PaymentMethodType.PayPal,
);
}
this.formGroup
.get("paymentMethod")
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((type) => {
this.onPaymentMethodChange(type);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.stripeService.unloadStripe();
if (this.showPayPal) {
this.braintreeService.unloadBraintree();
}
}
private onPaymentMethodChange(type: PaymentMethodType): void {
switch (type) {
case PaymentMethodType.Card: {
this.stripeService.mountElements();
break;
}
case PaymentMethodType.PayPal: {
this.braintreeService.createDropin();
break;
}
}
}
private get selected(): PaymentMethodType {
return this.formGroup.value.paymentMethod;
}
protected get usingAccountCredit(): boolean {
return this.selected === PaymentMethodType.Credit;
}
protected get usingBankAccount(): boolean {
return this.selected === PaymentMethodType.BankAccount;
}
protected get usingCard(): boolean {
return this.selected === PaymentMethodType.Card;
}
protected get usingPayPal(): boolean {
return this.selected === PaymentMethodType.PayPal;
}
private get usingStripe(): boolean {
return this.usingBankAccount || this.usingCard;
}
}

View File

@ -0,0 +1,18 @@
<app-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
<p>{{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}</p>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-form-field class="tw-mr-2 tw-w-40">
<bit-label>{{ "amountX" | i18n: "1" }}</bit-label>
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount1" />
<span bitPrefix>$0.</span>
</bit-form-field>
<bit-form-field class="tw-mr-2 tw-w-40">
<bit-label>{{ "amountX" | i18n: "2" }}</bit-label>
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount2" />
<span bitPrefix>$0.</span>
</bit-form-field>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
</form>
</app-callout>

View File

@ -0,0 +1,33 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
@Component({
selector: "app-verify-bank-account",
templateUrl: "./verify-bank-account.component.html",
})
export class VerifyBankAccountComponent {
@Input() onSubmit?: (amount1: number, amount2: number) => Promise<void>;
@Output() verificationSubmitted = new EventEmitter();
protected formGroup = this.formBuilder.group({
amount1: new FormControl<number>(null, [
Validators.required,
Validators.min(0),
Validators.max(99),
]),
amount2: new FormControl<number>(null, [
Validators.required,
Validators.min(0),
Validators.max(99),
]),
});
constructor(private formBuilder: FormBuilder) {}
submit = async () => {
if (this.onSubmit) {
await this.onSubmit(this.formGroup.value.amount1, this.formGroup.value.amount2);
}
this.verificationSubmitted.emit();
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -2,7 +2,24 @@ import { CommonModule, DatePipe } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { AutofocusDirective, ToastModule } from "@bitwarden/components";
import {
AddAccountCreditDialogComponent,
ManageTaxInformationComponent,
SelectPaymentMethodComponent,
VerifyBankAccountComponent,
} from "@bitwarden/angular/billing/components";
import {
AsyncActionsModule,
AutofocusDirective,
ButtonModule,
CheckboxModule,
DialogModule,
FormFieldModule,
RadioButtonModule,
SelectModule,
ToastModule,
TypographyModule,
} from "@bitwarden/components";
import { CalloutComponent } from "./components/callout.component";
import { A11yInvalidDirective } from "./directives/a11y-invalid.directive";
@ -41,6 +58,14 @@ import { IconComponent } from "./vault/components/icon.component";
CommonModule,
FormsModule,
ReactiveFormsModule,
AsyncActionsModule,
RadioButtonModule,
FormFieldModule,
SelectModule,
ButtonModule,
CheckboxModule,
DialogModule,
TypographyModule,
],
declarations: [
A11yInvalidDirective,
@ -70,6 +95,10 @@ import { IconComponent } from "./vault/components/icon.component";
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
AddAccountCreditDialogComponent,
ManageTaxInformationComponent,
SelectPaymentMethodComponent,
VerifyBankAccountComponent,
],
exports: [
A11yInvalidDirective,
@ -100,6 +129,10 @@ import { IconComponent } from "./vault/components/icon.component";
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
AddAccountCreditDialogComponent,
ManageTaxInformationComponent,
SelectPaymentMethodComponent,
VerifyBankAccountComponent,
],
providers: [
CreditCardNumberPipe,

View File

@ -1,6 +1,7 @@
import { InjectionToken } from "@angular/core";
import { Observable, Subject } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { ClientType } from "@bitwarden/common/enums";
import {
AbstractStorageService,
@ -36,7 +37,7 @@ export const MEMORY_STORAGE = new SafeInjectionToken<AbstractStorageService>("ME
export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SECURE_STORAGE");
export const STATE_FACTORY = new SafeInjectionToken<StateFactory>("STATE_FACTORY");
export const LOGOUT_CALLBACK = new SafeInjectionToken<
(expired: boolean, userId?: string) => Promise<void>
(logoutReason: LogoutReason, userId?: string) => Promise<void>
>("LOGOUT_CALLBACK");
export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise<void>>(
"LOCKED_CALLBACK",
@ -53,3 +54,7 @@ export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken<
Subject<Message<Record<string, unknown>>>
>("INTRAPROCESS_MESSAGING_SUBJECT");
export const CLIENT_TYPE = new SafeInjectionToken<ClientType>("CLIENT_TYPE");
export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() => void>(
"REFRESH_ACCESS_TOKEN_ERROR_CALLBACK",
);

View File

@ -13,6 +13,7 @@ import {
InternalUserDecryptionOptionsServiceAbstraction,
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
LogoutReason,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@ -109,14 +110,20 @@ import {
DomainSettingsService,
DefaultDomainSettingsService,
} from "@bitwarden/common/autofill/services/domain-settings.service";
import {
BillingApiServiceAbstraction,
BraintreeServiceAbstraction,
OrganizationBillingServiceAbstraction,
PaymentMethodWarningsServiceAbstraction,
StripeServiceAbstraction,
} from "@bitwarden/common/billing/abstractions";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PaymentMethodWarningsServiceAbstraction } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service";
import { BraintreeService } from "@bitwarden/common/billing/services/payment-processors/braintree.service";
import { StripeService } from "@bitwarden/common/billing/services/payment-processors/stripe.service";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
@ -232,6 +239,7 @@ import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync-
import { SyncService } from "@bitwarden/common/vault/services/sync/sync.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
import { ToastService } from "@bitwarden/components";
import {
ImportApiService,
ImportApiServiceAbstraction,
@ -275,6 +283,7 @@ import {
DEFAULT_VAULT_TIMEOUT,
INTRAPROCESS_MESSAGING_SUBJECT,
CLIENT_TYPE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
} from "./injection-tokens";
import { ModalService } from "./modal.service";
@ -316,8 +325,12 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: LOGOUT_CALLBACK,
useFactory:
(messagingService: MessagingServiceAbstraction) => (expired: boolean, userId?: string) =>
Promise.resolve(messagingService.send("logout", { expired: expired, userId: userId })),
(messagingService: MessagingServiceAbstraction) =>
async (logoutReason: LogoutReason, userId?: string) => {
return Promise.resolve(
messagingService.send("logout", { logoutReason: logoutReason, userId: userId }),
);
},
deps: [MessagingServiceAbstraction],
}),
safeProvider({
@ -526,6 +539,7 @@ const safeProviders: SafeProvider[] = [
KeyGenerationServiceAbstraction,
EncryptService,
LogService,
LOGOUT_CALLBACK,
],
}),
safeProvider({
@ -579,6 +593,17 @@ const safeProviders: SafeProvider[] = [
StateProvider,
],
}),
safeProvider({
provide: REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
useFactory: (toastService: ToastService, i18nService: I18nServiceAbstraction) => () => {
toastService.showToast({
variant: "error",
title: i18nService.t("errorRefreshingAccessToken"),
message: i18nService.t("errorRefreshingAccessTokenDesc"),
});
},
deps: [ToastService, I18nServiceAbstraction],
}),
safeProvider({
provide: ApiServiceAbstraction,
useClass: ApiService,
@ -587,8 +612,10 @@ const safeProviders: SafeProvider[] = [
PlatformUtilsServiceAbstraction,
EnvironmentService,
AppIdServiceAbstraction,
VaultTimeoutSettingsServiceAbstraction,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
LogService,
LOGOUT_CALLBACK,
VaultTimeoutSettingsServiceAbstraction,
],
}),
safeProvider({
@ -1190,6 +1217,16 @@ const safeProviders: SafeProvider[] = [
useClass: KdfConfigService,
deps: [StateProvider],
}),
safeProvider({
provide: BraintreeServiceAbstraction,
useClass: BraintreeService,
deps: [LogService],
}),
safeProvider({
provide: StripeServiceAbstraction,
useClass: StripeService,
deps: [LogService],
}),
];
function encryptServiceFactory(

View File

@ -15,7 +15,7 @@
</div>
<div class="tw-mb-auto tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center">
<div
class="tw-rounded-xl tw-mb-9 tw-mx-auto sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
class="tw-rounded-xl tw-mb-9 tw-mx-auto tw-min-w-64 sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
>
<ng-content></ng-content>
</div>

View File

@ -8,6 +8,7 @@
[label]="regionConfig.domain"
></bit-option>
<bit-option
*ngIf="isDesktopOrBrowserExtension"
[value]="ServerEnvironmentType.SelfHosted"
[label]="'selfHostedServer' | i18n"
></bit-option>

View File

@ -1,17 +1,26 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { EMPTY, Subject, from, map, of, switchMap, takeUntil, tap } from "rxjs";
import { Subject, from, map, of, pairwise, startWith, switchMap, takeUntil, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ClientType } from "@bitwarden/common/enums";
import {
Environment,
EnvironmentService,
Region,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { FormFieldModule, SelectModule } from "@bitwarden/components";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, FormFieldModule, SelectModule, ToastService } from "@bitwarden/components";
import { RegistrationSelfHostedEnvConfigDialogComponent } from "./registration-self-hosted-env-config-dialog.component";
/**
* Component for selecting the environment to register with in the email verification registration flow.
* Outputs the selected region to the parent component so it can respond as necessary.
*/
@Component({
standalone: true,
selector: "auth-registration-env-selector",
@ -19,7 +28,7 @@ import { FormFieldModule, SelectModule } from "@bitwarden/components";
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],
})
export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
@Output() onOpenSelfHostedSettings = new EventEmitter();
@Output() selectedRegionChange = new EventEmitter<RegionConfig | Region.SelfHosted | null>();
ServerEnvironmentType = Region;
@ -33,12 +42,24 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
availableRegionConfigs: RegionConfig[] = this.environmentService.availableRegions();
private selectedRegionFromEnv: RegionConfig | Region.SelfHosted;
isDesktopOrBrowserExtension = false;
private destroy$ = new Subject<void>();
constructor(
private formBuilder: FormBuilder,
private environmentService: EnvironmentService,
) {}
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
private platformUtilsService: PlatformUtilsService,
) {
const clientType = platformUtilsService.getClientType();
this.isDesktopOrBrowserExtension =
clientType === ClientType.Desktop || clientType === ClientType.Browser;
}
async ngOnInit() {
await this.initSelectedRegionAndListenForEnvChanges();
@ -61,13 +82,17 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
return regionConfig;
}),
tap((selectedRegionInitialValue: RegionConfig | Region.SelfHosted) => {
// This inits the form control with the selected region, but
// it also sets the value to self hosted if the self hosted settings are saved successfully
// in the client specific implementation managed by the parent component.
// It also resets the value to the previously selected region if the self hosted
// settings are closed without saving. We don't emit the event to avoid a loop.
this.selectedRegion.setValue(selectedRegionInitialValue, { emitEvent: false });
tap((selectedRegionFromEnv: RegionConfig | Region.SelfHosted) => {
// Only set the value if it is different from the current value.
if (selectedRegionFromEnv !== this.selectedRegion.value) {
// Don't emit to avoid triggering the selectedRegion valueChanges subscription
// which could loop back to this code.
this.selectedRegion.setValue(selectedRegionFromEnv, { emitEvent: false });
}
// Save this off so we can reset the value to the previously selected region
// if the self hosted settings are closed without saving.
this.selectedRegionFromEnv = selectedRegionFromEnv;
}),
takeUntil(this.destroy$),
)
@ -77,23 +102,66 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
private listenForSelectedRegionChanges() {
this.selectedRegion.valueChanges
.pipe(
switchMap((selectedRegionConfig: RegionConfig | Region.SelfHosted | null) => {
if (selectedRegionConfig === null) {
return of(null);
}
startWith(null), // required so that first user choice is not ignored
pairwise(),
switchMap(
([prevSelectedRegion, selectedRegion]: [
RegionConfig | Region.SelfHosted | null,
RegionConfig | Region.SelfHosted | null,
]) => {
if (selectedRegion === null) {
this.selectedRegionChange.emit(selectedRegion);
return of(null);
}
if (selectedRegionConfig === Region.SelfHosted) {
this.onOpenSelfHostedSettings.emit();
return EMPTY;
}
if (selectedRegion === Region.SelfHosted) {
return from(
RegistrationSelfHostedEnvConfigDialogComponent.open(this.dialogService),
).pipe(
tap((result: boolean | undefined) =>
this.handleSelfHostedEnvConfigDialogResult(result, prevSelectedRegion),
),
);
}
return from(this.environmentService.setEnvironment(selectedRegionConfig.key));
}),
this.selectedRegionChange.emit(selectedRegion);
return from(this.environmentService.setEnvironment(selectedRegion.key));
},
),
takeUntil(this.destroy$),
)
.subscribe();
}
private handleSelfHostedEnvConfigDialogResult(
result: boolean | undefined,
prevSelectedRegion: RegionConfig | Region.SelfHosted | null,
) {
if (result === true) {
this.selectedRegionChange.emit(Region.SelfHosted);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("environmentSaved"),
});
return;
}
// Reset the value to the previously selected region or the current env setting
// if the self hosted env settings dialog is closed without saving.
if (
(result === false || result === undefined) &&
prevSelectedRegion !== null &&
prevSelectedRegion !== Region.SelfHosted
) {
this.selectedRegionChange.emit(prevSelectedRegion);
this.selectedRegion.setValue(prevSelectedRegion, { emitEvent: false });
} else {
this.selectedRegionChange.emit(this.selectedRegionFromEnv);
this.selectedRegion.setValue(this.selectedRegionFromEnv, { emitEvent: false });
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();

View File

@ -0,0 +1,107 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle> Self-hosted environment</span>
<ng-container bitDialogContent>
<bit-form-field>
<bit-label>{{ "baseUrl" | i18n }}</bit-label>
<input
id="self_hosted_env_settings_form_input_base_url"
bitInput
type="text"
formControlName="baseUrl"
appAutofocus
appInputVerbatim
/>
<bit-hint>{{ "selfHostedBaseUrlHint" | i18n }}</bit-hint>
</bit-form-field>
<button bitLink linkType="primary" type="button" (click)="showCustomEnv = !showCustomEnv">
<i
class="bwi bwi-fw bwi-sm"
[ngClass]="{ 'bwi-angle-right': !showCustomEnv, 'bwi-angle-down': showCustomEnv }"
aria-hidden="true"
></i>
{{ "customEnvironment" | i18n }}
</button>
<ng-container *ngIf="showCustomEnv">
<p bitTypography="body1" class="tw-text-muted tw-mt-3">
{{ "selfHostedCustomEnvHeader" | i18n }}
</p>
<bit-form-field>
<bit-label>{{ "webVaultUrl" | i18n }}</bit-label>
<input
id="self_hosted_env_settings_form_input_web_vault_url"
bitInput
type="text"
formControlName="webVaultUrl"
appInputVerbatim
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "apiUrl" | i18n }}</bit-label>
<input
id="self_hosted_env_settings_form_input_api_url"
bitInput
type="text"
formControlName="apiUrl"
appInputVerbatim
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "identityUrl" | i18n }}</bit-label>
<input
id="self_hosted_env_settings_form_input_identity_url"
bitInput
type="text"
formControlName="identityUrl"
appInputVerbatim
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "notificationsUrl" | i18n }}</bit-label>
<input
id="self_hosted_env_settings_form_input_notifications_url"
bitInput
type="text"
formControlName="notificationsUrl"
appInputVerbatim
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "iconsUrl" | i18n }}</bit-label>
<input
id="self_hosted_env_settings_form_input_icons_url"
bitInput
type="text"
formControlName="iconsUrl"
appInputVerbatim
/>
</bit-form-field>
</ng-container>
<span
*ngIf="showErrorSummary"
class="tw-block tw-text-danger tw-mt-2"
aria-live="assertive"
role="alert"
>
<i class="bwi bwi-error"></i> {{ "selfHostedEnvFormInvalid" | i18n }}
</span>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "save" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,164 @@
import { DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
} from "@angular/forms";
import { Subject, firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import {
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogService,
FormFieldModule,
LinkModule,
TypographyModule,
} from "@bitwarden/components";
/**
* Validator for self-hosted environment settings form.
* It enforces that at least one URL is provided.
*/
function selfHostedEnvSettingsFormValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const formGroup = control as FormGroup;
const baseUrl = formGroup.get("baseUrl")?.value;
const webVaultUrl = formGroup.get("webVaultUrl")?.value;
const apiUrl = formGroup.get("apiUrl")?.value;
const identityUrl = formGroup.get("identityUrl")?.value;
const iconsUrl = formGroup.get("iconsUrl")?.value;
const notificationsUrl = formGroup.get("notificationsUrl")?.value;
if (baseUrl || webVaultUrl || apiUrl || identityUrl || iconsUrl || notificationsUrl) {
return null; // valid
} else {
return { atLeastOneUrlIsRequired: true }; // invalid
}
};
}
/**
* Dialog for configuring self-hosted environment settings.
*/
@Component({
standalone: true,
selector: "auth-registration-self-hosted-env-config-dialog",
templateUrl: "registration-self-hosted-env-config-dialog.component.html",
imports: [
CommonModule,
JslibModule,
DialogModule,
ButtonModule,
LinkModule,
TypographyModule,
ReactiveFormsModule,
FormFieldModule,
AsyncActionsModule,
],
})
export class RegistrationSelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
/**
* Opens the dialog.
* @param dialogService - Dialog service.
* @returns Promise that resolves to true if the dialog was closed with a successful result, false otherwise.
*/
static async open(dialogService: DialogService): Promise<boolean> {
const dialogRef = dialogService.open<boolean>(RegistrationSelfHostedEnvConfigDialogComponent, {
disableClose: false,
});
const dialogResult = await firstValueFrom(dialogRef.closed);
return dialogResult;
}
formGroup = this.formBuilder.group(
{
baseUrl: [null],
webVaultUrl: [null],
apiUrl: [null],
identityUrl: [null],
iconsUrl: [null],
notificationsUrl: [null],
},
{ validators: selfHostedEnvSettingsFormValidator() },
);
get baseUrl(): FormControl {
return this.formGroup.get("baseUrl") as FormControl;
}
get webVaultUrl(): FormControl {
return this.formGroup.get("webVaultUrl") as FormControl;
}
get apiUrl(): FormControl {
return this.formGroup.get("apiUrl") as FormControl;
}
get identityUrl(): FormControl {
return this.formGroup.get("identityUrl") as FormControl;
}
get iconsUrl(): FormControl {
return this.formGroup.get("iconsUrl") as FormControl;
}
get notificationsUrl(): FormControl {
return this.formGroup.get("notificationsUrl") as FormControl;
}
showCustomEnv = false;
showErrorSummary = false;
private destroy$ = new Subject<void>();
constructor(
private dialogRef: DialogRef<boolean>,
private formBuilder: FormBuilder,
private environmentService: EnvironmentService,
) {}
ngOnInit() {}
submit = async () => {
this.showErrorSummary = false;
if (this.formGroup.invalid) {
this.showErrorSummary = true;
return;
}
await this.environmentService.setEnvironment(Region.SelfHosted, {
base: this.baseUrl.value,
api: this.apiUrl.value,
identity: this.identityUrl.value,
webVault: this.webVaultUrl.value,
icons: this.iconsUrl.value,
notifications: this.notificationsUrl.value,
});
this.dialogRef.close(true);
};
async cancel() {
this.dialogRef.close(false);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -1,5 +1,9 @@
<ng-container *ngIf="state === RegistrationStartState.USER_DATA_ENTRY">
<form [formGroup]="formGroup" [bitSubmit]="submit">
<auth-registration-env-selector
(selectedRegionChange)="handleSelectedRegionChange($event)"
></auth-registration-env-selector>
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
<input

View File

@ -12,6 +12,7 @@ import { ActivatedRoute } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { RegionConfig, Region } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
AsyncActionsModule,
@ -23,6 +24,7 @@ import {
} from "@bitwarden/components";
import { RegistrationCheckEmailIcon } from "../../icons/registration-check-email.icon";
import { RegistrationEnvSelectorComponent } from "../registration-env-selector/registration-env-selector.component";
export enum RegistrationStartState {
USER_DATA_ENTRY = "UserDataEntry",
@ -43,6 +45,7 @@ export enum RegistrationStartState {
ButtonModule,
LinkModule,
IconModule,
RegistrationEnvSelectorComponent,
],
})
export class RegistrationStartComponent implements OnInit, OnDestroy {
@ -84,6 +87,7 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
) {
// TODO: this needs to update if user selects self hosted
this.isSelfHost = platformUtilsService.isSelfHost();
}
@ -116,6 +120,10 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
this.registrationStartStateChange.emit(this.state);
};
handleSelectedRegionChange(region: RegionConfig | Region.SelfHosted | null) {
this.isSelfHost = region === Region.SelfHosted;
}
private validateForm(): boolean {
this.formGroup.markAllAsTouched();

View File

@ -7,22 +7,72 @@ import * as stories from "./registration-start.stories";
# RegistrationStart Component
The Auth-owned RegistrationStartComponent is to be used for the first step in the new email
verification stagegated registration process. It collects the user's email address (required) and
optionally their name. On cloud environments, it requires acceptance of the terms of service and the
privacy policy; the checkbox is hidden on self hosted environments.
verification stage gated registration process. It collects the environment (required), the user's
email address (required) and optionally their name. On cloud environments, it requires acceptance of
the terms of service and the privacy policy; the checkbox is hidden on self hosted environments.
### Cloud Example
## Web Examples
<Story of={stories.CloudExample} />
Note that the self hosted option is not present in the environment selector.
### Self Hosted Example
### US Region
<Story of={stories.SelfHostExample} />
<Story of={stories.WebUSRegionExample} />
### Query Param Example
### EU Region
<Story of={stories.WebEURegionExample} />
### Query Params
The component accepts two query parameters: `email` and `emailReadonly`. If an email is provided, it
will be pre-filled in the email input field. If `emailReadonly` is set to `true`, the email input
field will be set to readonly. `emailReadonly` is primarily for the organization invite flow.
<Story of={stories.QueryParamsExample} />
<Story of={stories.WebUSRegionQueryParamsExample} />
## Desktop
Behavior to note:
- The self hosted option is present in the environment selector.
- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox
will disappear.
### US Region
<Story of={stories.DesktopUSRegionExample} />
### EU Region
<Story of={stories.DesktopEURegionExample} />
### Self Hosted
Note the fact that the terms of service and privacy policy checkbox is not present when the
environment is self hosted.
<Story of={stories.DesktopSelfHostExample} />
## Browser Extension
Behavior to note:
- The self hosted option is present in the environment selector.
- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox
will disappear.
### US Region
<Story of={stories.BrowserExtensionUSRegionExample} />
### EU Region
<Story of={stories.BrowserExtensionEURegionExample} />
### Self Hosted
Note the fact that the terms of service and privacy policy checkbox is not present when the
environment is self hosted.
<Story of={stories.BrowserExtensionSelfHostExample} />

View File

@ -1,10 +1,30 @@
import { importProvidersFrom } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { ActivatedRoute, Params } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { ClientType } from "@bitwarden/common/enums";
import {
Environment,
EnvironmentService,
Region,
Urls,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
AsyncActionsModule,
ButtonModule,
DialogModule,
FormFieldModule,
LinkModule,
SelectModule,
ToastOptions,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests";
@ -15,52 +35,70 @@ export default {
component: RegistrationStartComponent,
} as Meta;
const decorators = (options: { isSelfHost: boolean; queryParams: Params }) => {
const decorators = (options: {
isSelfHost?: boolean;
queryParams?: Params;
clientType?: ClientType;
defaultRegion?: Region;
}) => {
return [
moduleMetadata({
imports: [RouterTestingModule],
imports: [
RouterTestingModule,
DialogModule,
ReactiveFormsModule,
FormFieldModule,
SelectModule,
ButtonModule,
LinkModule,
TypographyModule,
AsyncActionsModule,
BrowserAnimationsModule,
],
providers: [
{
provide: ActivatedRoute,
useValue: { queryParams: of(options.queryParams) },
},
{
provide: PlatformUtilsService,
useValue: {
isSelfHost: () => options.isSelfHost,
} as Partial<PlatformUtilsService>,
useValue: { queryParams: of(options.queryParams || {}) },
},
],
}),
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
providers: [
importProvidersFrom(PreloadedEnglishI18nModule),
{
provide: EnvironmentService,
useValue: {
environment$: of({
getRegion: () => options.defaultRegion || Region.US,
} as Partial<Environment>),
availableRegions: () => [
{ key: Region.US, domain: "bitwarden.com", urls: {} },
{ key: Region.EU, domain: "bitwarden.eu", urls: {} },
],
setEnvironment: (region: Region, urls?: Urls) => Promise.resolve({}),
} as Partial<EnvironmentService>,
},
{
provide: PlatformUtilsService,
useValue: {
isSelfHost: () => options.isSelfHost || false,
getClientType: () => options.clientType || ClientType.Web,
} as Partial<PlatformUtilsService>,
},
{
provide: ToastService,
useValue: {
showToast: (options: ToastOptions) => {},
} as Partial<ToastService>,
},
],
}),
];
};
type Story = StoryObj<RegistrationStartComponent>;
export const CloudExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({ isSelfHost: false, queryParams: {} }),
};
export const SelfHostExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({ isSelfHost: true, queryParams: {} }),
};
export const QueryParamsExample: Story = {
export const WebUSRegionExample: Story = {
render: (args) => ({
props: args,
template: `
@ -68,7 +106,120 @@ export const QueryParamsExample: Story = {
`,
}),
decorators: decorators({
isSelfHost: false,
clientType: ClientType.Web,
queryParams: {},
defaultRegion: Region.US,
}),
};
export const WebEURegionExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({
clientType: ClientType.Web,
queryParams: {},
defaultRegion: Region.EU,
}),
};
export const WebUSRegionQueryParamsExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({
clientType: ClientType.Web,
defaultRegion: Region.US,
queryParams: { email: "jaredWasHere@bitwarden.com", emailReadonly: "true" },
}),
};
export const DesktopUSRegionExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({
clientType: ClientType.Desktop,
defaultRegion: Region.US,
isSelfHost: false,
}),
};
export const DesktopEURegionExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({
clientType: ClientType.Desktop,
defaultRegion: Region.EU,
isSelfHost: false,
}),
};
export const DesktopSelfHostExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({
clientType: ClientType.Desktop,
isSelfHost: true,
defaultRegion: Region.SelfHosted,
}),
};
export const BrowserExtensionUSRegionExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({
clientType: ClientType.Browser,
defaultRegion: Region.US,
isSelfHost: false,
}),
};
export const BrowserExtensionEURegionExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({
clientType: ClientType.Browser,
defaultRegion: Region.EU,
isSelfHost: false,
}),
};
export const BrowserExtensionSelfHostExample: Story = {
render: (args) => ({
props: args,
template: `
<auth-registration-start></auth-registration-start>
`,
}),
decorators: decorators({
clientType: ClientType.Browser,
isSelfHost: true,
defaultRegion: Region.SelfHosted,
}),
};

Some files were not shown because too many files have changed in this diff Show More