From eb2cdffe49955e5a902219bc23b1d79fd8c92c03 Mon Sep 17 00:00:00 2001 From: CarleyDiaz-Bitwarden <103955722+CarleyDiaz-Bitwarden@users.noreply.github.com> Date: Tue, 2 Aug 2022 11:57:50 -0400 Subject: [PATCH] Merging master into branch --- .github/workflows/version-auto-bump.yml | 68 +++ apps/desktop/desktop_native/.cargo/config | 8 + .../desktop_native/src/biometric/macos.rs | 9 + .../desktop_native/src/biometric/mod.rs | 5 + .../desktop_native/src/biometric/unix.rs | 9 + .../desktop_native/src/biometric/windows.rs | 51 ++ .../accounts/delete-account.component.html | 38 ++ .../app/accounts/delete-account.component.ts | 48 ++ .../app/core/broadcaster-messaging.service.ts | 14 + apps/web/src/app/core/core.module.ts | 148 +++++ apps/web/src/app/core/event.service.ts | 571 ++++++++++++++++++ apps/web/src/app/core/html-storage.service.ts | 70 +++ apps/web/src/app/core/i18n.service.ts | 72 +++ apps/web/src/app/core/index.ts | 5 + apps/web/src/app/core/init.service.ts | 58 ++ apps/web/src/app/core/modal.service.ts | 58 ++ .../src/app/core/password-reprompt.service.ts | 10 + apps/web/src/app/core/policy-list.service.ts | 13 + apps/web/src/app/core/router.service.ts | 56 ++ .../src/app/core/state-migration.service.ts | 13 + apps/web/src/app/core/state/account.ts | 20 + apps/web/src/app/core/state/global-state.ts | 7 + apps/web/src/app/core/state/index.ts | 3 + apps/web/src/app/core/state/state.service.ts | 131 ++++ .../src/app/core/web-file-download.service.ts | 26 + .../app/core/web-platform-utils.service.ts | 254 ++++++++ .../bulk/bulk-restore-revoke.component.html | 102 ++++ .../bulk/bulk-restore-revoke.component.ts | 66 ++ apps/web/src/main.ts | 17 + apps/web/src/polyfills.ts | 15 + bitwarden_license/bit-web/src/main.ts | 17 + .../password-strength.component.html | 14 + .../password-strength.component.ts | 133 ++++ .../common/spec/domain/encArrayBuffer.spec.ts | 76 +++ .../spec/matchers/toEqualBuffer.spec.ts | 25 + libs/common/spec/matchers/toEqualBuffer.ts | 34 ++ .../spec/services/crypto.service.spec.ts | 38 ++ .../spec/services/encrypt.service.spec.ts | 163 +++++ .../account-api.service.abstraction.ts | 5 + .../account/account.service.abstraction.ts | 5 + libs/common/src/interfaces/IEncrypted.ts | 8 + .../response/organizationExportResponse.ts | 15 + .../services/account/account-api.service.ts | 11 + .../src/services/account/account.service.ts | 27 + libs/components/src/modal/index.ts | 3 + .../src/modal/modal-simple.component.html | 19 + .../src/modal/modal-simple.component.ts | 16 + .../src/modal/modal-simple.stories.ts | 85 +++ .../components/src/modal/modal.component.html | 25 + libs/components/src/modal/modal.component.ts | 23 + libs/components/src/modal/modal.module.ts | 12 + libs/components/src/modal/modal.stories.ts | 80 +++ libs/components/src/table/cell.directive.ts | 10 + libs/components/src/table/index.ts | 1 + libs/components/src/table/row.directive.ts | 17 + .../components/src/table/table.component.html | 10 + libs/components/src/table/table.component.ts | 7 + libs/components/src/table/table.module.ts | 13 + libs/components/src/table/table.stories.ts | 53 ++ tailwind.config.js | 11 + 60 files changed, 2921 insertions(+) create mode 100644 .github/workflows/version-auto-bump.yml create mode 100644 apps/desktop/desktop_native/.cargo/config create mode 100644 apps/desktop/desktop_native/src/biometric/macos.rs create mode 100644 apps/desktop/desktop_native/src/biometric/mod.rs create mode 100644 apps/desktop/desktop_native/src/biometric/unix.rs create mode 100644 apps/desktop/desktop_native/src/biometric/windows.rs create mode 100644 apps/desktop/src/app/accounts/delete-account.component.html create mode 100644 apps/desktop/src/app/accounts/delete-account.component.ts create mode 100644 apps/web/src/app/core/broadcaster-messaging.service.ts create mode 100644 apps/web/src/app/core/core.module.ts create mode 100644 apps/web/src/app/core/event.service.ts create mode 100644 apps/web/src/app/core/html-storage.service.ts create mode 100644 apps/web/src/app/core/i18n.service.ts create mode 100644 apps/web/src/app/core/index.ts create mode 100644 apps/web/src/app/core/init.service.ts create mode 100644 apps/web/src/app/core/modal.service.ts create mode 100644 apps/web/src/app/core/password-reprompt.service.ts create mode 100644 apps/web/src/app/core/policy-list.service.ts create mode 100644 apps/web/src/app/core/router.service.ts create mode 100644 apps/web/src/app/core/state-migration.service.ts create mode 100644 apps/web/src/app/core/state/account.ts create mode 100644 apps/web/src/app/core/state/global-state.ts create mode 100644 apps/web/src/app/core/state/index.ts create mode 100644 apps/web/src/app/core/state/state.service.ts create mode 100644 apps/web/src/app/core/web-file-download.service.ts create mode 100644 apps/web/src/app/core/web-platform-utils.service.ts create mode 100644 apps/web/src/app/organizations/manage/bulk/bulk-restore-revoke.component.html create mode 100644 apps/web/src/app/organizations/manage/bulk/bulk-restore-revoke.component.ts create mode 100644 apps/web/src/main.ts create mode 100644 apps/web/src/polyfills.ts create mode 100644 bitwarden_license/bit-web/src/main.ts create mode 100644 libs/angular/src/shared/components/password-strength/password-strength.component.html create mode 100644 libs/angular/src/shared/components/password-strength/password-strength.component.ts create mode 100644 libs/common/spec/domain/encArrayBuffer.spec.ts create mode 100644 libs/common/spec/matchers/toEqualBuffer.spec.ts create mode 100644 libs/common/spec/matchers/toEqualBuffer.ts create mode 100644 libs/common/spec/services/crypto.service.spec.ts create mode 100644 libs/common/spec/services/encrypt.service.spec.ts create mode 100644 libs/common/src/abstractions/account/account-api.service.abstraction.ts create mode 100644 libs/common/src/abstractions/account/account.service.abstraction.ts create mode 100644 libs/common/src/interfaces/IEncrypted.ts create mode 100644 libs/common/src/models/response/organizationExportResponse.ts create mode 100644 libs/common/src/services/account/account-api.service.ts create mode 100644 libs/common/src/services/account/account.service.ts create mode 100644 libs/components/src/modal/index.ts create mode 100644 libs/components/src/modal/modal-simple.component.html create mode 100644 libs/components/src/modal/modal-simple.component.ts create mode 100644 libs/components/src/modal/modal-simple.stories.ts create mode 100644 libs/components/src/modal/modal.component.html create mode 100644 libs/components/src/modal/modal.component.ts create mode 100644 libs/components/src/modal/modal.module.ts create mode 100644 libs/components/src/modal/modal.stories.ts create mode 100644 libs/components/src/table/cell.directive.ts create mode 100644 libs/components/src/table/index.ts create mode 100644 libs/components/src/table/row.directive.ts create mode 100644 libs/components/src/table/table.component.html create mode 100644 libs/components/src/table/table.component.ts create mode 100644 libs/components/src/table/table.module.ts create mode 100644 libs/components/src/table/table.stories.ts create mode 100644 tailwind.config.js diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml new file mode 100644 index 00000000000..4c1b0f6e8f1 --- /dev/null +++ b/.github/workflows/version-auto-bump.yml @@ -0,0 +1,68 @@ +--- +name: Version Auto Bump + +on: + release: + types: [published] + +defaults: + run: + shell: bash + +jobs: + setup: + name: "Setup" + runs-on: ubuntu-20.04 + outputs: + version_number: ${{ steps.version.outputs.new-version }} + if: contains(github.event.release.tag, 'desktop') + steps: + - name: Checkout Branch + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 + + - name: Get version to bump + id: version + env: + RELEASE_TAG: ${{ github.event.release.tag }} + run: | + + CURR_MAJOR=$(echo $RELEASE_TAG | sed -r 's/desktop-v([0-9]{4}\.[0-9]\.)([0-9])/\1/') + CURR_VER=$(echo $RELEASE_TAG | sed -r 's/desktop-v([0-9]{4}\.[0-9]\.)([0-9])/\2/') + echo $CURR_VER + ((CURR_VER++)) + NEW_VER=$CURR_MAJOR$CURR_VER + echo "::set-output name=new-version::$NEW_VER" + + trigger_version_bump: + name: "Trigger desktop version bump workflow" + runs-on: ubuntu-20.04 + needs: + - setup + steps: + - name: Login to Azure + uses: Azure/login@ec3c14589bd3e9312b3cc8c41e6860e258df9010 + with: + creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + + - name: Retrieve secrets + id: retrieve-secrets + env: + KEYVAULT: bitwarden-prod-kv + SECRET: "github-pat-bitwarden-devops-bot-repo-scope" + run: | + VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $SECRET --query value --output tsv) + echo "::add-mask::$VALUE" + echo "::set-output name=$SECRET::$VALUE" + + - name: Call GitHub API to trigger workflow bump + env: + TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + VERSION: ${{ needs.setup.outputs.version_number}} + run: | + JSON_STRING=$(printf '{"ref":"master", "inputs": { "client":"Desktop", "version_number":"%s"}}' "$VERSION") + curl \ + -X POST \ + -i -u bitwarden-devops-bot:$TOKEN \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/bitwarden/clients/actions/workflows/version-bump.yml/dispatches \ + -d $JSON_STRING diff --git a/apps/desktop/desktop_native/.cargo/config b/apps/desktop/desktop_native/.cargo/config new file mode 100644 index 00000000000..b58e89798b5 --- /dev/null +++ b/apps/desktop/desktop_native/.cargo/config @@ -0,0 +1,8 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-Ctarget-feature=+crt-static"] + +[target.i686-pc-windows-msvc] +rustflags = ["-Ctarget-feature=+crt-static"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-Ctarget-feature=+crt-static"] diff --git a/apps/desktop/desktop_native/src/biometric/macos.rs b/apps/desktop/desktop_native/src/biometric/macos.rs new file mode 100644 index 00000000000..3401b7f6da9 --- /dev/null +++ b/apps/desktop/desktop_native/src/biometric/macos.rs @@ -0,0 +1,9 @@ +use anyhow::{Result, bail}; + +pub fn prompt(_hwnd: Vec, _message: String) -> Result { + bail!("platform not supported"); +} + +pub fn available() -> Result { + bail!("platform not supported"); +} diff --git a/apps/desktop/desktop_native/src/biometric/mod.rs b/apps/desktop/desktop_native/src/biometric/mod.rs new file mode 100644 index 00000000000..5ad1403f44f --- /dev/null +++ b/apps/desktop/desktop_native/src/biometric/mod.rs @@ -0,0 +1,5 @@ +#[cfg_attr(target_os = "linux", path = "unix.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod biometric; +pub use biometric::*; diff --git a/apps/desktop/desktop_native/src/biometric/unix.rs b/apps/desktop/desktop_native/src/biometric/unix.rs new file mode 100644 index 00000000000..3401b7f6da9 --- /dev/null +++ b/apps/desktop/desktop_native/src/biometric/unix.rs @@ -0,0 +1,9 @@ +use anyhow::{Result, bail}; + +pub fn prompt(_hwnd: Vec, _message: String) -> Result { + bail!("platform not supported"); +} + +pub fn available() -> Result { + bail!("platform not supported"); +} diff --git a/apps/desktop/desktop_native/src/biometric/windows.rs b/apps/desktop/desktop_native/src/biometric/windows.rs new file mode 100644 index 00000000000..d956da064d0 --- /dev/null +++ b/apps/desktop/desktop_native/src/biometric/windows.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use windows::{ + core::factory, Foundation::IAsyncOperation, Security::Credentials::UI::*, + Win32::Foundation::HWND, Win32::System::WinRT::IUserConsentVerifierInterop, +}; + +pub fn prompt(hwnd: Vec, message: String) -> Result { + let interop = factory::()?; + + let h = isize::from_le_bytes(hwnd.try_into().unwrap()); + let window = HWND(h); + + let operation: IAsyncOperation = + unsafe { interop.RequestVerificationForWindowAsync(window, message)? }; + + let result: UserConsentVerificationResult = operation.get()?; + + match result { + UserConsentVerificationResult::Verified => Ok(true), + _ => Ok(false), + } +} + +pub fn available() -> Result { + let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?; + + match ucv_available { + UserConsentVerifierAvailability::Available => Ok(true), + UserConsentVerifierAvailability::DeviceBusy => Ok(true), // TODO: Look into removing this and making the check more ad-hoc + _ => Ok(false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prompt() { + prompt( + vec![0, 0, 0, 0, 0, 0, 0, 0], + String::from("Hello from Rust"), + ) + .unwrap(); + } + + #[test] + fn test_available() { + assert!(available().unwrap()) + } +} diff --git a/apps/desktop/src/app/accounts/delete-account.component.html b/apps/desktop/src/app/accounts/delete-account.component.html new file mode 100644 index 00000000000..1371cee162f --- /dev/null +++ b/apps/desktop/src/app/accounts/delete-account.component.html @@ -0,0 +1,38 @@ + diff --git a/apps/desktop/src/app/accounts/delete-account.component.ts b/apps/desktop/src/app/accounts/delete-account.component.ts new file mode 100644 index 00000000000..c708ba57416 --- /dev/null +++ b/apps/desktop/src/app/accounts/delete-account.component.ts @@ -0,0 +1,48 @@ +import { Component } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; + +import { AccountService } from "@bitwarden/common/abstractions/account/account.service.abstraction"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; + +import { Verification } from "../../../../../libs/common/src/types/verification"; + +@Component({ + selector: "app-delete-account", + templateUrl: "delete-account.component.html", +}) +export class DeleteAccountComponent { + formPromise: Promise; + + deleteForm = this.formBuilder.group({ + verification: undefined as Verification | undefined, + }); + + constructor( + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private formBuilder: FormBuilder, + private accountService: AccountService, + private logService: LogService + ) {} + + get secret() { + return this.deleteForm.get("verification")?.value?.secret; + } + + async submit() { + try { + const verification = this.deleteForm.get("verification").value; + this.formPromise = this.accountService.delete(verification); + await this.formPromise; + this.platformUtilsService.showToast( + "success", + this.i18nService.t("accountDeleted"), + this.i18nService.t("accountDeletedDesc") + ); + } catch (e) { + this.logService.error(e); + } + } +} diff --git a/apps/web/src/app/core/broadcaster-messaging.service.ts b/apps/web/src/app/core/broadcaster-messaging.service.ts new file mode 100644 index 00000000000..47745a9e443 --- /dev/null +++ b/apps/web/src/app/core/broadcaster-messaging.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from "@angular/core"; + +import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; +import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; + +@Injectable() +export class BroadcasterMessagingService implements MessagingService { + constructor(private broadcasterService: BroadcasterService) {} + + send(subscriber: string, arg: any = {}) { + const message = Object.assign({}, { command: subscriber }, arg); + this.broadcasterService.send(message); + } +} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts new file mode 100644 index 00000000000..fe0f180931c --- /dev/null +++ b/apps/web/src/app/core/core.module.ts @@ -0,0 +1,148 @@ +import { CommonModule } from "@angular/common"; +import { APP_INITIALIZER, NgModule, Optional, SkipSelf } from "@angular/core"; + +import { + JslibServicesModule, + SECURE_STORAGE, + STATE_FACTORY, + STATE_SERVICE_USE_CACHE, + LOCALES_DIRECTORY, + SYSTEM_LANGUAGE, + MEMORY_STORAGE, +} from "@bitwarden/angular/services/jslib-services.module"; +import { + ModalService as ModalServiceAbstraction, + ModalConfig as ModalConfigAbstraction, + ModalConfig, +} from "@bitwarden/angular/services/modal.service"; +import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; +import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/abstractions/cipher.service"; +import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/abstractions/collection.service"; +import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/abstractions/crypto.service"; +import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { ExportService as ExportServiceAbstraction } from "@bitwarden/common/abstractions/export.service"; +import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; +import { FolderService as FolderServiceAbstraction } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service"; +import { ImportService as ImportServiceAbstraction } from "@bitwarden/common/abstractions/import.service"; +import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/abstractions/messaging.service"; +import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@bitwarden/common/abstractions/passwordReprompt.service"; +import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/abstractions/platformUtils.service"; +import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; +import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/abstractions/stateMigration.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { StateFactory } from "@bitwarden/common/factories/stateFactory"; +import { ExportService } from "@bitwarden/common/services/export.service"; +import { ImportService } from "@bitwarden/common/services/import.service"; +import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; + +import { BroadcasterMessagingService } from "./broadcaster-messaging.service"; +import { EventService } from "./event.service"; +import { HtmlStorageService } from "./html-storage.service"; +import { I18nService } from "./i18n.service"; +import { InitService } from "./init.service"; +import { ModalService } from "./modal.service"; +import { PasswordRepromptService } from "./password-reprompt.service"; +import { PolicyListService } from "./policy-list.service"; +import { RouterService } from "./router.service"; +import { Account, GlobalState, StateService } from "./state"; +import { StateMigrationService } from "./state-migration.service"; +import { WebFileDownloadService } from "./web-file-download.service"; +import { WebPlatformUtilsService } from "./web-platform-utils.service"; + +@NgModule({ + declarations: [], + imports: [CommonModule, JslibServicesModule], + providers: [ + InitService, + RouterService, + EventService, + PolicyListService, + { + provide: APP_INITIALIZER, + useFactory: (initService: InitService) => initService.init(), + deps: [InitService], + multi: true, + }, + { + provide: STATE_FACTORY, + useValue: new StateFactory(GlobalState, Account), + }, + { + provide: STATE_SERVICE_USE_CACHE, + useValue: false, + }, + { + provide: I18nServiceAbstraction, + useClass: I18nService, + deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY], + }, + { provide: AbstractStorageService, useClass: HtmlStorageService }, + { + provide: SECURE_STORAGE, + // TODO: platformUtilsService.isDev has a helper for this, but using that service here results in a circular dependency. + // We have a tech debt item in the backlog to break up platformUtilsService, but in the meantime simply checking the environement here is less cumbersome. + useClass: process.env.NODE_ENV === "development" ? HtmlStorageService : MemoryStorageService, + }, + { + provide: MEMORY_STORAGE, + useClass: MemoryStorageService, + }, + { + provide: PlatformUtilsServiceAbstraction, + useClass: WebPlatformUtilsService, + }, + { provide: MessagingServiceAbstraction, useClass: BroadcasterMessagingService }, + { provide: ModalServiceAbstraction, useClass: ModalService }, + { provide: ModalConfigAbstraction, useClass: ModalConfig }, + { + provide: ImportServiceAbstraction, + useClass: ImportService, + deps: [ + CipherServiceAbstraction, + FolderServiceAbstraction, + ApiServiceAbstraction, + I18nServiceAbstraction, + CollectionServiceAbstraction, + PlatformUtilsServiceAbstraction, + CryptoServiceAbstraction, + ], + }, + { + provide: ExportServiceAbstraction, + useClass: ExportService, + deps: [ + FolderServiceAbstraction, + CipherServiceAbstraction, + ApiServiceAbstraction, + CryptoServiceAbstraction, + CryptoFunctionServiceAbstraction, + ], + }, + { + provide: StateMigrationServiceAbstraction, + useClass: StateMigrationService, + deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY], + }, + StateService, + { + provide: BaseStateServiceAbstraction, + useExisting: StateService, + }, + { + provide: PasswordRepromptServiceAbstraction, + useClass: PasswordRepromptService, + }, + { + provide: FileDownloadService, + useClass: WebFileDownloadService, + }, + ], +}) +export class CoreModule { + constructor(@Optional() @SkipSelf() parentModule?: CoreModule) { + if (parentModule) { + throw new Error("CoreModule is already loaded. Import it in the AppModule only"); + } + } +} diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts new file mode 100644 index 00000000000..2627c6b6a82 --- /dev/null +++ b/apps/web/src/app/core/event.service.ts @@ -0,0 +1,571 @@ +import { Injectable } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PolicyService } from "@bitwarden/common/abstractions/policy.service"; +import { DeviceType } from "@bitwarden/common/enums/deviceType"; +import { EventType } from "@bitwarden/common/enums/eventType"; +import { PolicyType } from "@bitwarden/common/enums/policyType"; +import { EventResponse } from "@bitwarden/common/models/response/eventResponse"; + +@Injectable() +export class EventService { + constructor(private i18nService: I18nService, private policyService: PolicyService) {} + + getDefaultDateFilters() { + const d = new Date(); + const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59); + d.setDate(d.getDate() - 30); + const start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0); + return [this.toDateTimeLocalString(start), this.toDateTimeLocalString(end)]; + } + + formatDateFilters(filterStart: string, filterEnd: string) { + const start: Date = new Date(filterStart); + const end: Date = new Date(filterEnd + ":59.999"); + if (isNaN(start.getTime()) || isNaN(end.getTime()) || end < start) { + throw new Error("Invalid date range."); + } + return [start.toISOString(), end.toISOString()]; + } + + async getEventInfo(ev: EventResponse, options = new EventOptions()): Promise { + const appInfo = this.getAppInfo(ev.deviceType); + const { message, humanReadableMessage } = await this.getEventMessage(ev, options); + return { + message: message, + humanReadableMessage: humanReadableMessage, + appIcon: appInfo[0], + appName: appInfo[1], + }; + } + + private async getEventMessage(ev: EventResponse, options: EventOptions) { + let msg = ""; + let humanReadableMsg = ""; + switch (ev.type) { + // User + case EventType.User_LoggedIn: + msg = humanReadableMsg = this.i18nService.t("loggedIn"); + break; + case EventType.User_ChangedPassword: + msg = humanReadableMsg = this.i18nService.t("changedPassword"); + break; + case EventType.User_Updated2fa: + msg = humanReadableMsg = this.i18nService.t("enabledUpdated2fa"); + break; + case EventType.User_Disabled2fa: + msg = humanReadableMsg = this.i18nService.t("disabled2fa"); + break; + case EventType.User_Recovered2fa: + msg = humanReadableMsg = this.i18nService.t("recovered2fa"); + break; + case EventType.User_FailedLogIn: + msg = humanReadableMsg = this.i18nService.t("failedLogin"); + break; + case EventType.User_FailedLogIn2fa: + msg = humanReadableMsg = this.i18nService.t("failedLogin2fa"); + break; + case EventType.User_ClientExportedVault: + msg = humanReadableMsg = this.i18nService.t("exportedVault"); + break; + case EventType.User_UpdatedTempPassword: + msg = humanReadableMsg = this.i18nService.t("updatedMasterPassword"); + break; + case EventType.User_MigratedKeyToKeyConnector: + msg = humanReadableMsg = this.i18nService.t("migratedKeyConnector"); + break; + // Cipher + case EventType.Cipher_Created: + msg = this.i18nService.t("createdItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("createdItemId", this.getShortId(ev.cipherId)); + break; + case EventType.Cipher_Updated: + msg = this.i18nService.t("editedItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("editedItemId", this.getShortId(ev.cipherId)); + break; + case EventType.Cipher_Deleted: + msg = this.i18nService.t("permanentlyDeletedItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "permanentlyDeletedItemId", + this.getShortId(ev.cipherId) + ); + break; + case EventType.Cipher_SoftDeleted: + msg = this.i18nService.t("deletedItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("deletedItemId", this.getShortId(ev.cipherId)); + break; + case EventType.Cipher_Restored: + msg = this.i18nService.t("restoredItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("restoredItemId", this.formatCipherId(ev, options)); + break; + case EventType.Cipher_AttachmentCreated: + msg = this.i18nService.t("createdAttachmentForItem", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "createdAttachmentForItem", + this.getShortId(ev.cipherId) + ); + break; + case EventType.Cipher_AttachmentDeleted: + msg = this.i18nService.t("deletedAttachmentForItem", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "deletedAttachmentForItem", + this.getShortId(ev.cipherId) + ); + break; + case EventType.Cipher_Shared: + msg = this.i18nService.t("movedItemIdToOrg", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("movedItemIdToOrg", this.getShortId(ev.cipherId)); + break; + case EventType.Cipher_ClientViewed: + msg = this.i18nService.t("viewedItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("viewedItemId", this.getShortId(ev.cipherId)); + break; + case EventType.Cipher_ClientToggledPasswordVisible: + msg = this.i18nService.t("viewedPasswordItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("viewedPasswordItemId", this.getShortId(ev.cipherId)); + break; + case EventType.Cipher_ClientToggledHiddenFieldVisible: + msg = this.i18nService.t("viewedHiddenFieldItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "viewedHiddenFieldItemId", + this.getShortId(ev.cipherId) + ); + break; + case EventType.Cipher_ClientToggledCardCodeVisible: + msg = this.i18nService.t("viewedSecurityCodeItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "viewedSecurityCodeItemId", + this.getShortId(ev.cipherId) + ); + break; + case EventType.Cipher_ClientCopiedHiddenField: + msg = this.i18nService.t("copiedHiddenFieldItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "copiedHiddenFieldItemId", + this.getShortId(ev.cipherId) + ); + break; + case EventType.Cipher_ClientCopiedPassword: + msg = this.i18nService.t("copiedPasswordItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("copiedPasswordItemId", this.getShortId(ev.cipherId)); + break; + case EventType.Cipher_ClientCopiedCardCode: + msg = this.i18nService.t("copiedSecurityCodeItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "copiedSecurityCodeItemId", + this.getShortId(ev.cipherId) + ); + break; + case EventType.Cipher_ClientAutofilled: + msg = this.i18nService.t("autofilledItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t("autofilledItemId", this.getShortId(ev.cipherId)); + break; + case EventType.Cipher_UpdatedCollections: + msg = this.i18nService.t("editedCollectionsForItem", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "editedCollectionsForItem", + this.getShortId(ev.cipherId) + ); + break; + // Collection + case EventType.Collection_Created: + msg = this.i18nService.t("createdCollectionId", this.formatCollectionId(ev)); + humanReadableMsg = this.i18nService.t( + "createdCollectionId", + this.getShortId(ev.collectionId) + ); + break; + case EventType.Collection_Updated: + msg = this.i18nService.t("editedCollectionId", this.formatCollectionId(ev)); + humanReadableMsg = this.i18nService.t( + "editedCollectionId", + this.getShortId(ev.collectionId) + ); + break; + case EventType.Collection_Deleted: + msg = this.i18nService.t("deletedCollectionId", this.formatCollectionId(ev)); + humanReadableMsg = this.i18nService.t( + "deletedCollectionId", + this.getShortId(ev.collectionId) + ); + break; + // Group + case EventType.Group_Created: + msg = this.i18nService.t("createdGroupId", this.formatGroupId(ev)); + humanReadableMsg = this.i18nService.t("createdGroupId", this.getShortId(ev.groupId)); + break; + case EventType.Group_Updated: + msg = this.i18nService.t("editedGroupId", this.formatGroupId(ev)); + humanReadableMsg = this.i18nService.t("editedGroupId", this.getShortId(ev.groupId)); + break; + case EventType.Group_Deleted: + msg = this.i18nService.t("deletedGroupId", this.formatGroupId(ev)); + humanReadableMsg = this.i18nService.t("deletedGroupId", this.getShortId(ev.groupId)); + break; + // Org user + case EventType.OrganizationUser_Invited: + msg = this.i18nService.t("invitedUserId", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "invitedUserId", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_Confirmed: + msg = this.i18nService.t("confirmedUserId", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "confirmedUserId", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_Updated: + msg = this.i18nService.t("editedUserId", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "editedUserId", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_Removed: + msg = this.i18nService.t("removedUserId", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "removedUserId", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_UpdatedGroups: + msg = this.i18nService.t("editedGroupsForUser", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "editedGroupsForUser", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_UnlinkedSso: + msg = this.i18nService.t("unlinkedSsoUser", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "unlinkedSsoUser", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_ResetPassword_Enroll: + msg = this.i18nService.t("eventEnrollPasswordReset", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "eventEnrollPasswordReset", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_ResetPassword_Withdraw: + msg = this.i18nService.t("eventWithdrawPasswordReset", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "eventWithdrawPasswordReset", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_AdminResetPassword: + msg = this.i18nService.t("eventAdminPasswordReset", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "eventAdminPasswordReset", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_ResetSsoLink: + msg = this.i18nService.t("eventResetSsoLink", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "eventResetSsoLink", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_FirstSsoLogin: + msg = this.i18nService.t("firstSsoLogin", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "firstSsoLogin", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_Revoked: + msg = this.i18nService.t("revokedUserId", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "revokedUserId", + this.getShortId(ev.organizationUserId) + ); + break; + case EventType.OrganizationUser_Restored: + msg = this.i18nService.t("restoredUserId", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "restoredUserId", + this.getShortId(ev.organizationUserId) + ); + break; + // Org + case EventType.Organization_Updated: + msg = humanReadableMsg = this.i18nService.t("editedOrgSettings"); + break; + case EventType.Organization_PurgedVault: + msg = humanReadableMsg = this.i18nService.t("purgedOrganizationVault"); + break; + case EventType.Organization_ClientExportedVault: + msg = humanReadableMsg = this.i18nService.t("exportedOrganizationVault"); + break; + case EventType.Organization_VaultAccessed: + msg = humanReadableMsg = this.i18nService.t("vaultAccessedByProvider"); + break; + case EventType.Organization_EnabledSso: + msg = humanReadableMsg = this.i18nService.t("enabledSso"); + break; + case EventType.Organization_DisabledSso: + msg = humanReadableMsg = this.i18nService.t("disabledSso"); + break; + case EventType.Organization_EnabledKeyConnector: + msg = humanReadableMsg = this.i18nService.t("enabledKeyConnector"); + break; + case EventType.Organization_DisabledKeyConnector: + msg = humanReadableMsg = this.i18nService.t("disabledKeyConnector"); + break; + case EventType.Organization_SponsorshipsSynced: + msg = humanReadableMsg = this.i18nService.t("sponsorshipsSynced"); + break; + // Policies + case EventType.Policy_Updated: { + msg = this.i18nService.t("modifiedPolicyId", this.formatPolicyId(ev)); + + const policies = await this.policyService.getAll(); + const policy = policies.filter((p) => p.id === ev.policyId)[0]; + let p1 = this.getShortId(ev.policyId); + if (policy != null) { + p1 = PolicyType[policy.type]; + } + + humanReadableMsg = this.i18nService.t("modifiedPolicyId", p1); + break; + } + // Provider users: + case EventType.ProviderUser_Invited: + msg = this.i18nService.t("invitedUserId", this.formatProviderUserId(ev)); + humanReadableMsg = this.i18nService.t("invitedUserId", this.getShortId(ev.providerUserId)); + break; + case EventType.ProviderUser_Confirmed: + msg = this.i18nService.t("confirmedUserId", this.formatProviderUserId(ev)); + humanReadableMsg = this.i18nService.t( + "confirmedUserId", + this.getShortId(ev.providerUserId) + ); + break; + case EventType.ProviderUser_Updated: + msg = this.i18nService.t("editedUserId", this.formatProviderUserId(ev)); + humanReadableMsg = this.i18nService.t("editedUserId", this.getShortId(ev.providerUserId)); + break; + case EventType.ProviderUser_Removed: + msg = this.i18nService.t("removedUserId", this.formatProviderUserId(ev)); + humanReadableMsg = this.i18nService.t("removedUserId", this.getShortId(ev.providerUserId)); + break; + case EventType.ProviderOrganization_Created: + msg = this.i18nService.t("createdOrganizationId", this.formatProviderOrganizationId(ev)); + humanReadableMsg = this.i18nService.t( + "createdOrganizationId", + this.getShortId(ev.providerOrganizationId) + ); + break; + case EventType.ProviderOrganization_Added: + msg = this.i18nService.t("addedOrganizationId", this.formatProviderOrganizationId(ev)); + humanReadableMsg = this.i18nService.t( + "addedOrganizationId", + this.getShortId(ev.providerOrganizationId) + ); + break; + case EventType.ProviderOrganization_Removed: + msg = this.i18nService.t("removedOrganizationId", this.formatProviderOrganizationId(ev)); + humanReadableMsg = this.i18nService.t( + "removedOrganizationId", + this.getShortId(ev.providerOrganizationId) + ); + break; + case EventType.ProviderOrganization_VaultAccessed: + msg = this.i18nService.t("accessedClientVault", this.formatProviderOrganizationId(ev)); + humanReadableMsg = this.i18nService.t( + "accessedClientVault", + this.getShortId(ev.providerOrganizationId) + ); + break; + default: + break; + } + return { + message: msg === "" ? null : msg, + humanReadableMessage: humanReadableMsg === "" ? null : humanReadableMsg, + }; + } + + private getAppInfo(deviceType: DeviceType): [string, string] { + switch (deviceType) { + case DeviceType.Android: + return ["bwi-android", this.i18nService.t("mobile") + " - Android"]; + case DeviceType.iOS: + return ["bwi-apple", this.i18nService.t("mobile") + " - iOS"]; + case DeviceType.UWP: + return ["bwi-windows", this.i18nService.t("mobile") + " - Windows"]; + case DeviceType.ChromeExtension: + return ["bwi-chrome", this.i18nService.t("extension") + " - Chrome"]; + case DeviceType.FirefoxExtension: + return ["bwi-firefox", this.i18nService.t("extension") + " - Firefox"]; + case DeviceType.OperaExtension: + return ["bwi-opera", this.i18nService.t("extension") + " - Opera"]; + case DeviceType.EdgeExtension: + return ["bwi-edge", this.i18nService.t("extension") + " - Edge"]; + case DeviceType.VivaldiExtension: + return ["bwi-puzzle", this.i18nService.t("extension") + " - Vivaldi"]; + case DeviceType.SafariExtension: + return ["bwi-safari", this.i18nService.t("extension") + " - Safari"]; + case DeviceType.WindowsDesktop: + return ["bwi-windows", this.i18nService.t("desktop") + " - Windows"]; + case DeviceType.MacOsDesktop: + return ["bwi-apple", this.i18nService.t("desktop") + " - macOS"]; + case DeviceType.LinuxDesktop: + return ["bwi-linux", this.i18nService.t("desktop") + " - Linux"]; + case DeviceType.ChromeBrowser: + return ["bwi-globe", this.i18nService.t("webVault") + " - Chrome"]; + case DeviceType.FirefoxBrowser: + return ["bwi-globe", this.i18nService.t("webVault") + " - Firefox"]; + case DeviceType.OperaBrowser: + return ["bwi-globe", this.i18nService.t("webVault") + " - Opera"]; + case DeviceType.SafariBrowser: + return ["bwi-globe", this.i18nService.t("webVault") + " - Safari"]; + case DeviceType.VivaldiBrowser: + return ["bwi-globe", this.i18nService.t("webVault") + " - Vivaldi"]; + case DeviceType.EdgeBrowser: + return ["bwi-globe", this.i18nService.t("webVault") + " - Edge"]; + case DeviceType.IEBrowser: + return ["bwi-globe", this.i18nService.t("webVault") + " - IE"]; + case DeviceType.UnknownBrowser: + return [ + "bwi-globe", + this.i18nService.t("webVault") + " - " + this.i18nService.t("unknown"), + ]; + default: + return ["bwi-globe", this.i18nService.t("unknown")]; + } + } + + private formatCipherId(ev: EventResponse, options: EventOptions) { + const shortId = this.getShortId(ev.cipherId); + if (ev.organizationId == null || !options.cipherInfo) { + return "" + shortId + ""; + } + const a = this.makeAnchor(shortId); + a.setAttribute( + "href", + "#/organizations/" + + ev.organizationId + + "/vault?search=" + + shortId + + "&viewEvents=" + + ev.cipherId + ); + return a.outerHTML; + } + + private formatGroupId(ev: EventResponse) { + const shortId = this.getShortId(ev.groupId); + const a = this.makeAnchor(shortId); + a.setAttribute( + "href", + "#/organizations/" + ev.organizationId + "/manage/groups?search=" + shortId + ); + return a.outerHTML; + } + + private formatCollectionId(ev: EventResponse) { + const shortId = this.getShortId(ev.collectionId); + const a = this.makeAnchor(shortId); + a.setAttribute( + "href", + "#/organizations/" + ev.organizationId + "/manage/collections?search=" + shortId + ); + return a.outerHTML; + } + + private formatOrgUserId(ev: EventResponse) { + const shortId = this.getShortId(ev.organizationUserId); + const a = this.makeAnchor(shortId); + a.setAttribute( + "href", + "#/organizations/" + + ev.organizationId + + "/manage/people?search=" + + shortId + + "&viewEvents=" + + ev.organizationUserId + ); + return a.outerHTML; + } + + private formatProviderUserId(ev: EventResponse) { + const shortId = this.getShortId(ev.providerUserId); + const a = this.makeAnchor(shortId); + a.setAttribute( + "href", + "#/providers/" + + ev.providerId + + "/manage/people?search=" + + shortId + + "&viewEvents=" + + ev.providerUserId + ); + return a.outerHTML; + } + + private formatProviderOrganizationId(ev: EventResponse) { + const shortId = this.getShortId(ev.providerOrganizationId); + const a = this.makeAnchor(shortId); + a.setAttribute("href", "#/providers/" + ev.providerId + "/clients?search=" + shortId); + return a.outerHTML; + } + + private formatPolicyId(ev: EventResponse) { + const shortId = this.getShortId(ev.policyId); + const a = this.makeAnchor(shortId); + a.setAttribute( + "href", + "#/organizations/" + ev.organizationId + "/manage/policies?policyId=" + ev.policyId + ); + return a.outerHTML; + } + + private makeAnchor(shortId: string) { + const a = document.createElement("a"); + a.title = this.i18nService.t("view"); + a.innerHTML = "" + shortId + ""; + return a; + } + + private getShortId(id: string) { + return id?.substring(0, 8); + } + + private toDateTimeLocalString(date: Date) { + return ( + date.getFullYear() + + "-" + + this.pad(date.getMonth() + 1) + + "-" + + this.pad(date.getDate()) + + "T" + + this.pad(date.getHours()) + + ":" + + this.pad(date.getMinutes()) + ); + } + + private pad(num: number) { + const norm = Math.floor(Math.abs(num)); + return (norm < 10 ? "0" : "") + norm; + } +} + +export class EventInfo { + message: string; + humanReadableMessage: string; + appIcon: string; + appName: string; +} + +export class EventOptions { + cipherInfo = true; +} diff --git a/apps/web/src/app/core/html-storage.service.ts b/apps/web/src/app/core/html-storage.service.ts new file mode 100644 index 00000000000..680051aa858 --- /dev/null +++ b/apps/web/src/app/core/html-storage.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from "@angular/core"; + +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { HtmlStorageLocation } from "@bitwarden/common/enums/htmlStorageLocation"; +import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; + +@Injectable() +export class HtmlStorageService implements AbstractStorageService { + get defaultOptions(): StorageOptions { + return { htmlStorageLocation: HtmlStorageLocation.Session }; + } + + get(key: string, options: StorageOptions = this.defaultOptions): Promise { + let json: string = null; + switch (options.htmlStorageLocation) { + case HtmlStorageLocation.Local: + json = window.localStorage.getItem(key); + break; + case HtmlStorageLocation.Session: + default: + json = window.sessionStorage.getItem(key); + break; + } + + if (json != null) { + const obj = JSON.parse(json); + return Promise.resolve(obj as T); + } + return Promise.resolve(null); + } + + async has(key: string, options: StorageOptions = this.defaultOptions): Promise { + return (await this.get(key, options)) != null; + } + + save(key: string, obj: any, options: StorageOptions = this.defaultOptions): Promise { + if (obj == null) { + return this.remove(key, options); + } + + if (obj instanceof Set) { + obj = Array.from(obj); + } + + const json = JSON.stringify(obj); + switch (options.htmlStorageLocation) { + case HtmlStorageLocation.Local: + window.localStorage.setItem(key, json); + break; + case HtmlStorageLocation.Session: + default: + window.sessionStorage.setItem(key, json); + break; + } + return Promise.resolve(); + } + + remove(key: string, options: StorageOptions = this.defaultOptions): Promise { + switch (options.htmlStorageLocation) { + case HtmlStorageLocation.Local: + window.localStorage.removeItem(key); + break; + case HtmlStorageLocation.Session: + default: + window.sessionStorage.removeItem(key); + break; + } + return Promise.resolve(); + } +} diff --git a/apps/web/src/app/core/i18n.service.ts b/apps/web/src/app/core/i18n.service.ts new file mode 100644 index 00000000000..5d6d40191b8 --- /dev/null +++ b/apps/web/src/app/core/i18n.service.ts @@ -0,0 +1,72 @@ +import { I18nService as BaseI18nService } from "@bitwarden/common/services/i18n.service"; + +export class I18nService extends BaseI18nService { + constructor(systemLanguage: string, localesDirectory: string) { + super(systemLanguage || "en-US", localesDirectory, async (formattedLocale: string) => { + const filePath = + this.localesDirectory + + "/" + + formattedLocale + + "/messages.json?cache=" + + process.env.CACHE_TAG; + const localesResult = await fetch(filePath); + const locales = await localesResult.json(); + return locales; + }); + + // Please leave 'en' where it is, as it's our fallback language in case no translation can be found + this.supportedTranslationLocales = [ + "en", + "af", + "az", + "be", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en-GB", + "en-IN", + "eo", + "es", + "et", + "fi", + "fil", + "fr", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ka", + "km", + "kn", + "ko", + "lv", + "ml", + "nb", + "nl", + "nn", + "pl", + "pt-PT", + "pt-BR", + "ro", + "ru", + "si", + "sk", + "sl", + "sr", + "sv", + "tr", + "uk", + "vi", + "zh-CN", + "zh-TW", + ]; + } +} diff --git a/apps/web/src/app/core/index.ts b/apps/web/src/app/core/index.ts new file mode 100644 index 00000000000..80c1a44d50f --- /dev/null +++ b/apps/web/src/app/core/index.ts @@ -0,0 +1,5 @@ +export * from "./core.module"; +export * from "./event.service"; +export * from "./policy-list.service"; +export * from "./router.service"; +export * from "./state/state.service"; diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts new file mode 100644 index 00000000000..940ee501685 --- /dev/null +++ b/apps/web/src/app/core/init.service.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable } from "@angular/core"; + +import { WINDOW } from "@bitwarden/angular/services/jslib-services.module"; +import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; +import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/abstractions/crypto.service"; +import { + EnvironmentService as EnvironmentServiceAbstraction, + Urls, +} from "@bitwarden/common/abstractions/environment.service"; +import { EventService as EventLoggingServiceAbstraction } from "@bitwarden/common/abstractions/event.service"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service"; +import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; +import { StateService as StateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; +import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/abstractions/twoFactor.service"; +import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout.service"; +import { ContainerService } from "@bitwarden/common/services/container.service"; +import { EventService as EventLoggingService } from "@bitwarden/common/services/event.service"; +import { VaultTimeoutService as VaultTimeoutService } from "@bitwarden/common/services/vaultTimeout.service"; + +import { I18nService } from "./i18n.service"; + +@Injectable() +export class InitService { + constructor( + @Inject(WINDOW) private win: Window, + private environmentService: EnvironmentServiceAbstraction, + private notificationsService: NotificationsServiceAbstraction, + private vaultTimeoutService: VaultTimeoutServiceAbstraction, + private i18nService: I18nServiceAbstraction, + private eventLoggingService: EventLoggingServiceAbstraction, + private twoFactorService: TwoFactorServiceAbstraction, + private stateService: StateServiceAbstraction, + private cryptoService: CryptoServiceAbstraction, + private themingService: AbstractThemingService + ) {} + + init() { + return async () => { + await this.stateService.init(); + + const urls = process.env.URLS as Urls; + urls.base ??= this.win.location.origin; + this.environmentService.setUrls(urls); + + setTimeout(() => this.notificationsService.init(), 3000); + (this.vaultTimeoutService as VaultTimeoutService).init(true); + const locale = await this.stateService.getLocale(); + await (this.i18nService as I18nService).init(locale); + (this.eventLoggingService as EventLoggingService).init(true); + this.twoFactorService.init(); + const htmlEl = this.win.document.documentElement; + htmlEl.classList.add("locale_" + this.i18nService.translationLocale); + await this.themingService.monitorThemeChanges(); + const containerService = new ContainerService(this.cryptoService); + containerService.attachToGlobal(this.win); + }; + } +} diff --git a/apps/web/src/app/core/modal.service.ts b/apps/web/src/app/core/modal.service.ts new file mode 100644 index 00000000000..19c54a23bce --- /dev/null +++ b/apps/web/src/app/core/modal.service.ts @@ -0,0 +1,58 @@ +import { ApplicationRef, ComponentFactoryResolver, Injectable, Injector } from "@angular/core"; +import * as jq from "jquery"; +import { first } from "rxjs/operators"; + +import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; +import { ModalService as BaseModalService } from "@bitwarden/angular/services/modal.service"; +import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { Utils } from "@bitwarden/common/misc/utils"; + +@Injectable() +export class ModalService extends BaseModalService { + el: any = null; + modalOpen = false; + + constructor( + componentFactoryResolver: ComponentFactoryResolver, + applicationRef: ApplicationRef, + injector: Injector, + private messagingService: MessagingService + ) { + super(componentFactoryResolver, applicationRef, injector); + } + + protected setupHandlers(modalRef: ModalRef) { + modalRef.onCreated.pipe(first()).subscribe(() => { + const modals = Array.from(document.querySelectorAll(".modal")); + if (modals.length > 0) { + this.el = jq(modals[0]); + this.el.modal("show"); + + this.el.on("show.bs.modal", () => { + modalRef.show(); + this.messagingService.send("modalShow"); + }); + this.el.on("shown.bs.modal", () => { + modalRef.shown(); + this.messagingService.send("modalShown"); + if (!Utils.isMobileBrowser) { + this.el.find("*[appAutoFocus]").focus(); + } + }); + this.el.on("hide.bs.modal", () => { + this.messagingService.send("modalClose"); + }); + this.el.on("hidden.bs.modal", () => { + modalRef.closed(); + this.messagingService.send("modalClosed"); + }); + } + }); + + modalRef.onClose.pipe(first()).subscribe(() => { + if (this.el != null) { + this.el.modal("hide"); + } + }); + } +} diff --git a/apps/web/src/app/core/password-reprompt.service.ts b/apps/web/src/app/core/password-reprompt.service.ts new file mode 100644 index 00000000000..fdd176e42d2 --- /dev/null +++ b/apps/web/src/app/core/password-reprompt.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from "@angular/core"; + +import { PasswordRepromptService as BasePasswordRepromptService } from "@bitwarden/angular/services/passwordReprompt.service"; + +import { PasswordRepromptComponent } from "../components/password-reprompt.component"; + +@Injectable() +export class PasswordRepromptService extends BasePasswordRepromptService { + component = PasswordRepromptComponent; +} diff --git a/apps/web/src/app/core/policy-list.service.ts b/apps/web/src/app/core/policy-list.service.ts new file mode 100644 index 00000000000..70857ef8196 --- /dev/null +++ b/apps/web/src/app/core/policy-list.service.ts @@ -0,0 +1,13 @@ +import { BasePolicy } from "../organizations/policies/base-policy.component"; + +export class PolicyListService { + private policies: BasePolicy[] = []; + + addPolicies(policies: BasePolicy[]) { + this.policies.push(...policies); + } + + getPolicies(): BasePolicy[] { + return this.policies; + } +} diff --git a/apps/web/src/app/core/router.service.ts b/apps/web/src/app/core/router.service.ts new file mode 100644 index 00000000000..aa9041875a1 --- /dev/null +++ b/apps/web/src/app/core/router.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from "@angular/core"; +import { Title } from "@angular/platform-browser"; +import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; +import { filter } from "rxjs"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; + +@Injectable() +export class RouterService { + private previousUrl: string = undefined; + private currentUrl: string = undefined; + + constructor( + private router: Router, + private activatedRoute: ActivatedRoute, + private titleService: Title, + i18nService: I18nService + ) { + this.currentUrl = this.router.url; + + router.events + .pipe(filter((e) => e instanceof NavigationEnd)) + .subscribe((event: NavigationEnd) => { + this.currentUrl = event.url; + + let title = i18nService.t("pageTitle", "Bitwarden"); + let child = this.activatedRoute.firstChild; + while (child.firstChild) { + child = child.firstChild; + } + + const titleId: string = child?.snapshot?.data?.titleId; + const rawTitle: string = child?.snapshot?.data?.title; + const updateUrl = !child?.snapshot?.data?.doNotSaveUrl ?? true; + + if (titleId != null || rawTitle != null) { + const newTitle = rawTitle != null ? rawTitle : i18nService.t(titleId); + if (newTitle != null && newTitle !== "") { + title = newTitle + " | " + title; + } + } + this.titleService.setTitle(title); + if (updateUrl) { + this.setPreviousUrl(this.currentUrl); + } + }); + } + + getPreviousUrl() { + return this.previousUrl; + } + + setPreviousUrl(url: string) { + this.previousUrl = url; + } +} diff --git a/apps/web/src/app/core/state-migration.service.ts b/apps/web/src/app/core/state-migration.service.ts new file mode 100644 index 00000000000..0c0c0ad6821 --- /dev/null +++ b/apps/web/src/app/core/state-migration.service.ts @@ -0,0 +1,13 @@ +import { StateMigrationService as BaseStateMigrationService } from "@bitwarden/common/services/stateMigration.service"; + +import { Account } from "./state/account"; +import { GlobalState } from "./state/global-state"; + +export class StateMigrationService extends BaseStateMigrationService { + protected async migrationStateFrom1To2(): Promise { + await super.migrateStateFrom1To2(); + const globals = (await this.get("global")) ?? this.stateFactory.createGlobal(null); + globals.rememberEmail = (await this.get("rememberEmail")) ?? globals.rememberEmail; + await this.set("global", globals); + } +} diff --git a/apps/web/src/app/core/state/account.ts b/apps/web/src/app/core/state/account.ts new file mode 100644 index 00000000000..8403e91547f --- /dev/null +++ b/apps/web/src/app/core/state/account.ts @@ -0,0 +1,20 @@ +import { + Account as BaseAccount, + AccountSettings as BaseAccountSettings, +} from "@bitwarden/common/models/domain/account"; + +export class AccountSettings extends BaseAccountSettings { + vaultTimeout: number = process.env.NODE_ENV === "development" ? null : 15; +} + +export class Account extends BaseAccount { + settings?: AccountSettings = new AccountSettings(); + + constructor(init: Partial) { + super(init); + Object.assign(this.settings, { + ...new AccountSettings(), + ...this.settings, + }); + } +} diff --git a/apps/web/src/app/core/state/global-state.ts b/apps/web/src/app/core/state/global-state.ts new file mode 100644 index 00000000000..15fc03876f2 --- /dev/null +++ b/apps/web/src/app/core/state/global-state.ts @@ -0,0 +1,7 @@ +import { ThemeType } from "@bitwarden/common/enums/themeType"; +import { GlobalState as BaseGlobalState } from "@bitwarden/common/models/domain/globalState"; + +export class GlobalState extends BaseGlobalState { + theme?: ThemeType = ThemeType.Light; + rememberEmail = true; +} diff --git a/apps/web/src/app/core/state/index.ts b/apps/web/src/app/core/state/index.ts new file mode 100644 index 00000000000..dd968df3a2e --- /dev/null +++ b/apps/web/src/app/core/state/index.ts @@ -0,0 +1,3 @@ +export * from "./account"; +export * from "./global-state"; +export * from "./state.service"; diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts new file mode 100644 index 00000000000..074ffa44538 --- /dev/null +++ b/apps/web/src/app/core/state/state.service.ts @@ -0,0 +1,131 @@ +import { Inject, Injectable } from "@angular/core"; + +import { + MEMORY_STORAGE, + SECURE_STORAGE, + STATE_FACTORY, + STATE_SERVICE_USE_CACHE, +} from "@bitwarden/angular/services/jslib-services.module"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { StateMigrationService } from "@bitwarden/common/abstractions/stateMigration.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { StateFactory } from "@bitwarden/common/factories/stateFactory"; +import { CipherData } from "@bitwarden/common/models/data/cipherData"; +import { CollectionData } from "@bitwarden/common/models/data/collectionData"; +import { FolderData } from "@bitwarden/common/models/data/folderData"; +import { SendData } from "@bitwarden/common/models/data/sendData"; +import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; +import { StateService as BaseStateService } from "@bitwarden/common/services/state.service"; + +import { Account } from "./account"; +import { GlobalState } from "./global-state"; + +@Injectable() +export class StateService extends BaseStateService { + constructor( + storageService: AbstractStorageService, + @Inject(SECURE_STORAGE) secureStorageService: AbstractStorageService, + @Inject(MEMORY_STORAGE) memoryStorageService: AbstractStorageService, + logService: LogService, + stateMigrationService: StateMigrationService, + @Inject(STATE_FACTORY) stateFactory: StateFactory, + @Inject(STATE_SERVICE_USE_CACHE) useAccountCache = true + ) { + super( + storageService, + secureStorageService, + memoryStorageService, + logService, + stateMigrationService, + stateFactory, + useAccountCache + ); + } + + async addAccount(account: Account) { + // Apply web overrides to default account values + account = new Account(account); + await super.addAccount(account); + } + + async getRememberEmail(options?: StorageOptions) { + return ( + await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) + )?.rememberEmail; + } + + async setRememberEmail(value: boolean, options?: StorageOptions): Promise { + const globals = await this.getGlobals( + this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) + ); + globals.rememberEmail = value; + await this.saveGlobals( + globals, + this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) + ); + } + + async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.getEncryptedCiphers(options); + } + + async setEncryptedCiphers( + value: { [id: string]: CipherData }, + options?: StorageOptions + ): Promise { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.setEncryptedCiphers(value, options); + } + + async getEncryptedCollections( + options?: StorageOptions + ): Promise<{ [id: string]: CollectionData }> { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.getEncryptedCollections(options); + } + + async setEncryptedCollections( + value: { [id: string]: CollectionData }, + options?: StorageOptions + ): Promise { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.setEncryptedCollections(value, options); + } + + async getEncryptedFolders(options?: StorageOptions): Promise<{ [id: string]: FolderData }> { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.getEncryptedFolders(options); + } + + async setEncryptedFolders( + value: { [id: string]: FolderData }, + options?: StorageOptions + ): Promise { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.setEncryptedFolders(value, options); + } + + async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.getEncryptedSends(options); + } + + async setEncryptedSends( + value: { [id: string]: SendData }, + options?: StorageOptions + ): Promise { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.setEncryptedSends(value, options); + } + + override async getLastSync(options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.getLastSync(options); + } + + override async setLastSync(value: string, options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); + return await super.setLastSync(value, options); + } +} diff --git a/apps/web/src/app/core/web-file-download.service.ts b/apps/web/src/app/core/web-file-download.service.ts new file mode 100644 index 00000000000..de1626c8df1 --- /dev/null +++ b/apps/web/src/app/core/web-file-download.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@angular/core"; + +import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; +import { FileDownloadBuilder } from "@bitwarden/common/abstractions/fileDownload/fileDownloadBuilder"; +import { FileDownloadRequest } from "@bitwarden/common/abstractions/fileDownload/fileDownloadRequest"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; + +@Injectable() +export class WebFileDownloadService implements FileDownloadService { + constructor(private platformUtilsService: PlatformUtilsService) {} + + download(request: FileDownloadRequest): void { + const builder = new FileDownloadBuilder(request); + const a = window.document.createElement("a"); + if (builder.downloadMethod === "save") { + a.download = request.fileName; + } else if (!this.platformUtilsService.isSafari()) { + a.target = "_blank"; + } + a.href = URL.createObjectURL(builder.blob); + a.style.position = "fixed"; + window.document.body.appendChild(a); + a.click(); + window.document.body.removeChild(a); + } +} diff --git a/apps/web/src/app/core/web-platform-utils.service.ts b/apps/web/src/app/core/web-platform-utils.service.ts new file mode 100644 index 00000000000..115d53401a9 --- /dev/null +++ b/apps/web/src/app/core/web-platform-utils.service.ts @@ -0,0 +1,254 @@ +import { Injectable } from "@angular/core"; +import Swal, { SweetAlertIcon } from "sweetalert2"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { ClientType } from "@bitwarden/common/enums/clientType"; +import { DeviceType } from "@bitwarden/common/enums/deviceType"; + +@Injectable() +export class WebPlatformUtilsService implements PlatformUtilsService { + private browserCache: DeviceType = null; + + constructor( + private i18nService: I18nService, + private messagingService: MessagingService, + private logService: LogService + ) {} + + getDevice(): DeviceType { + if (this.browserCache != null) { + return this.browserCache; + } + + if ( + navigator.userAgent.indexOf(" Firefox/") !== -1 || + navigator.userAgent.indexOf(" Gecko/") !== -1 + ) { + this.browserCache = DeviceType.FirefoxBrowser; + } else if (navigator.userAgent.indexOf(" OPR/") >= 0) { + this.browserCache = DeviceType.OperaBrowser; + } else if (navigator.userAgent.indexOf(" Edg/") !== -1) { + this.browserCache = DeviceType.EdgeBrowser; + } else if (navigator.userAgent.indexOf(" Vivaldi/") !== -1) { + this.browserCache = DeviceType.VivaldiBrowser; + } else if ( + navigator.userAgent.indexOf(" Safari/") !== -1 && + navigator.userAgent.indexOf("Chrome") === -1 + ) { + this.browserCache = DeviceType.SafariBrowser; + } else if ((window as any).chrome && navigator.userAgent.indexOf(" Chrome/") !== -1) { + this.browserCache = DeviceType.ChromeBrowser; + } else if (navigator.userAgent.indexOf(" Trident/") !== -1) { + this.browserCache = DeviceType.IEBrowser; + } else { + this.browserCache = DeviceType.UnknownBrowser; + } + + return this.browserCache; + } + + getDeviceString(): string { + const device = DeviceType[this.getDevice()].toLowerCase(); + return device.replace("browser", ""); + } + + getClientType() { + return ClientType.Web; + } + + isFirefox(): boolean { + return this.getDevice() === DeviceType.FirefoxBrowser; + } + + isChrome(): boolean { + return this.getDevice() === DeviceType.ChromeBrowser; + } + + isEdge(): boolean { + return this.getDevice() === DeviceType.EdgeBrowser; + } + + isOpera(): boolean { + return this.getDevice() === DeviceType.OperaBrowser; + } + + isVivaldi(): boolean { + return this.getDevice() === DeviceType.VivaldiBrowser; + } + + isSafari(): boolean { + return this.getDevice() === DeviceType.SafariBrowser; + } + + isMacAppStore(): boolean { + return false; + } + + isViewOpen(): Promise { + return Promise.resolve(false); + } + + launchUri(uri: string, options?: any): void { + const a = document.createElement("a"); + a.href = uri; + if (options == null || !options.sameWindow) { + a.target = "_blank"; + a.rel = "noreferrer noopener"; + } + a.classList.add("d-none"); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + + getApplicationVersion(): Promise { + return Promise.resolve(process.env.APPLICATION_VERSION || "-"); + } + + supportsWebAuthn(win: Window): boolean { + return typeof PublicKeyCredential !== "undefined"; + } + + supportsDuo(): boolean { + return true; + } + + showToast( + type: "error" | "success" | "warning" | "info", + title: string, + text: string | string[], + options?: any + ): void { + this.messagingService.send("showToast", { + text: text, + title: title, + type: type, + options: options, + }); + } + + async showDialog( + body: string, + title?: string, + confirmText?: string, + cancelText?: string, + type?: string, + bodyIsHtml = false + ) { + let iconClasses: string = null; + if (type != null) { + // If you add custom types to this part, the type to SweetAlertIcon cast below needs to be changed. + switch (type) { + case "success": + iconClasses = "bwi-check text-success"; + break; + case "warning": + iconClasses = "bwi-exclamation-triangle text-warning"; + break; + case "error": + iconClasses = "bwi-error text-danger"; + break; + case "info": + iconClasses = "bwi-info-circle text-info"; + break; + default: + break; + } + } + + const bootstrapModal = document.querySelector("div.modal"); + if (bootstrapModal != null) { + bootstrapModal.removeAttribute("tabindex"); + } + + const iconHtmlStr = + iconClasses != null ? `` : undefined; + const confirmed = await Swal.fire({ + heightAuto: false, + buttonsStyling: false, + icon: type as SweetAlertIcon, // required to be any of the SweetAlertIcons to output the iconHtml. + iconHtml: iconHtmlStr, + text: bodyIsHtml ? null : body, + html: bodyIsHtml ? body : null, + titleText: title, + showCancelButton: cancelText != null, + cancelButtonText: cancelText, + showConfirmButton: true, + confirmButtonText: confirmText == null ? this.i18nService.t("ok") : confirmText, + }); + + if (bootstrapModal != null) { + bootstrapModal.setAttribute("tabindex", "-1"); + } + + return confirmed.value; + } + + isDev(): boolean { + return process.env.NODE_ENV === "development"; + } + + isSelfHost(): boolean { + return process.env.ENV.toString() === "selfhosted"; + } + + copyToClipboard(text: string, options?: any): void | boolean { + let win = window; + let doc = window.document; + if (options && (options.window || options.win)) { + win = options.window || options.win; + doc = win.document; + } else if (options && options.doc) { + doc = options.doc; + } + if ((win as any).clipboardData && (win as any).clipboardData.setData) { + // IE specific code path to prevent textarea being shown while dialog is visible. + (win as any).clipboardData.setData("Text", text); + } else if (doc.queryCommandSupported && doc.queryCommandSupported("copy")) { + const textarea = doc.createElement("textarea"); + textarea.textContent = text; + // Prevent scrolling to bottom of page in MS Edge. + textarea.style.position = "fixed"; + let copyEl = doc.body; + // For some reason copy command won't work when modal is open if appending to body + if (doc.body.classList.contains("modal-open")) { + copyEl = doc.body.querySelector(".modal"); + } + copyEl.appendChild(textarea); + textarea.select(); + let success = false; + try { + // Security exception may be thrown by some browsers. + success = doc.execCommand("copy"); + if (!success) { + this.logService.debug("Copy command unsupported or disabled."); + } + } catch (e) { + // eslint-disable-next-line + console.warn("Copy to clipboard failed.", e); + } finally { + copyEl.removeChild(textarea); + } + return success; + } + } + + readFromClipboard(options?: any): Promise { + throw new Error("Cannot read from clipboard on web."); + } + + supportsBiometric() { + return Promise.resolve(false); + } + + authenticateBiometric() { + return Promise.resolve(false); + } + + supportsSecureStorage() { + return false; + } +} diff --git a/apps/web/src/app/organizations/manage/bulk/bulk-restore-revoke.component.html b/apps/web/src/app/organizations/manage/bulk/bulk-restore-revoke.component.html new file mode 100644 index 00000000000..e02ee93e885 --- /dev/null +++ b/apps/web/src/app/organizations/manage/bulk/bulk-restore-revoke.component.html @@ -0,0 +1,102 @@ + diff --git a/apps/web/src/app/organizations/manage/bulk/bulk-restore-revoke.component.ts b/apps/web/src/app/organizations/manage/bulk/bulk-restore-revoke.component.ts new file mode 100644 index 00000000000..fa0a916ef05 --- /dev/null +++ b/apps/web/src/app/organizations/manage/bulk/bulk-restore-revoke.component.ts @@ -0,0 +1,66 @@ +import { Component } from "@angular/core"; + +import { ModalConfig } from "@bitwarden/angular/services/modal.service"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { OrganizationUserBulkRequest } from "@bitwarden/common/models/request/organizationUserBulkRequest"; + +import { BulkUserDetails } from "./bulk-status.component"; + +@Component({ + selector: "app-bulk-restore-revoke", + templateUrl: "bulk-restore-revoke.component.html", +}) +export class BulkRestoreRevokeComponent { + isRevoking: boolean; + organizationId: string; + users: BulkUserDetails[]; + + statuses: Map = new Map(); + + loading = false; + done = false; + error: string; + + constructor( + protected apiService: ApiService, + protected i18nService: I18nService, + config: ModalConfig + ) { + this.isRevoking = config.data.isRevoking; + this.organizationId = config.data.organizationId; + this.users = config.data.users; + } + + get bulkTitle() { + const titleKey = this.isRevoking ? "revokeUsers" : "restoreUsers"; + return this.i18nService.t(titleKey); + } + + async submit() { + this.loading = true; + try { + const response = await this.performBulkUserAction(); + + const bulkMessage = this.isRevoking ? "bulkRevokedMessage" : "bulkRestoredMessage"; + response.data.forEach((entry) => { + const error = entry.error !== "" ? entry.error : this.i18nService.t(bulkMessage); + this.statuses.set(entry.id, error); + }); + this.done = true; + } catch (e) { + this.error = e.message; + } + + this.loading = false; + } + + protected async performBulkUserAction() { + const request = new OrganizationUserBulkRequest(this.users.map((user) => user.id)); + if (this.isRevoking) { + return await this.apiService.revokeManyOrganizationUsers(this.organizationId, request); + } else { + return await this.apiService.restoreManyOrganizationUsers(this.organizationId, request); + } + } +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 00000000000..01b054780ad --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,17 @@ +import { enableProdMode } from "@angular/core"; +import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; + +import "bootstrap"; +import "jquery"; +import "popper.js"; + +require("./scss/styles.scss"); +require("./scss/tailwind.css"); + +import { AppModule } from "./app/app.module"; + +if (process.env.NODE_ENV === "production") { + enableProdMode(); +} + +platformBrowserDynamic().bootstrapModule(AppModule, { preserveWhitespaces: true }); diff --git a/apps/web/src/polyfills.ts b/apps/web/src/polyfills.ts new file mode 100644 index 00000000000..0691f05659b --- /dev/null +++ b/apps/web/src/polyfills.ts @@ -0,0 +1,15 @@ +import "core-js/stable"; +require("zone.js/dist/zone"); + +if (process.env.NODE_ENV === "production") { + // Production +} else { + // Development and test + Error["stackTraceLimit"] = Infinity; + require("zone.js/dist/long-stack-trace-zone"); +} + +// Other polyfills +require("whatwg-fetch"); +require("webcrypto-shim"); +require("date-input-polyfill"); diff --git a/bitwarden_license/bit-web/src/main.ts b/bitwarden_license/bit-web/src/main.ts new file mode 100644 index 00000000000..aa0e9d46977 --- /dev/null +++ b/bitwarden_license/bit-web/src/main.ts @@ -0,0 +1,17 @@ +import { enableProdMode } from "@angular/core"; +import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; + +import "bootstrap"; +import "jquery"; +import "popper.js"; + +require("src/scss/styles.scss"); +require("src/scss/tailwind.css"); + +import { AppModule } from "./app/app.module"; + +if (process.env.NODE_ENV === "production") { + enableProdMode(); +} + +platformBrowserDynamic().bootstrapModule(AppModule, { preserveWhitespaces: true }); diff --git a/libs/angular/src/shared/components/password-strength/password-strength.component.html b/libs/angular/src/shared/components/password-strength/password-strength.component.html new file mode 100644 index 00000000000..c9eec73899b --- /dev/null +++ b/libs/angular/src/shared/components/password-strength/password-strength.component.html @@ -0,0 +1,14 @@ +
+
+ + {{ text }} + +
+
diff --git a/libs/angular/src/shared/components/password-strength/password-strength.component.ts b/libs/angular/src/shared/components/password-strength/password-strength.component.ts new file mode 100644 index 00000000000..8630099d65e --- /dev/null +++ b/libs/angular/src/shared/components/password-strength/password-strength.component.ts @@ -0,0 +1,133 @@ +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; + +export interface PasswordColorText { + color: string; + text: string; +} + +@Component({ + selector: "app-password-strength", + templateUrl: "password-strength.component.html", +}) +export class PasswordStrengthComponent implements OnChanges { + @Input() showText = false; + @Input() email: string; + @Input() password: string; + @Input() name: string; + + @Output() passwordStrengthResult = new EventEmitter(); + @Output() passwordScoreColor = new EventEmitter(); + + masterPasswordScore: number; + scoreWidth = 0; + color = "bg-danger"; + text: string; + + private masterPasswordStrengthTimeout: any; + + //used by desktop and browser to display strength text color + get masterPasswordScoreColor() { + switch (this.masterPasswordScore) { + case 4: + return "success"; + case 3: + return "primary"; + case 2: + return "warning"; + default: + return "danger"; + } + } + + //used by desktop and browser to display strength text + get masterPasswordScoreText() { + switch (this.masterPasswordScore) { + case 4: + return this.i18nService.t("strong"); + case 3: + return this.i18nService.t("good"); + case 2: + return this.i18nService.t("weak"); + default: + return this.masterPasswordScore != null ? this.i18nService.t("weak") : null; + } + } + + constructor( + private i18nService: I18nService, + private passwordGenerationService: PasswordGenerationService + ) {} + + ngOnChanges(changes: SimpleChanges): void { + this.masterPasswordStrengthTimeout = setTimeout(() => { + this.updatePasswordStrength(changes.password?.currentValue); + + this.scoreWidth = this.masterPasswordScore == null ? 0 : (this.masterPasswordScore + 1) * 20; + + switch (this.masterPasswordScore) { + case 4: + this.color = "bg-success"; + this.text = this.i18nService.t("strong"); + break; + case 3: + this.color = "bg-primary"; + this.text = this.i18nService.t("good"); + break; + case 2: + this.color = "bg-warning"; + this.text = this.i18nService.t("weak"); + break; + default: + this.color = "bg-danger"; + this.text = this.masterPasswordScore != null ? this.i18nService.t("weak") : null; + break; + } + + this.setPasswordScoreText(this.color, this.text); + }, 100); + } + + updatePasswordStrength(password: string) { + const masterPassword = password; + + if (this.masterPasswordStrengthTimeout != null) { + clearTimeout(this.masterPasswordStrengthTimeout); + } + + const strengthResult = this.passwordGenerationService.passwordStrength( + masterPassword, + this.getPasswordStrengthUserInput() + ); + this.passwordStrengthResult.emit(strengthResult); + + this.masterPasswordScore = strengthResult == null ? null : strengthResult.score; + } + + getPasswordStrengthUserInput() { + let userInput: string[] = []; + const email = this.email; + const name = this.name; + const atPosition = email.indexOf("@"); + if (atPosition > -1) { + userInput = userInput.concat( + email + .substr(0, atPosition) + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + ); + } + if (name != null && name !== "") { + userInput = userInput.concat(name.trim().toLowerCase().split(" ")); + } + return userInput; + } + + setPasswordScoreText(color: string, text: string) { + color = color.slice(3); + this.passwordScoreColor.emit({ color: color, text: text }); + } +} diff --git a/libs/common/spec/domain/encArrayBuffer.spec.ts b/libs/common/spec/domain/encArrayBuffer.spec.ts new file mode 100644 index 00000000000..fb363937072 --- /dev/null +++ b/libs/common/spec/domain/encArrayBuffer.spec.ts @@ -0,0 +1,76 @@ +import { EncryptionType } from "@bitwarden/common/enums/encryptionType"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; + +import { makeStaticByteArray } from "../utils"; + +describe("encArrayBuffer", () => { + describe("parses the buffer", () => { + test.each([ + [EncryptionType.AesCbc128_HmacSha256_B64, "AesCbc128_HmacSha256_B64"], + [EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"], + ])("with %c%s", (encType: EncryptionType) => { + const iv = makeStaticByteArray(16, 10); + const mac = makeStaticByteArray(32, 20); + // We use the minimum data length of 1 to test the boundary of valid lengths + const data = makeStaticByteArray(1, 100); + + const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength); + array.set([encType]); + array.set(iv, 1); + array.set(mac, 1 + iv.byteLength); + array.set(data, 1 + iv.byteLength + mac.byteLength); + + const actual = new EncArrayBuffer(array.buffer); + + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.macBytes).toEqualBuffer(mac); + expect(actual.dataBytes).toEqualBuffer(data); + }); + + it("with AesCbc256_B64", () => { + const encType = EncryptionType.AesCbc256_B64; + const iv = makeStaticByteArray(16, 10); + // We use the minimum data length of 1 to test the boundary of valid lengths + const data = makeStaticByteArray(1, 100); + + const array = new Uint8Array(1 + iv.byteLength + data.byteLength); + array.set([encType]); + array.set(iv, 1); + array.set(data, 1 + iv.byteLength); + + const actual = new EncArrayBuffer(array.buffer); + + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.dataBytes).toEqualBuffer(data); + expect(actual.macBytes).toBeNull(); + }); + }); + + describe("throws if the buffer has an invalid length", () => { + test.each([ + [EncryptionType.AesCbc128_HmacSha256_B64, 50, "AesCbc128_HmacSha256_B64"], + [EncryptionType.AesCbc256_HmacSha256_B64, 50, "AesCbc256_HmacSha256_B64"], + [EncryptionType.AesCbc256_B64, 18, "AesCbc256_B64"], + ])("with %c%c%s", (encType: EncryptionType, minLength: number) => { + // Generate invalid byte array + // Minus 1 to leave room for the encType, minus 1 to make it invalid + const invalidBytes = makeStaticByteArray(minLength - 2); + + const invalidArray = new Uint8Array(1 + invalidBytes.buffer.byteLength); + invalidArray.set([encType]); + invalidArray.set(invalidBytes, 1); + + expect(() => new EncArrayBuffer(invalidArray.buffer)).toThrow( + "Error parsing encrypted ArrayBuffer" + ); + }); + }); + + it("doesn't parse the buffer if the encryptionType is not supported", () => { + // Starting at 9 implicitly gives us an invalid encType + const bytes = makeStaticByteArray(50, 9); + expect(() => new EncArrayBuffer(bytes)).toThrow("Error parsing encrypted ArrayBuffer"); + }); +}); diff --git a/libs/common/spec/matchers/toEqualBuffer.spec.ts b/libs/common/spec/matchers/toEqualBuffer.spec.ts new file mode 100644 index 00000000000..ccf5742365c --- /dev/null +++ b/libs/common/spec/matchers/toEqualBuffer.spec.ts @@ -0,0 +1,25 @@ +import { makeStaticByteArray } from "../utils"; + +describe("toEqualBuffer custom matcher", () => { + it("matches identical ArrayBuffers", () => { + const array = makeStaticByteArray(10); + expect(array.buffer).toEqualBuffer(array.buffer); + }); + + it("matches an identical ArrayBuffer and Uint8Array", () => { + const array = makeStaticByteArray(10); + expect(array.buffer).toEqualBuffer(array); + }); + + it("doesn't match different ArrayBuffers", () => { + const array1 = makeStaticByteArray(10); + const array2 = makeStaticByteArray(10, 11); + expect(array1.buffer).not.toEqualBuffer(array2.buffer); + }); + + it("doesn't match a different ArrayBuffer and Uint8Array", () => { + const array1 = makeStaticByteArray(10); + const array2 = makeStaticByteArray(10, 11); + expect(array1.buffer).not.toEqualBuffer(array2); + }); +}); diff --git a/libs/common/spec/matchers/toEqualBuffer.ts b/libs/common/spec/matchers/toEqualBuffer.ts new file mode 100644 index 00000000000..fb8ae09492e --- /dev/null +++ b/libs/common/spec/matchers/toEqualBuffer.ts @@ -0,0 +1,34 @@ +/** + * The inbuilt toEqual() matcher will always return TRUE when provided with 2 ArrayBuffers. + * This is because an ArrayBuffer must be wrapped in a new Uint8Array to be accessible. + * This custom matcher will automatically instantiate a new Uint8Array on the recieved value + * (and optionally, the expected value) and then call toEqual() on the resulting Uint8Arrays. + */ +export const toEqualBuffer: jest.CustomMatcher = function ( + received: ArrayBuffer, + expected: Uint8Array | ArrayBuffer +) { + received = new Uint8Array(received); + + if (expected instanceof ArrayBuffer) { + expected = new Uint8Array(expected); + } + + if (this.equals(received, expected)) { + return { + message: () => `expected +${received} +not to match +${expected}`, + pass: true, + }; + } + + return { + message: () => `expected +${received} +to match +${expected}`, + pass: false, + }; +}; diff --git a/libs/common/spec/services/crypto.service.spec.ts b/libs/common/spec/services/crypto.service.spec.ts new file mode 100644 index 00000000000..40db49dfa37 --- /dev/null +++ b/libs/common/spec/services/crypto.service.spec.ts @@ -0,0 +1,38 @@ +import { mock, mockReset } from "jest-mock-extended"; + +import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { CryptoService } from "@bitwarden/common/services/crypto.service"; + +describe("cryptoService", () => { + let cryptoService: CryptoService; + + const cryptoFunctionService = mock(); + const encryptService = mock(); + const platformUtilService = mock(); + const logService = mock(); + const stateService = mock(); + + beforeEach(() => { + mockReset(cryptoFunctionService); + mockReset(encryptService); + mockReset(platformUtilService); + mockReset(logService); + mockReset(stateService); + + cryptoService = new CryptoService( + cryptoFunctionService, + encryptService, + platformUtilService, + logService, + stateService + ); + }); + + it("instantiates", () => { + expect(cryptoService).not.toBeFalsy(); + }); +}); diff --git a/libs/common/spec/services/encrypt.service.spec.ts b/libs/common/spec/services/encrypt.service.spec.ts new file mode 100644 index 00000000000..74a95f9acba --- /dev/null +++ b/libs/common/spec/services/encrypt.service.spec.ts @@ -0,0 +1,163 @@ +import { mockReset, mock } from "jest-mock-extended"; + +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { EncryptionType } from "@bitwarden/common/enums/encryptionType"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; +import { EncryptService } from "@bitwarden/common/services/encrypt.service"; + +import { makeStaticByteArray } from "../utils"; + +describe("EncryptService", () => { + const cryptoFunctionService = mock(); + const logService = mock(); + + let encryptService: EncryptService; + + beforeEach(() => { + mockReset(cryptoFunctionService); + mockReset(logService); + + encryptService = new EncryptService(cryptoFunctionService, logService, true); + }); + + describe("encryptToBytes", () => { + const plainValue = makeStaticByteArray(16, 1); + const iv = makeStaticByteArray(16, 30); + const mac = makeStaticByteArray(32, 40); + const encryptedData = makeStaticByteArray(20, 50); + + it("throws if no key is provided", () => { + return expect(encryptService.encryptToBytes(plainValue, null)).rejects.toThrow( + "No encryption key" + ); + }); + + describe("encrypts data", () => { + beforeEach(() => { + cryptoFunctionService.randomBytes.calledWith(16).mockResolvedValueOnce(iv.buffer); + cryptoFunctionService.aesEncrypt.mockResolvedValue(encryptedData.buffer); + }); + + it("using a key which supports mac", async () => { + const key = mock(); + const encType = EncryptionType.AesCbc128_HmacSha256_B64; + key.encType = encType; + + key.macKey = makeStaticByteArray(16, 20); + + cryptoFunctionService.hmac.mockResolvedValue(mac.buffer); + + const actual = await encryptService.encryptToBytes(plainValue, key); + + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.macBytes).toEqualBuffer(mac); + expect(actual.dataBytes).toEqualBuffer(encryptedData); + expect(actual.buffer.byteLength).toEqual( + 1 + iv.byteLength + mac.byteLength + encryptedData.byteLength + ); + }); + + it("using a key which doesn't support mac", async () => { + const key = mock(); + const encType = EncryptionType.AesCbc256_B64; + key.encType = encType; + + key.macKey = null; + + const actual = await encryptService.encryptToBytes(plainValue, key); + + expect(cryptoFunctionService.hmac).not.toBeCalled(); + + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.macBytes).toBeNull(); + expect(actual.dataBytes).toEqualBuffer(encryptedData); + expect(actual.buffer.byteLength).toEqual(1 + iv.byteLength + encryptedData.byteLength); + }); + }); + }); + + describe("decryptToBytes", () => { + const encType = EncryptionType.AesCbc256_HmacSha256_B64; + const key = new SymmetricCryptoKey(makeStaticByteArray(64, 100), encType); + const computedMac = new Uint8Array(1).buffer; + const encBuffer = new EncArrayBuffer(makeStaticByteArray(60, encType)); + + beforeEach(() => { + cryptoFunctionService.hmac.mockResolvedValue(computedMac); + }); + + it("throws if no key is provided", () => { + return expect(encryptService.decryptToBytes(encBuffer, null)).rejects.toThrow( + "No encryption key" + ); + }); + + it("throws if no encrypted value is provided", () => { + return expect(encryptService.decryptToBytes(null, key)).rejects.toThrow( + "Nothing provided for decryption" + ); + }); + + it("decrypts data with provided key", async () => { + const decryptedBytes = makeStaticByteArray(10, 200).buffer; + + cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1).buffer); + cryptoFunctionService.compare.mockResolvedValue(true); + cryptoFunctionService.aesDecrypt.mockResolvedValueOnce(decryptedBytes); + + const actual = await encryptService.decryptToBytes(encBuffer, key); + + expect(cryptoFunctionService.aesDecrypt).toBeCalledWith( + expect.toEqualBuffer(encBuffer.dataBytes), + expect.toEqualBuffer(encBuffer.ivBytes), + expect.toEqualBuffer(key.encKey) + ); + + expect(actual).toEqualBuffer(decryptedBytes); + }); + + it("compares macs using CryptoFunctionService", async () => { + const expectedMacData = new Uint8Array( + encBuffer.ivBytes.byteLength + encBuffer.dataBytes.byteLength + ); + expectedMacData.set(new Uint8Array(encBuffer.ivBytes)); + expectedMacData.set(new Uint8Array(encBuffer.dataBytes), encBuffer.ivBytes.byteLength); + + await encryptService.decryptToBytes(encBuffer, key); + + expect(cryptoFunctionService.hmac).toBeCalledWith( + expect.toEqualBuffer(expectedMacData), + key.macKey, + "sha256" + ); + + expect(cryptoFunctionService.compare).toBeCalledWith( + expect.toEqualBuffer(encBuffer.macBytes), + expect.toEqualBuffer(computedMac) + ); + }); + + it("returns null if macs don't match", async () => { + cryptoFunctionService.compare.mockResolvedValue(false); + + const actual = await encryptService.decryptToBytes(encBuffer, key); + expect(cryptoFunctionService.compare).toHaveBeenCalled(); + expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled(); + expect(actual).toBeNull(); + }); + + it("returns null if encTypes don't match", async () => { + key.encType = EncryptionType.AesCbc256_B64; + cryptoFunctionService.compare.mockResolvedValue(true); + + const actual = await encryptService.decryptToBytes(encBuffer, key); + + expect(actual).toBeNull(); + expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/abstractions/account/account-api.service.abstraction.ts b/libs/common/src/abstractions/account/account-api.service.abstraction.ts new file mode 100644 index 00000000000..c8f382e4ca7 --- /dev/null +++ b/libs/common/src/abstractions/account/account-api.service.abstraction.ts @@ -0,0 +1,5 @@ +import { SecretVerificationRequest } from "@bitwarden/common/models/request/secretVerificationRequest"; + +export abstract class AccountApiService { + abstract deleteAccount(request: SecretVerificationRequest): Promise; +} diff --git a/libs/common/src/abstractions/account/account.service.abstraction.ts b/libs/common/src/abstractions/account/account.service.abstraction.ts new file mode 100644 index 00000000000..d87f9349a1e --- /dev/null +++ b/libs/common/src/abstractions/account/account.service.abstraction.ts @@ -0,0 +1,5 @@ +import { Verification } from "../../types/verification"; + +export abstract class AccountService { + abstract delete(verification: Verification): Promise; +} diff --git a/libs/common/src/interfaces/IEncrypted.ts b/libs/common/src/interfaces/IEncrypted.ts new file mode 100644 index 00000000000..0775a1aced1 --- /dev/null +++ b/libs/common/src/interfaces/IEncrypted.ts @@ -0,0 +1,8 @@ +import { EncryptionType } from "../enums/encryptionType"; + +export interface IEncrypted { + encryptionType?: EncryptionType; + dataBytes: ArrayBuffer; + macBytes: ArrayBuffer; + ivBytes: ArrayBuffer; +} diff --git a/libs/common/src/models/response/organizationExportResponse.ts b/libs/common/src/models/response/organizationExportResponse.ts new file mode 100644 index 00000000000..847fcab96be --- /dev/null +++ b/libs/common/src/models/response/organizationExportResponse.ts @@ -0,0 +1,15 @@ +import { BaseResponse } from "./baseResponse"; +import { CipherResponse } from "./cipherResponse"; +import { CollectionResponse } from "./collectionResponse"; +import { ListResponse } from "./listResponse"; + +export class OrganizationExportResponse extends BaseResponse { + collections: ListResponse; + ciphers: ListResponse; + + constructor(response: any) { + super(response); + this.collections = this.getResponseProperty("Collections"); + this.ciphers = this.getResponseProperty("Ciphers"); + } +} diff --git a/libs/common/src/services/account/account-api.service.ts b/libs/common/src/services/account/account-api.service.ts new file mode 100644 index 00000000000..fe4789d4b10 --- /dev/null +++ b/libs/common/src/services/account/account-api.service.ts @@ -0,0 +1,11 @@ +import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/abstractions/account/account-api.service.abstraction"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SecretVerificationRequest } from "@bitwarden/common/models/request/secretVerificationRequest"; + +export class AccountApiService implements AccountApiServiceAbstraction { + constructor(private apiService: ApiService) {} + + deleteAccount(request: SecretVerificationRequest): Promise { + return this.apiService.send("DELETE", "/accounts", request, true, false); + } +} diff --git a/libs/common/src/services/account/account.service.ts b/libs/common/src/services/account/account.service.ts new file mode 100644 index 00000000000..0870003414b --- /dev/null +++ b/libs/common/src/services/account/account.service.ts @@ -0,0 +1,27 @@ +import { AccountApiService } from "@bitwarden/common/abstractions/account/account-api.service.abstraction"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification.service"; + +import { AccountService as AccountServiceAbstraction } from "../../abstractions/account/account.service.abstraction"; +import { Verification } from "../../types/verification"; + +export class AccountService implements AccountServiceAbstraction { + constructor( + private accountApiService: AccountApiService, + private userVerificationService: UserVerificationService, + private messagingService: MessagingService, + private logService: LogService + ) {} + + async delete(verification: Verification): Promise { + try { + const verificationRequest = await this.userVerificationService.buildRequest(verification); + await this.accountApiService.deleteAccount(verificationRequest); + this.messagingService.send("logout"); + } catch (e) { + this.logService.error(e); + throw e; + } + } +} diff --git a/libs/components/src/modal/index.ts b/libs/components/src/modal/index.ts new file mode 100644 index 00000000000..5c8ac1f8e8d --- /dev/null +++ b/libs/components/src/modal/index.ts @@ -0,0 +1,3 @@ +export * from "./modal.component"; +export * from "./modal-simple.component"; +export * from "./modal.module"; diff --git a/libs/components/src/modal/modal-simple.component.html b/libs/components/src/modal/modal-simple.component.html new file mode 100644 index 00000000000..e4ff97d5ba5 --- /dev/null +++ b/libs/components/src/modal/modal-simple.component.html @@ -0,0 +1,19 @@ +
+
+ + + + +

+ +

+
+
+ +
+
+ +
+
diff --git a/libs/components/src/modal/modal-simple.component.ts b/libs/components/src/modal/modal-simple.component.ts new file mode 100644 index 00000000000..b083e784cf7 --- /dev/null +++ b/libs/components/src/modal/modal-simple.component.ts @@ -0,0 +1,16 @@ +import { Component, ContentChild, Directive } from "@angular/core"; + +@Directive({ selector: "[bit-modal-icon]" }) +export class IconDirective {} + +@Component({ + selector: "bit-simple-modal", + templateUrl: "./modal-simple.component.html", +}) +export class ModalSimpleComponent { + @ContentChild(IconDirective) icon!: IconDirective; + + get hasIcon() { + return this.icon != null; + } +} diff --git a/libs/components/src/modal/modal-simple.stories.ts b/libs/components/src/modal/modal-simple.stories.ts new file mode 100644 index 00000000000..5f28e63bb00 --- /dev/null +++ b/libs/components/src/modal/modal-simple.stories.ts @@ -0,0 +1,85 @@ +import { Meta, moduleMetadata, Story } from "@storybook/angular"; + +import { ButtonModule } from "../button"; + +import { IconDirective, ModalSimpleComponent } from "./modal-simple.component"; + +export default { + title: "Component Library/Modals/Simple Modal", + component: ModalSimpleComponent, + decorators: [ + moduleMetadata({ + imports: [ButtonModule], + declarations: [IconDirective], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library", + }, + }, +} as Meta; + +const Template: Story = (args: ModalSimpleComponent) => ({ + props: args, + template: ` + + Alert Modal + + Message Content + +
+ + +
+
+ `, +}); + +export const Default = Template.bind({}); + +const TemplateWithIcon: Story = (args: ModalSimpleComponent) => ({ + props: args, + template: ` + + + Premium Subscription Available + + Message Content + +
+ + +
+
+ `, +}); + +export const CustomIcon = TemplateWithIcon.bind({}); + +const TemplateScroll: Story = (args: ModalSimpleComponent) => ({ + props: args, + template: ` + + Alert Modal + + Message Content + Message text goes here.
+ + repeating lines of characters
+
+ end of sequence! +
+
+ + +
+
+ `, +}); + +export const ScrollingContent = TemplateScroll.bind({}); +ScrollingContent.args = { + useDefaultIcon: true, +}; diff --git a/libs/components/src/modal/modal.component.html b/libs/components/src/modal/modal.component.html new file mode 100644 index 00000000000..a4528a4a0eb --- /dev/null +++ b/libs/components/src/modal/modal.component.html @@ -0,0 +1,25 @@ +
+
+

+ +

+ +
+ +
+ +
+ +
+ +
+
diff --git a/libs/components/src/modal/modal.component.ts b/libs/components/src/modal/modal.component.ts new file mode 100644 index 00000000000..208929bf024 --- /dev/null +++ b/libs/components/src/modal/modal.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from "@angular/core"; + +@Component({ + selector: "bit-modal", + templateUrl: "./modal.component.html", +}) +export class ModalComponent { + @Input() modalSize: "small" | "default" | "large"; + + get width() { + switch (this.modalSize) { + case "small": { + return "tw-max-w-xs"; + } + case "large": { + return "tw-max-w-4xl"; + } + default: { + return "tw-max-w-xl"; + } + } + } +} diff --git a/libs/components/src/modal/modal.module.ts b/libs/components/src/modal/modal.module.ts new file mode 100644 index 00000000000..340a6b2deed --- /dev/null +++ b/libs/components/src/modal/modal.module.ts @@ -0,0 +1,12 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; + +import { ModalSimpleComponent } from "./modal-simple.component"; +import { ModalComponent } from "./modal.component"; + +@NgModule({ + imports: [CommonModule], + exports: [ModalComponent, ModalSimpleComponent], + declarations: [ModalComponent, ModalSimpleComponent], +}) +export class ModalModule {} diff --git a/libs/components/src/modal/modal.stories.ts b/libs/components/src/modal/modal.stories.ts new file mode 100644 index 00000000000..6225dbe6aa3 --- /dev/null +++ b/libs/components/src/modal/modal.stories.ts @@ -0,0 +1,80 @@ +import { Meta, moduleMetadata, Story } from "@storybook/angular"; + +import { ButtonModule } from "../button"; + +import { ModalComponent } from "./modal.component"; + +export default { + title: "Component Library/Modals/Modal", + component: ModalComponent, + decorators: [ + moduleMetadata({ + imports: [ButtonModule], + }), + ], + args: { + modalSize: "small", + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library", + }, + }, +} as Meta; + +const Template: Story = (args: ModalComponent) => ({ + props: args, + template: ` + + Modal Title + + Modal body text goes here. + +
+ + +
+
+ `, +}); + +export const Default = Template.bind({}); +Default.args = { + modalSize: "default", +}; + +export const Small = Template.bind({}); +Small.args = { + modalSize: "small", +}; + +export const Large = Template.bind({}); +Large.args = { + modalSize: "large", +}; + +const TemplateScrolling: Story = (args: ModalComponent) => ({ + props: args, + template: ` + + Modal Title + + Modal body text goes here.
+ + repeating lines of characters
+
+ end of sequence! +
+
+ + +
+
+ `, +}); + +export const ScrollingContent = TemplateScrolling.bind({}); +ScrollingContent.args = { + modalSize: "small", +}; diff --git a/libs/components/src/table/cell.directive.ts b/libs/components/src/table/cell.directive.ts new file mode 100644 index 00000000000..7083ed462e3 --- /dev/null +++ b/libs/components/src/table/cell.directive.ts @@ -0,0 +1,10 @@ +import { HostBinding, Directive } from "@angular/core"; + +@Directive({ + selector: "th[bitCell], td[bitCell]", +}) +export class CellDirective { + @HostBinding("class") get classList() { + return ["tw-p-3"]; + } +} diff --git a/libs/components/src/table/index.ts b/libs/components/src/table/index.ts new file mode 100644 index 00000000000..02fed986ac7 --- /dev/null +++ b/libs/components/src/table/index.ts @@ -0,0 +1 @@ +export * from "./table.module"; diff --git a/libs/components/src/table/row.directive.ts b/libs/components/src/table/row.directive.ts new file mode 100644 index 00000000000..ebef2f520a1 --- /dev/null +++ b/libs/components/src/table/row.directive.ts @@ -0,0 +1,17 @@ +import { HostBinding, Directive } from "@angular/core"; + +@Directive({ + selector: "tr[bitRow]", +}) +export class RowDirective { + @HostBinding("class") get classList() { + return [ + "tw-border-0", + "tw-border-b", + "tw-border-secondary-300", + "tw-border-solid", + "hover:tw-bg-background-alt", + "last:tw-border-0", + ]; + } +} diff --git a/libs/components/src/table/table.component.html b/libs/components/src/table/table.component.html new file mode 100644 index 00000000000..11108d8ec1a --- /dev/null +++ b/libs/components/src/table/table.component.html @@ -0,0 +1,10 @@ + + + + + + + +
diff --git a/libs/components/src/table/table.component.ts b/libs/components/src/table/table.component.ts new file mode 100644 index 00000000000..8099c35b929 --- /dev/null +++ b/libs/components/src/table/table.component.ts @@ -0,0 +1,7 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "bit-table", + templateUrl: "./table.component.html", +}) +export class TableComponent {} diff --git a/libs/components/src/table/table.module.ts b/libs/components/src/table/table.module.ts new file mode 100644 index 00000000000..2ca88a349f0 --- /dev/null +++ b/libs/components/src/table/table.module.ts @@ -0,0 +1,13 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; + +import { CellDirective } from "./cell.directive"; +import { RowDirective } from "./row.directive"; +import { TableComponent } from "./table.component"; + +@NgModule({ + imports: [CommonModule], + declarations: [TableComponent, CellDirective, RowDirective], + exports: [TableComponent, CellDirective, RowDirective], +}) +export class TableModule {} diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts new file mode 100644 index 00000000000..49da9cd0160 --- /dev/null +++ b/libs/components/src/table/table.stories.ts @@ -0,0 +1,53 @@ +import { Meta, moduleMetadata, Story } from "@storybook/angular"; + +import { TableModule } from "./table.module"; + +export default { + title: "Component Library/Table", + decorators: [ + moduleMetadata({ + imports: [TableModule], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A18371", + }, + }, +} as Meta; + +const Template: Story = (args) => ({ + props: args, + template: ` + + + + Header 1 + Header 2 + Header 3 + + + + + Cell 1 + Cell 2 + Cell 3 + + + Cell 4 + Cell 5 + Cell 6 + + + Cell 7 + Cell 8 + Cell 9 + + + + + `, +}); + +export const Default = Template.bind({}); diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 00000000000..dd391db8d32 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/* eslint-disable */ +const config = require("./libs/components/tailwind.config.base"); + +config.content = ["./libs/components/src/**/*.{html,ts,mdx}", "./.storybook/preview.js"]; +config.safelist = [ + { + pattern: /tw-bg-(.*)/, + }, +]; + +module.exports = config;