1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 04:33:38 +00:00

Merge branch 'main' into km/pm-18576/fix-missing-userid-on-remove-password

This commit is contained in:
Maciej Zieniuk
2025-03-13 12:25:25 +00:00
221 changed files with 3745 additions and 5134 deletions

3
.github/CODEOWNERS vendored
View File

@@ -8,6 +8,8 @@
apps/desktop/desktop_native @bitwarden/team-platform-dev
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-dev
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-dev
## No ownership for Cargo.toml to allow dependency updates
apps/desktop/desktop_native/Cargo.toml
## Auth team files ##
apps/browser/src/auth @bitwarden/team-auth-dev
@@ -119,6 +121,7 @@ apps/browser/src/autofill @bitwarden/team-autofill-dev
apps/desktop/src/autofill @bitwarden/team-autofill-dev
libs/common/src/autofill @bitwarden/team-autofill-dev
apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev
apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-dev
apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev

View File

@@ -168,15 +168,20 @@
matchPackageNames: [
"@emotion/css",
"@webcomponents/custom-elements",
"bitwarden-russh",
"bytes",
"concurrently",
"cross-env",
"del",
"ed25519",
"lit",
"patch-package",
"pkcs8",
"prettier",
"prettier-plugin-tailwindcss",
"rimraf",
"ssh-encoding",
"ssh-key",
"@storybook/web-components-webpack5",
"tabbable",
"tldts",
@@ -210,31 +215,68 @@
"@types/node-forge",
"@types/node-ipc",
"@yao-pkg/pkg",
"anyhow",
"arboard",
"babel-loader",
"base64",
"bindgen",
"browserslist",
"byteorder",
"bytes",
"core-foundation",
"copy-webpack-plugin",
"dirs",
"electron",
"electron-builder",
"electron-log",
"electron-reload",
"electron-store",
"electron-updater",
"embed_plist",
"futures",
"hex",
"homedir",
"html-webpack-injector",
"html-webpack-plugin",
"interprocess",
"json5",
"keytar",
"libc",
"log",
"lowdb",
"napi",
"napi-build",
"napi-derive",
"node-forge",
"node-ipc",
"oo7",
"oslog",
"pin-project",
"pkg",
"rand",
"rxjs",
"scopeguard",
"security-framework",
"security-framework-sys",
"serde",
"serde_json",
"simplelog",
"sysinfo",
"tsconfig-paths-webpack-plugin",
"type-fest",
"typenum",
"typescript",
"typescript-strict-plugin",
"uniffi",
"webpack",
"webpack-cli",
"webpack-dev-server",
"webpack-node-externals",
"widestring",
"windows",
"windows-registry",
"zbus",
"zbus_polkit",
],
description: "Platform owned dependencies",
commitMessagePrefix: "[deps] Platform:",
@@ -352,7 +394,17 @@
reviewers: ["team:team-vault-dev"],
},
{
matchPackageNames: ["@types/argon2-browser", "argon2", "argon2-browser", "big-integer"],
matchPackageNames: [
"@types/argon2-browser",
"aes",
"argon2",
"argon2-browser",
"big-integer",
"cbc",
"rsa",
"russh-cryptovec",
"sha2",
],
description: "Key Management owned dependencies",
commitMessagePrefix: "[deps] KM:",
reviewers: ["team:team-key-management-dev"],

View File

@@ -385,6 +385,15 @@
"editFolder": {
"message": "Edit folder"
},
"editFolderWithName": {
"message": "Edit folder: $FOLDERNAME$",
"placeholders": {
"foldername": {
"content": "$1",
"example": "Social"
}
}
},
"newFolder": {
"message": "New folder"
},
@@ -1670,6 +1679,9 @@
"dragToSort": {
"message": "Drag to sort"
},
"dragToReorder": {
"message": "Drag to reorder"
},
"cfTypeText": {
"message": "Text"
},
@@ -3326,9 +3338,6 @@
"loginWithDevice": {
"message": "Log in with device"
},
"loginWithDeviceEnabledInfo": {
"message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?"
},
"fingerprintPhraseHeader": {
"message": "Fingerprint phrase"
},
@@ -3341,9 +3350,6 @@
"viewAllLogInOptions": {
"message": "View all log in options"
},
"viewAllLoginOptionsV1": {
"message": "View all log in options"
},
"notificationSentDevice": {
"message": "A notification has been sent to your device."
},
@@ -3534,9 +3540,6 @@
"adminApprovalRequestSentToAdmins": {
"message": "Your request has been sent to your admin."
},
"youWillBeNotifiedOnceApproved": {
"message": "You will be notified once approved."
},
"troubleLoggingIn": {
"message": "Trouble logging in?"
},
@@ -4697,6 +4700,9 @@
}
}
},
"reorderWebsiteUriButton": {
"message": "Reorder website URI. Use arrow key to move item up or down."
},
"reorderFieldUp": {
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
"placeholders": {
@@ -5128,6 +5134,33 @@
"extraWide": {
"message": "Extra wide"
},
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
},
"importSshKey": {
"message": "Import"
},
"confirmSshKeyPassword": {
"message": "Confirm password"
},
"enterSshKeyPasswordDesc": {
"message": "Enter the password for the SSH key."
},
"enterSshKeyPassword": {
"message": "Enter password"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
},
"sshKeyTypeUnsupported": {
"message": "The SSH key type is not supported"
},
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"placeholders": {

View File

@@ -1,7 +1,7 @@
<form #form (ngSubmit)="submit()">
<header>
<div class="left">
<button type="button" routerLink="/home">{{ "close" | i18n }}</button>
<button type="button" routerLink="/login">{{ "close" | i18n }}</button>
</div>
<h1 class="center">
<span class="title">{{ "appName" | i18n }}</span>

View File

@@ -1,36 +0,0 @@
<app-header [noTheme]="true"></app-header>
<div class="center-content">
<div class="content login-page">
<div class="logo-image"></div>
<p class="lead text-center">{{ "loginOrCreateNewAccount" | i18n }}</p>
<form #form [formGroup]="formGroup" (ngSubmit)="submit()">
<div class="box">
<div class="box-content">
<div class="box-content-row" appBoxRow>
<label for="email">{{ "emailAddress" | i18n }}</label>
<input id="email" type="email" formControlName="email" appInputVerbatim="false" />
</div>
<environment-selector></environment-selector>
<div class="remember-email-check">
<input
id="rememberEmail"
type="checkbox"
name="rememberEmail"
formControlName="rememberEmail"
/>
<label for="rememberEmail">{{ "rememberEmail" | i18n }}</label>
</div>
</div>
</div>
<div class="box">
<button type="submit" class="btn primary block">
<b>{{ "continue" | i18n }}</b>
</button>
</div>
</form>
<p class="createAccountLink">
{{ "newAroundHere" | i18n }}
<a routerLink="/signup" (click)="setLoginEmailValues()">{{ "createAccount" | i18n }}</a>
</p>
</div>
</div>

View File

@@ -1,130 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, firstValueFrom, switchMap, takeUntil, tap } from "rxjs";
import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component";
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import { AccountSwitcherService } from "./account-switching/services/account-switcher.service";
@Component({
selector: "app-home",
templateUrl: "home.component.html",
})
export class HomeComponent implements OnInit, OnDestroy {
@ViewChild(EnvironmentSelectorComponent, { static: true })
environmentSelector!: EnvironmentSelectorComponent;
private destroyed$: Subject<void> = new Subject();
loginInitiated = false;
formGroup = this.formBuilder.group({
email: ["", [Validators.required, Validators.email]],
rememberEmail: [false],
});
constructor(
protected platformUtilsService: PlatformUtilsService,
private formBuilder: FormBuilder,
private router: Router,
private i18nService: I18nService,
private loginEmailService: LoginEmailServiceAbstraction,
private accountSwitcherService: AccountSwitcherService,
private toastService: ToastService,
private configService: ConfigService,
private route: ActivatedRoute,
) {}
async ngOnInit(): Promise<void> {
this.listenForUnauthUiRefreshFlagChanges();
const email = await firstValueFrom(this.loginEmailService.loginEmail$);
const rememberEmail = this.loginEmailService.getRememberEmail();
if (email != null) {
this.formGroup.patchValue({ email, rememberEmail });
} else {
const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$);
if (storedEmail != null) {
this.formGroup.patchValue({ email: storedEmail, rememberEmail: true });
}
}
this.environmentSelector.onOpenSelfHostedSettings
.pipe(
switchMap(async () => {
await this.setLoginEmailValues();
await this.router.navigate(["environment"]);
}),
takeUntil(this.destroyed$),
)
.subscribe();
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
private listenForUnauthUiRefreshFlagChanges() {
this.configService
.getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh)
.pipe(
tap(async (flag) => {
// If the flag is turned ON, we must force a reload to ensure the correct UI is shown
if (flag) {
const qParams = await firstValueFrom(this.route.queryParams);
const uniqueQueryParams = {
...qParams,
// adding a unique timestamp to the query params to force a reload
t: new Date().getTime().toString(),
};
await this.router.navigate(["/login"], {
queryParams: uniqueQueryParams,
});
}
}),
takeUntil(this.destroyed$),
)
.subscribe();
}
get availableAccounts$() {
return this.accountSwitcherService.availableAccounts$;
}
async submit() {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
message: this.i18nService.t("invalidEmail"),
});
return;
}
await this.setLoginEmailValues();
await this.router.navigate(["login"], {
queryParams: { email: this.formGroup.controls.email.value },
});
}
async setLoginEmailValues() {
// Note: Browser saves email settings here instead of the login component
this.loginEmailService.setRememberEmail(this.formGroup.controls.rememberEmail.value);
await this.loginEmailService.setLoginEmail(this.formGroup.controls.email.value);
await this.loginEmailService.saveEmailSettings();
}
}

View File

@@ -1,81 +0,0 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" [formGroup]="formGroup">
<header>
<h1 class="login-center">
<span class="title">{{ "logIn" | i18n }}</span>
</h1>
</header>
<main tabindex="-1">
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-flex" appBoxRow>
<div class="row-main">
<input id="email" type="text" formControlName="email" [hidden]="true" />
<input
id="rememberEmail"
type="checkbox"
formControlName="rememberEmail"
[hidden]="true"
/>
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
<input
id="masterPassword"
type="{{ showPassword ? 'text' : 'password' }}"
class="monospaced"
formControlName="masterPassword"
appInputVerbatim
appAutofocus
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword()"
[attr.aria-pressed]="showPassword"
>
<i
class="bwi bwi-lg"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
aria-hidden="true"
></i>
</button>
</div>
</div>
<div class="box-content-row" [hidden]="!showCaptcha()">
<iframe
id="hcaptcha_iframe"
height="80"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
</div>
<div class="box-footer">
<button type="button" class="btn link" routerLink="/hint" (click)="saveEmailSettings()">
<b>{{ "getMasterPasswordHint" | i18n }}</b>
</button>
</div>
</div>
<div class="content login-buttons">
<button type="submit" class="btn primary block" [disabled]="form.loading">
<span [hidden]="form.loading"
><b>{{ "logInWithMasterPassword" | i18n }}</b></span
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!form.loading" aria-hidden="true"></i>
</button>
<div class="tw-mb-3" *ngIf="showLoginWithDevice">
<button type="button" class="btn block" (click)="startAuthRequestLogin()">
<span> <i class="bwi bwi-mobile"></i> {{ "loginWithDevice" | i18n }} </span>
</button>
</div>
<button type="button" (click)="launchSsoBrowser()" class="btn block">
<i class="bwi bwi-provider" aria-hidden="true"></i> {{ "enterpriseSingleSignOn" | i18n }}
</button>
<div class="small">
<p class="no-margin">{{ "loggingInAs" | i18n }} {{ loggedEmail }}</p>
<a routerLink="/home">{{ "notYou" | i18n }}</a>
</div>
</div>
</main>
</form>

View File

@@ -1,142 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, NgZone, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@Component({
selector: "app-login",
templateUrl: "login-v1.component.html",
})
export class LoginComponentV1 extends BaseLoginComponent implements OnInit {
constructor(
devicesApiService: DevicesApiServiceAbstraction,
appIdService: AppIdService,
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected stateService: StateService,
protected environmentService: EnvironmentService,
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected cryptoFunctionService: CryptoFunctionService,
syncService: SyncService,
logService: LogService,
ngZone: NgZone,
formBuilder: FormBuilder,
formValidationErrorService: FormValidationErrorsService,
route: ActivatedRoute,
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
toastService: ToastService,
) {
super(
devicesApiService,
appIdService,
loginStrategyService,
router,
platformUtilsService,
i18nService,
stateService,
environmentService,
passwordGenerationService,
cryptoFunctionService,
logService,
ngZone,
formBuilder,
formValidationErrorService,
route,
loginEmailService,
ssoLoginService,
toastService,
);
this.onSuccessfulLogin = async () => {
await syncService.fullSync(true);
};
this.successRoute = "/tabs/vault";
}
async ngOnInit(): Promise<void> {
await super.ngOnInit();
await this.validateEmail();
}
settings() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["environment"]);
}
async launchSsoBrowser() {
// Save off email for SSO
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
// Generate necessary sso params
const passwordOptions: any = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
};
const state =
(await this.passwordGenerationService.generatePassword(passwordOptions)) +
":clientId=browser";
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
await this.ssoLoginService.setCodeVerifier(codeVerifier);
await this.ssoLoginService.setSsoState(state);
const env = await firstValueFrom(this.environmentService.environment$);
let url = env.getWebVaultUrl();
if (url == null) {
url = "https://vault.bitwarden.com";
}
const redirectUri = url + "/sso-connector.html";
// Launch browser
this.platformUtilsService.launchUri(
url +
"/#/sso?clientId=browser" +
"&redirectUri=" +
encodeURIComponent(redirectUri) +
"&state=" +
state +
"&codeChallenge=" +
codeChallenge +
"&email=" +
encodeURIComponent(this.formGroup.controls.email.value),
);
}
async saveEmailSettings() {
// values should be saved on home component
return;
}
}

View File

@@ -1,68 +0,0 @@
<div class="login-with-device">
<header>
<h1 class="login-center">
<span class="title">{{ "logIn" | i18n }}</span>
</h1>
</header>
<div class="content login-page">
<ng-container *ngIf="state == StateEnum.StandardAuthRequest">
<div>
<p class="lead">{{ "logInRequestSent" | i18n }}</p>
<div>
<p>
{{ "notificationSentDevicePart1" | i18n }}
<a
bitLink
linkType="primary"
class="tw-cursor-pointer"
[href]="deviceManagementUrl"
target="_blank"
rel="noreferrer"
>{{ "notificationSentDeviceAnchor" | i18n }}</a
>. {{ "notificationSentDevicePart2" | i18n }}
</p>
</div>
<div>
<b class="fingerprint-phrase-header">{{ "fingerprintPhraseHeader" | i18n }}</b>
<p class="fingerprint-text">
<code>{{ fingerprintPhrase }}</code>
</p>
</div>
<div class="resend-notification" *ngIf="showResendNotification">
<a (click)="startAuthRequestLogin()">{{ "resendNotification" | i18n }}</a>
</div>
<div class="footer">
{{ "loginWithDeviceEnabledInfo" | i18n }}
<a href="#" (click)="back()">{{ "viewAllLoginOptionsV1" | i18n }}</a>
</div>
</div>
</ng-container>
<ng-container *ngIf="state == StateEnum.AdminAuthRequest">
<div>
<p class="lead">{{ "adminApprovalRequested" | i18n }}</p>
<div>
<p>{{ "adminApprovalRequestSentToAdmins" | i18n }}</p>
<p>{{ "youWillBeNotifiedOnceApproved" | i18n }}</p>
</div>
<div>
<b class="fingerprint-phrase-header">{{ "fingerprintPhraseHeader" | i18n }}</b>
<p class="fingerprint-text">
<code>{{ fingerprintPhrase }}</code>
</p>
</div>
<div class="footer">
{{ "troubleLoggingIn" | i18n }}
<a routerLink="/login-initiated">{{ "viewAllLoginOptionsV1" | i18n }}</a>
</div>
</div>
</ng-container>
</div>
</div>

View File

@@ -1,85 +0,0 @@
import { Location } from "@angular/common";
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component";
import {
AuthRequestServiceAbstraction,
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KeyService } from "@bitwarden/key-management";
@Component({
selector: "app-login-via-auth-request",
templateUrl: "login-via-auth-request-v1.component.html",
})
export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 {
constructor(
router: Router,
keyService: KeyService,
cryptoFunctionService: CryptoFunctionService,
appIdService: AppIdService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
apiService: ApiService,
authService: AuthService,
logService: LogService,
environmentService: EnvironmentService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
anonymousHubService: AnonymousHubService,
validationService: ValidationService,
loginEmailService: LoginEmailServiceAbstraction,
syncService: SyncService,
deviceTrustService: DeviceTrustServiceAbstraction,
authRequestService: AuthRequestServiceAbstraction,
loginStrategyService: LoginStrategyServiceAbstraction,
accountService: AccountService,
private location: Location,
toastService: ToastService,
) {
super(
router,
keyService,
cryptoFunctionService,
appIdService,
passwordGenerationService,
apiService,
authService,
logService,
environmentService,
i18nService,
platformUtilsService,
anonymousHubService,
validationService,
accountService,
loginEmailService,
deviceTrustService,
authRequestService,
loginStrategyService,
toastService,
);
this.onSuccessfulLogin = async () => {
await syncService.fullSync(true);
};
}
protected back() {
this.location.back();
}
}

View File

@@ -1,7 +1,7 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<header>
<div class="left">
<button type="button" routerLink="/home">{{ "cancel" | i18n }}</button>
<button type="button" routerLink="/login">{{ "cancel" | i18n }}</button>
</div>
<h1 class="center">
<span class="title">{{ "setMasterPassword" | i18n }}</span>

View File

@@ -72,7 +72,7 @@ describe("AuthPopoutWindow", () => {
it("closes any existing popup window types that are open to the login extension route", async () => {
const loginTab = createChromeTabMock({
url: chrome.runtime.getURL("popup/index.html#/home"),
url: chrome.runtime.getURL("popup/index.html#/login"),
});
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([loginTab]);
jest.spyOn(BrowserApi, "removeWindow");

View File

@@ -13,7 +13,7 @@ const AuthPopoutType = {
const extensionUnlockUrls = new Set([
chrome.runtime.getURL("popup/index.html#/lock"),
chrome.runtime.getURL("popup/index.html#/home"),
chrome.runtime.getURL("popup/index.html#/login"),
]);
/**

View File

@@ -364,7 +364,7 @@ export class AutofillComponent implements OnInit {
return;
}
BrowserApi.updateDefaultBrowserAutofillSettings(!this.defaultBrowserAutofillDisabled);
await BrowserApi.updateDefaultBrowserAutofillSettings(!this.defaultBrowserAutofillDisabled);
}
private handleOverrideDialogAccept = async () => {

View File

@@ -21,8 +21,7 @@ describe("InlineMenuFieldQualificationService", () => {
});
describe("isFieldForLoginForm", () => {
it("does not disqualify totp fields for premium users with flag set to true", () => {
inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true;
it("does not disqualify totp fields for premium users", () => {
inlineMenuFieldQualificationService["premiumEnabled"] = true;
const field = mock<AutofillField>({
type: "text",
@@ -37,24 +36,7 @@ describe("InlineMenuFieldQualificationService", () => {
);
});
it("disqualifies totp fields for premium users with flag set to false", () => {
inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = false;
inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true;
const field = mock<AutofillField>({
type: "text",
autoCompleteType: "one-time-code",
htmlName: "totp",
htmlID: "totp",
placeholder: "totp",
});
expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe(
false,
);
});
it("disqualifies totp fields for non-premium users with flag set to true", () => {
inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true;
it("disqualifies totp fields for non-premium users", () => {
inlineMenuFieldQualificationService["premiumEnabled"] = false;
const field = mock<AutofillField>({
type: "text",

View File

@@ -151,17 +151,14 @@ export class InlineMenuFieldQualificationService
]);
private totpFieldAutocompleteValue = "one-time-code";
private inlineMenuFieldQualificationFlagSet = false;
private inlineMenuTotpFeatureFlag = false;
private premiumEnabled = false;
constructor() {
void Promise.all([
sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"),
sendExtensionMessage("getInlineMenuTotpFeatureFlag"),
sendExtensionMessage("getUserPremiumStatus"),
]).then(([fieldQualificationFlag, totpFeatureFlag, premiumStatus]) => {
]).then(([fieldQualificationFlag, premiumStatus]) => {
this.inlineMenuFieldQualificationFlagSet = !!fieldQualificationFlag?.result;
this.inlineMenuTotpFeatureFlag = !!totpFeatureFlag?.result;
this.premiumEnabled = !!premiumStatus?.result;
});
}
@@ -180,7 +177,7 @@ export class InlineMenuFieldQualificationService
/**
* Totp inline menu is available only for premium users.
*/
if (this.inlineMenuTotpFeatureFlag && this.premiumEnabled) {
if (this.premiumEnabled) {
const isTotpField = this.isTotpField(field);
// Autofill does not fill totp inputs with a "password" `type` attribute value
const passwordType = field.type === "password";

View File

@@ -103,6 +103,7 @@ export function createChromeTabMock(customFields = {}): chrome.tabs.Tab {
selected: true,
discarded: false,
autoDiscardable: false,
frozen: false,
groupId: 2,
url: "https://jest-testing-website.com",
...customFields,

View File

@@ -1011,6 +1011,7 @@ export default class MainBackground {
this.encryptService,
this.pinService,
this.accountService,
this.sdkService,
);
this.individualVaultExportService = new IndividualVaultExportService(

View File

@@ -78,7 +78,6 @@ export default class RuntimeBackground {
BiometricsCommands.GetBiometricsStatusForUser,
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
"getInlineMenuFieldQualificationFeatureFlag",
"getInlineMenuTotpFeatureFlag",
"getUserPremiumStatus",
];
@@ -217,9 +216,6 @@ export default class RuntimeBackground {
);
return result;
}
case "getInlineMenuTotpFeatureFlag": {
return await this.configService.getFeatureFlag(FeatureFlag.InlineMenuTotp);
}
}
}

View File

@@ -2,6 +2,8 @@ import { mock } from "jest-mock-extended";
import { BrowserApi } from "./browser-api";
type ChromeSettingsGet = chrome.types.ChromeSetting<boolean>["get"];
describe("BrowserApi", () => {
const executeScriptResult = ["value"];
@@ -468,19 +470,23 @@ describe("BrowserApi", () => {
describe("browserAutofillSettingsOverridden", () => {
it("returns true if the browser autofill settings are overridden", async () => {
const expectedDetails = {
value: false,
levelOfControl: "controlled_by_this_extension",
} as chrome.types.ChromeSettingGetResultDetails;
chrome.privacy.services.autofillAddressEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
chrome.privacy.services.autofillCreditCardEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
chrome.privacy.services.passwordSavingEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
const mockFn = jest.fn<
void,
[
details: chrome.types.ChromeSettingGetDetails,
callback: (details: chrome.types.ChromeSettingGetResult<boolean>) => void,
],
never
>((details, callback) => {
callback({
value: false,
levelOfControl: "controlled_by_this_extension",
});
});
chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet;
chrome.privacy.services.autofillCreditCardEnabled.get =
mockFn as unknown as ChromeSettingsGet;
chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet;
const result = await BrowserApi.browserAutofillSettingsOverridden();
@@ -488,19 +494,24 @@ describe("BrowserApi", () => {
});
it("returns false if the browser autofill settings are not overridden", async () => {
const expectedDetails = {
value: true,
levelOfControl: "controlled_by_this_extension",
} as chrome.types.ChromeSettingGetResultDetails;
chrome.privacy.services.autofillAddressEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
chrome.privacy.services.autofillCreditCardEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
chrome.privacy.services.passwordSavingEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
const mockFn = jest.fn<
void,
[
details: chrome.types.ChromeSettingGetDetails,
callback: (details: chrome.types.ChromeSettingGetResult<boolean>) => void,
],
never
>((details, callback) => {
callback({
value: true,
levelOfControl: "controlled_by_this_extension",
});
});
chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet;
chrome.privacy.services.autofillCreditCardEnabled.get =
mockFn as unknown as ChromeSettingsGet;
chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet;
const result = await BrowserApi.browserAutofillSettingsOverridden();
@@ -508,19 +519,23 @@ describe("BrowserApi", () => {
});
it("returns false if the browser autofill settings are not controlled by the extension", async () => {
const expectedDetails = {
value: false,
levelOfControl: "controlled_by_other_extensions",
} as chrome.types.ChromeSettingGetResultDetails;
chrome.privacy.services.autofillAddressEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
chrome.privacy.services.autofillCreditCardEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
chrome.privacy.services.passwordSavingEnabled.get = jest.fn((details, callback) =>
callback(expectedDetails),
);
const mockFn = jest.fn<
void,
[
details: chrome.types.ChromeSettingGetDetails,
callback: (details: chrome.types.ChromeSettingGetResult<boolean>) => void,
],
never
>((details, callback) => {
callback({
value: false,
levelOfControl: "controlled_by_other_extensions",
});
});
chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet;
chrome.privacy.services.autofillCreditCardEnabled.get =
mockFn as unknown as ChromeSettingsGet;
chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet;
const result = await BrowserApi.browserAutofillSettingsOverridden();

View File

@@ -504,7 +504,9 @@ export class BrowserApi {
*
* @param permissions - The permissions to check.
*/
static async permissionsGranted(permissions: string[]): Promise<boolean> {
static async permissionsGranted(
permissions: chrome.runtime.ManifestPermissions[],
): Promise<boolean> {
return new Promise((resolve) =>
chrome.permissions.contains({ permissions }, (result) => resolve(result)),
);
@@ -594,7 +596,7 @@ export class BrowserApi {
* Identifies if the browser autofill settings are overridden by the extension.
*/
static async browserAutofillSettingsOverridden(): Promise<boolean> {
const checkOverrideStatus = (details: chrome.types.ChromeSettingGetResultDetails) =>
const checkOverrideStatus = (details: chrome.types.ChromeSettingGetResult<boolean>) =>
details.levelOfControl === "controlled_by_this_extension" && !details.value;
const autofillAddressOverridden: boolean = await new Promise((resolve) =>
@@ -623,10 +625,10 @@ export class BrowserApi {
*
* @param value - Determines whether to enable or disable the autofill settings.
*/
static updateDefaultBrowserAutofillSettings(value: boolean) {
chrome.privacy.services.autofillAddressEnabled.set({ value });
chrome.privacy.services.autofillCreditCardEnabled.set({ value });
chrome.privacy.services.passwordSavingEnabled.set({ value });
static async updateDefaultBrowserAutofillSettings(value: boolean) {
await chrome.privacy.services.autofillAddressEnabled.set({ value });
await chrome.privacy.services.autofillCreditCardEnabled.set({ value });
await chrome.privacy.services.passwordSavingEnabled.set({ value });
}
/**

View File

@@ -64,8 +64,16 @@ export class PopupViewCacheService implements ViewCacheService {
filter((e) => e instanceof NavigationEnd),
/** Skip the first navigation triggered by `popupRouterCacheGuard` */
skip(1),
filter((e: NavigationEnd) =>
// viewing/editing a cipher and navigating back to the vault list should not clear the cache
["/view-cipher", "/edit-cipher", "/tabs/vault"].every(
(route) => !e.urlAfterRedirects.startsWith(route),
),
),
)
.subscribe(() => this.clearState());
.subscribe((e) => {
return this.clearState();
});
}
/**

View File

@@ -7,7 +7,6 @@ import {
EnvironmentSelectorRouteData,
ExtensionDefaultOverlayPosition,
} from "@bitwarden/angular/auth/components/environment-selector.component";
import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect";
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
import {
activeAuthGuard,
@@ -58,15 +57,9 @@ import {
ExtensionAnonLayoutWrapperComponent,
ExtensionAnonLayoutWrapperData,
} from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component";
import { HintComponent } from "../auth/popup/hint.component";
import { HomeComponent } from "../auth/popup/home.component";
import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "../auth/popup/login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { SsoComponentV1 } from "../auth/popup/sso-v1.component";
import { TwoFactorOptionsComponentV1 } from "../auth/popup/two-factor-options-v1.component";
import { TwoFactorComponentV1 } from "../auth/popup/two-factor-v1.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
@@ -131,20 +124,19 @@ const routes: Routes = [
children: [], // Children lets us have an empty component.
canActivate: [
popupRouterCacheGuard,
redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/home", locked: "/lock" }),
redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/login", locked: "/lock" }),
],
},
{
path: "home",
redirectTo: "login",
pathMatch: "full",
},
{
path: "vault",
redirectTo: "/tabs/vault",
pathMatch: "full",
},
{
path: "home",
component: HomeComponent,
canActivate: [unauthGuardFn(unauthRouteOverrides), unauthUiRefreshRedirect("/login")],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "fido2",
component: Fido2Component,
@@ -206,40 +198,6 @@ const routes: Routes = [
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 } satisfies RouteDataProperties,
},
...unauthUiRefreshSwap(
SsoComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "sso",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "sso",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
),
{
path: "device-verification",
component: ExtensionAnonLayoutWrapperComponent,
@@ -420,158 +378,7 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "login-with-device",
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
showLogo: false,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
showLogo: false,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap(
HintComponent,
ExtensionAnonLayoutWrapperComponent,
{
path: "hint",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
elevation: 1,
} satisfies RouteDataProperties,
},
{
path: "",
children: [
{
path: "hint",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "login",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 },
},
{
path: "",
children: [
{
path: "login",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "logInToBitwarden",
},
elevation: 1,
showAcctSwitcher: true,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginDecryptionOptionsComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
),
{
path: "",
component: ExtensionAnonLayoutWrapperComponent,
@@ -597,7 +404,7 @@ const routes: Routes = [
component: RegistrationStartSecondaryComponent,
outlet: "secondary",
data: {
loginRoute: "/home",
loginRoute: "/login",
} satisfies RegistrationStartSecondaryComponentData,
},
],
@@ -617,6 +424,127 @@ const routes: Routes = [
},
],
},
{
path: "login",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "logInToBitwarden",
},
elevation: 1,
showAcctSwitcher: true,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
{
path: "sso",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "hint",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
showLogo: false,
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
{
path: "lock",
canActivate: [lockGuard()],

View File

@@ -1,9 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { LogoutReason } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -68,7 +70,10 @@ export class AppComponent implements OnInit, OnDestroy {
private animationControlService: AnimationControlService,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
async ngOnInit() {
initPopupClosedListener();
@@ -113,9 +118,7 @@ export class AppComponent implements OnInit, OnDestroy {
});
this.changeDetectorRef.detectChanges();
} else if (msg.command === "authBlocked" || msg.command === "goHome") {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["home"]);
await this.router.navigate(["login"]);
} else if (
msg.command === "locked" &&
(msg.userId == null || msg.userId == this.activeUserId)

View File

@@ -22,10 +22,7 @@ import { CurrentAccountComponent } from "../auth/popup/account-switching/current
import { EnvironmentComponent } from "../auth/popup/environment.component";
import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component";
import { HintComponent } from "../auth/popup/hint.component";
import { HomeComponent } from "../auth/popup/home.component";
import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "../auth/popup/login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
@@ -98,9 +95,6 @@ import "../platform/popup/locales";
ColorPasswordCountPipe,
EnvironmentComponent,
HintComponent,
HomeComponent,
LoginViaAuthRequestComponentV1,
LoginComponentV1,
LoginDecryptionOptionsComponentV1,
SetPasswordComponent,
SsoComponentV1,

View File

@@ -448,38 +448,6 @@ main:not(popup-page main) {
width: 100%;
}
.login-with-device {
.fingerprint-phrase-header {
padding-top: 1rem;
display: block;
}
@include themify($themes) {
.fingerprint-text {
color: themed("codeColor");
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
padding: 1rem 0;
}
}
.resend-notification {
padding-bottom: 1rem;
a {
cursor: pointer;
}
}
.footer {
padding-top: 1rem;
a {
padding-top: 1rem;
display: block;
}
}
}
#login-initiated {
.margin-auto {
margin: auto;

View File

@@ -130,7 +130,11 @@ import {
KeyService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management-ui";
import { PasswordRepromptService } from "@bitwarden/vault";
import {
DefaultSshImportPromptService,
PasswordRepromptService,
SshImportPromptService,
} from "@bitwarden/vault";
import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service";
import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service";
@@ -653,6 +657,11 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionLoginDecryptionOptionsService,
deps: [MessagingServiceAbstraction, Router],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction],
}),
];
@NgModule({

View File

@@ -6,6 +6,7 @@
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null"
showAutofillButton
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[primaryActionAutofill]="clickItemsToAutofillVaultView"
[groupByType]="groupByType()"
></app-vault-list-items-container>

View File

@@ -27,12 +27,7 @@
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</a>
<a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.SshKey)"
*ngIf="sshKeysEnabled"
>
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.SshKey)">
<i class="bwi bwi-key" slot="start" aria-hidden="true"></i>
{{ "typeSshKey" | i18n }}
</a>

View File

@@ -5,8 +5,6 @@ import { Component, Input, OnInit } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -40,13 +38,9 @@ export class NewItemDropdownV2Component implements OnInit {
constructor(
private router: Router,
private dialogService: DialogService,
private configService: ConfigService,
) {}
sshKeysEnabled = false;
async ngOnInit() {
this.sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
this.tab = await BrowserApi.getTabFromCurrentWindow();
}

View File

@@ -17,6 +17,7 @@
<a
tabIndex="0"
bitLink
class="tw-font-bold"
linkType="primary"
routerLink="/appearance"
(keydown.enter)="goToAppearance()"

View File

@@ -70,7 +70,12 @@
</ng-template>
<ng-template #descriptionText>
<div *ngIf="description" class="tw-text-muted tw-px-1 tw-mb-2" bitTypography="body2">
<div
*ngIf="description"
class="tw-text-muted tw-px-1 tw-mb-2"
[ngClass]="{ '!tw-mb-0': disableDescriptionMargin }"
bitTypography="body2"
>
{{ description }}
</div>
</ng-template>

View File

@@ -245,6 +245,12 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
@Input({ transform: booleanAttribute })
disableSectionMargin: boolean = false;
/**
* Remove the description margin
*/
@Input({ transform: booleanAttribute })
disableDescriptionMargin: boolean = false;
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
* @param cipher

View File

@@ -19,7 +19,7 @@
slot="end"
type="button"
(click)="openAddEditFolderDialog(folder)"
[appA11yTitle]="'editFolder' | i18n"
[appA11yTitle]="'editFolderWithName' | i18n: folder.name"
bitIconButton="bwi-pencil-square"
class="tw-self-end"
data-testid="edit-folder-button"

View File

@@ -21,6 +21,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
@@ -58,6 +59,7 @@ export class LoginCommand {
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected authService: AuthService,
protected apiService: ApiService,
protected masterPasswordApiService: MasterPasswordApiService,
protected cryptoFunctionService: CryptoFunctionService,
protected environmentService: EnvironmentService,
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
@@ -321,7 +323,7 @@ export class LoginCommand {
})({
type: "input",
name: "token",
message: "New device login code:",
message: "New device verification required. Enter OTP sent to login email:",
});
newDeviceToken = answer.token;
}
@@ -454,7 +456,7 @@ export class LoginCommand {
request.newMasterPasswordHash = newPasswordHash;
request.key = newUserKey[1].encryptedString;
await this.apiService.postPassword(request);
await this.masterPasswordApiService.postPassword(request);
return await this.handleUpdatePasswordSuccessResponse();
} catch (e) {
@@ -491,7 +493,7 @@ export class LoginCommand {
request.newMasterPasswordHash = newPasswordHash;
request.masterPasswordHint = hint;
await this.apiService.putUpdateTempPassword(request);
await this.masterPasswordApiService.putUpdateTempPassword(request);
return await this.handleUpdatePasswordSuccessResponse();
} catch (e) {

View File

@@ -155,6 +155,7 @@ export class Program extends BaseProgram {
this.serviceContainer.loginStrategyService,
this.serviceContainer.authService,
this.serviceContainer.apiService,
this.serviceContainer.masterPasswordApiService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.environmentService,
this.serviceContainer.passwordGenerationService,

View File

@@ -36,6 +36,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import {
AccountServiceImplementation,
@@ -46,6 +47,7 @@ import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import { DeviceTrustService } from "@bitwarden/common/auth/services/device-trust.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation";
import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service";
import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
@@ -280,6 +282,7 @@ export class ServiceContainer {
sdkLoadService: SdkLoadService;
cipherAuthorizationService: CipherAuthorizationService;
ssoUrlService: SsoUrlService;
masterPasswordApiService: MasterPasswordApiServiceAbstraction;
constructor() {
let p = null;
@@ -779,6 +782,7 @@ export class ServiceContainer {
this.encryptService,
this.pinService,
this.accountService,
this.sdkService,
);
this.individualExportService = new IndividualVaultExportService(
@@ -841,6 +845,8 @@ export class ServiceContainer {
this.organizationService,
this.accountService,
);
this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService);
}
async logout() {

View File

@@ -9,11 +9,55 @@ edition = "2021"
publish = false
[workspace.dependencies]
aes = "=0.8.4"
anyhow = "=1.0.94"
arboard = { version = "=3.4.1", default-features = false }
argon2 = "=0.5.3"
base64 = "=0.22.1"
bindgen = "0.71.1"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" }
byteorder = "=1.5.0"
bytes = "1.9.0"
cbc = "=0.1.2"
core-foundation = "=0.10.0"
dirs = "=6.0.0"
ed25519 = "=2.2.3"
embed_plist = "=1.2.2"
futures = "=0.3.31"
hex = "=0.4.3"
homedir = "=0.3.4"
interprocess = "=2.2.1"
keytar = "=0.1.6"
libc = "=0.2.169"
log = "=0.4.25"
napi = "=2.16.15"
napi-build = "=2.1.4"
napi-derive = "=2.16.13"
oo7 = "=0.3.3"
oslog = "=0.2.0"
pin-project = "=1.1.8"
pkcs8 = "=0.10.2"
rand = "=0.8.5"
rsa = "=0.9.6"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
security-framework = "=3.1.0"
security-framework-sys = "=2.13.0"
serde = "=1.0.209"
serde_json = "=1.0.127"
tokio = "=1.43.0"
tokio-util = "=0.7.13"
tokio-stream = "=0.1.15"
sha2 = "=0.10.8"
simplelog = "=0.12.2"
ssh-encoding = "=0.2.0"
ssh-key = {version = "=0.6.7", default-features = false }
sysinfo = "0.33.1"
thiserror = "=1.0.69"
tokio = "=1.43.0"
tokio-stream = "=0.1.15"
tokio-util = "=0.7.13"
typenum = "=1.17.0"
uniffi = "=0.28.3"
widestring = "=1.1.0"
windows = "=0.58.0"
windows-registry = "=0.4.0"
zbus = "=4.4.0"
zbus_polkit = "=4.0.0"

View File

@@ -18,47 +18,47 @@ default = [
manual_test = []
[dependencies]
aes = "=0.8.4"
aes = { workspace = true }
anyhow = { workspace = true }
arboard = { version = "=3.4.1", default-features = false, features = [
arboard = { workspace = true, features = [
"wayland-data-control",
] }
argon2 = { version = "=0.5.3", features = ["zeroize"] }
base64 = "=0.22.1"
byteorder = "=1.5.0"
cbc = { version = "=0.1.2", features = ["alloc"] }
homedir = "=0.3.4"
pin-project = "=1.1.8"
dirs = "=6.0.0"
futures = "=0.3.31"
interprocess = { version = "=2.2.1", features = ["tokio"] }
argon2 = { workspace = true, features = ["zeroize"] }
base64 = { workspace = true }
byteorder = { workspace = true }
cbc = { workspace = true, features = ["alloc"] }
homedir = { workspace = true }
pin-project = { workspace = true }
dirs = { workspace = true }
futures = { workspace = true }
interprocess = { workspace = true, features = ["tokio"] }
log = { workspace = true }
rand = "=0.8.5"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
sha2 = "=0.10.8"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false, features = [
rand = { workspace = true }
russh-cryptovec = { workspace = true }
scopeguard = { workspace = true }
sha2 = { workspace = true }
ssh-encoding = { workspace = true }
ssh-key = { workspace = true, features = [
"encryption",
"ed25519",
"rsa",
"getrandom",
] }
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" }
bitwarden-russh = { workspace = true }
tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] }
tokio-stream = { workspace = true, features = ["net"] }
tokio-util = { workspace = true, features = ["codec"] }
thiserror = { workspace = true }
typenum = "=1.17.0"
pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] }
rsa = "=0.9.6"
ed25519 = { version = "=2.2.3", features = ["pkcs8"] }
bytes = "1.9.0"
sysinfo = { version = "0.33.1", features = ["windows"] }
typenum = { workspace = true }
pkcs8 = { workspace = true, features = ["alloc", "encryption", "pem"] }
rsa = { workspace = true }
ed25519 = { workspace = true, features = ["pkcs8"] }
bytes = { workspace = true }
sysinfo = { workspace = true, features = ["windows"] }
[target.'cfg(windows)'.dependencies]
widestring = { version = "=1.1.0", optional = true }
windows = { version = "=0.58.0", features = [
widestring = { workspace = true, optional = true }
windows = { workspace = true, features = [
"Foundation",
"Security_Credentials_UI",
"Security_Cryptography",
@@ -72,17 +72,17 @@ windows = { version = "=0.58.0", features = [
], optional = true }
[target.'cfg(windows)'.dev-dependencies]
keytar = "=0.1.6"
keytar = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = { version = "=0.10.0", optional = true }
security-framework = { version = "=3.1.0", optional = true }
security-framework-sys = { version = "=2.13.0", optional = true }
core-foundation = { workspace = true, optional = true }
security-framework = { workspace = true, optional = true }
security-framework-sys = { workspace = true, optional = true }
desktop_objc = { path = "../objc" }
[target.'cfg(target_os = "linux")'.dependencies]
oo7 = "=0.3.3"
libc = "=0.2.169"
oo7 = { workspace = true }
libc = { workspace = true }
zbus = { version = "=4.4.0", optional = true }
zbus_polkit = { version = "=4.0.0", optional = true }
zbus = { workspace = true, optional = true }
zbus_polkit = { workspace = true, optional = true }

View File

@@ -1,402 +0,0 @@
use ed25519;
use pkcs8::{
der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument,
};
use ssh_key::{
private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair},
HashAlg, LineEnding,
};
const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----";
const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----";
const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----";
pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier =
ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1");
#[derive(Debug)]
enum KeyType {
Ed25519,
Rsa,
Unknown,
}
pub fn import_key(
encoded_key: String,
password: String,
) -> Result<SshKeyImportResult, anyhow::Error> {
match encoded_key.lines().next() {
Some(PKCS1_HEADER) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
}),
Some(PKCS8_UNENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, None) {
Ok(result) => Ok(result),
Err(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
},
Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) {
Ok(result) => Ok(result),
Err(err) => match err {
SshKeyImportError::PasswordRequired => Ok(SshKeyImportResult {
status: SshKeyImportStatus::PasswordRequired,
ssh_key: None,
}),
SshKeyImportError::WrongPassword => Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
}),
SshKeyImportError::ParsingError => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
},
},
Some(OPENSSH_HEADER) => import_openssh_key(encoded_key, password),
Some(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
None => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
}
}
fn import_pkcs8_key(
encoded_key: String,
password: Option<String>,
) -> Result<SshKeyImportResult, SshKeyImportError> {
let der = match SecretDocument::from_pem(&encoded_key) {
Ok((_, doc)) => doc,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
let decrypted_der = match password.clone() {
Some(password) => {
let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes())
{
Ok(info) => info,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
match encrypted_private_key_info.decrypt(password.as_bytes()) {
Ok(der) => der,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
});
}
}
}
None => der,
};
let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes())
.map_err(|_| SshKeyImportError::ParsingError)?
.algorithm
.oid
{
ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519,
RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa,
_ => KeyType::Unknown,
};
match key_type {
KeyType::Ed25519 => {
let pk: ed25519::KeypairBytes = match password {
Some(password) => {
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
.map_err(|err| match err {
ed25519::pkcs8::Error::EncryptedPrivateKey(_) => {
SshKeyImportError::WrongPassword
}
_ => SshKeyImportError::ParsingError,
})?
}
None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
.map_err(|_| SshKeyImportError::ParsingError)?,
};
let pk: Ed25519Keypair =
Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key));
let private_key = ssh_key::private::PrivateKey::from(pk);
Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
})
}
KeyType::Rsa => {
let pk: rsa::RsaPrivateKey = match password {
Some(password) => {
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
.map_err(|err| match err {
pkcs8::Error::EncryptedPrivateKey(_) => {
SshKeyImportError::WrongPassword
}
_ => SshKeyImportError::ParsingError,
})?
}
None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
.map_err(|_| SshKeyImportError::ParsingError)?,
};
let rsa_keypair: Result<RsaKeypair, ssh_key::Error> = RsaKeypair::try_from(pk);
match rsa_keypair {
Ok(rsa_keypair) => {
let private_key = ssh_key::private::PrivateKey::from(rsa_keypair);
Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key
.to_openssh(LineEnding::LF)
.unwrap()
.to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
})
}
Err(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
}
}
_ => Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
}),
}
}
fn import_openssh_key(
encoded_key: String,
password: String,
) -> Result<SshKeyImportResult, anyhow::Error> {
let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key);
let private_key = match private_key {
Ok(k) => k,
Err(err) => {
match err {
ssh_key::Error::AlgorithmUnknown
| ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
});
}
_ => {}
}
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
if private_key.is_encrypted() && password.is_empty() {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::PasswordRequired,
ssh_key: None,
});
}
let private_key = if private_key.is_encrypted() {
match private_key.decrypt(password.as_bytes()) {
Ok(k) => k,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
});
}
}
} else {
private_key
};
match private_key.to_openssh(LineEnding::LF) {
Ok(private_key_openssh) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key_openssh.to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
}),
Err(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
}
}
#[derive(PartialEq, Debug)]
pub enum SshKeyImportStatus {
/// ssh key was parsed correctly and will be returned in the result
Success,
/// ssh key was parsed correctly but is encrypted and requires a password
PasswordRequired,
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
WrongPassword,
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
ParsingError,
/// ssh key type is not supported
UnsupportedKeyType,
}
pub enum SshKeyImportError {
ParsingError,
PasswordRequired,
WrongPassword,
}
pub struct SshKeyImportResult {
pub status: SshKeyImportStatus,
pub ssh_key: Option<SshKey>,
}
pub struct SshKey {
pub private_key: String,
pub public_key: String,
pub key_fingerprint: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn import_key_ed25519_openssh_unencrypted() {
let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted");
let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_openssh_encrypted() {
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_openssh_unencrypted() {
let private_key = include_str!("./test_keys/rsa_openssh_unencrypted");
let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_openssh_encrypted() {
let private_key = include_str!("./test_keys/rsa_openssh_encrypted");
let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_pkcs8_unencrypted() {
let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted");
let public_key =
include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_pkcs8_unencrypted() {
let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted");
// for whatever reason pkcs8 + rsa does not include the comment in the public key
let public_key =
include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_pkcs8_encrypted() {
let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted");
let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_openssh_encrypted_wrong_password() {
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::WrongPassword);
}
#[test]
fn import_non_key_error() {
let result = import_key("not a key".to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
#[test]
fn import_ecdsa_error() {
let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType);
}
// Putty-exported keys should be supported, but are not due to a parser incompatibility.
// Should this test start failing, please change it to expect a correct key, and
// make sure the documentation support for putty-exported keys this is updated.
// https://bitwarden.atlassian.net/browse/PM-14989
#[test]
fn import_key_ed25519_putty() {
let private_key = include_str!("./test_keys/ed25519_putty_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
// Putty-exported keys should be supported, but are not due to a parser incompatibility.
// Should this test start failing, please change it to expect a correct key, and
// make sure the documentation support for putty-exported keys this is updated.
// https://bitwarden.atlassian.net/browse/PM-14989
#[test]
fn import_key_rsa_openssh_putty() {
let private_key = include_str!("./test_keys/rsa_putty_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
#[test]
fn import_key_rsa_pkcs8_putty() {
let private_key = include_str!("./test_keys/rsa_putty_pkcs1_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType);
}
}

View File

@@ -16,7 +16,6 @@ mod platform_ssh_agent;
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod peercred_unix_listener_stream;
pub mod importer;
pub mod peerinfo;
mod request_parser;

View File

@@ -15,16 +15,16 @@ bench = false
[dependencies]
desktop_core = { path = "../core" }
futures = "=0.3.31"
futures = { workspace = true }
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-util = { workspace = true }
uniffi = { version = "=0.28.3", features = ["cli"] }
uniffi = { workspace = true, features = ["cli"] }
[target.'cfg(target_os = "macos")'.dependencies]
oslog = "=0.2.0"
oslog = { workspace = true }
[build-dependencies]
uniffi = { version = "=0.28.3", features = ["build"] }
uniffi = { workspace = true, features = ["build"] }

View File

@@ -14,12 +14,12 @@ default = []
manual_test = []
[dependencies]
base64 = "=0.22.1"
hex = "=0.4.3"
base64 = { workspace = true }
hex = { workspace = true }
anyhow = { workspace = true }
desktop_core = { path = "../core" }
napi = { version = "=2.16.15", features = ["async"] }
napi-derive = "=2.16.13"
napi = { workspace = true, features = ["async"] }
napi-derive = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true }
@@ -27,7 +27,7 @@ tokio-util = { workspace = true }
tokio-stream = { workspace = true }
[target.'cfg(windows)'.dependencies]
windows-registry = "=0.4.0"
windows-registry = { workspace = true }
[build-dependencies]
napi-build = "=2.1.4"
napi-build = { workspace = true }

View File

@@ -51,22 +51,6 @@ export declare namespace sshagent {
publicKey: string
keyFingerprint: string
}
export const enum SshKeyImportStatus {
/** ssh key was parsed correctly and will be returned in the result */
Success = 0,
/** ssh key was parsed correctly but is encrypted and requires a password */
PasswordRequired = 1,
/** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */
WrongPassword = 2,
/** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */
ParsingError = 3,
/** ssh key type is not supported (e.g. ecdsa) */
UnsupportedKeyType = 4
}
export interface SshKeyImportResult {
status: SshKeyImportStatus
sshKey?: SshKey
}
export interface SshUiRequest {
cipherId?: string
isList: boolean
@@ -79,7 +63,6 @@ export declare namespace sshagent {
export function isRunning(agentState: SshAgentState): boolean
export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void
export function lock(agentState: SshAgentState): void
export function importKey(encodedKey: string, password: string): SshKeyImportResult
export function clearKeys(agentState: SshAgentState): void
export class SshAgentState { }
}

View File

@@ -182,67 +182,6 @@ pub mod sshagent {
pub key_fingerprint: String,
}
impl From<desktop_core::ssh_agent::importer::SshKey> for SshKey {
fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self {
SshKey {
private_key: key.private_key,
public_key: key.public_key,
key_fingerprint: key.key_fingerprint,
}
}
}
#[napi]
pub enum SshKeyImportStatus {
/// ssh key was parsed correctly and will be returned in the result
Success,
/// ssh key was parsed correctly but is encrypted and requires a password
PasswordRequired,
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
WrongPassword,
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
ParsingError,
/// ssh key type is not supported (e.g. ecdsa)
UnsupportedKeyType,
}
impl From<desktop_core::ssh_agent::importer::SshKeyImportStatus> for SshKeyImportStatus {
fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self {
match status {
desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => {
SshKeyImportStatus::Success
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => {
SshKeyImportStatus::PasswordRequired
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => {
SshKeyImportStatus::WrongPassword
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => {
SshKeyImportStatus::ParsingError
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => {
SshKeyImportStatus::UnsupportedKeyType
}
}
}
}
#[napi(object)]
pub struct SshKeyImportResult {
pub status: SshKeyImportStatus,
pub ssh_key: Option<SshKey>,
}
impl From<desktop_core::ssh_agent::importer::SshKeyImportResult> for SshKeyImportResult {
fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self {
SshKeyImportResult {
status: result.status.into(),
ssh_key: result.ssh_key.map(|k| k.into()),
}
}
}
#[napi(object)]
pub struct SshUIRequest {
pub cipher_id: Option<String>,
@@ -359,13 +298,6 @@ pub mod sshagent {
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub fn import_key(encoded_key: String, password: String) -> napi::Result<SshKeyImportResult> {
let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(result.into())
}
#[napi]
pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;

View File

@@ -8,11 +8,11 @@ publish = { workspace = true }
[dependencies]
anyhow = { workspace = true }
desktop_core = { path = "../core" }
futures = "=0.3.31"
futures = { workspace = true }
log = { workspace = true }
simplelog = "=0.12.2"
simplelog = { workspace = true }
tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt"] }
tokio-util = { workspace = true, features = ["codec"] }
[target.'cfg(target_os = "macos")'.dependencies]
embed_plist = "=1.2.2"
embed_plist = { workspace = true }

View File

@@ -6,4 +6,5 @@ version = { workspace = true }
publish = { workspace = true }
[target.'cfg(target_os = "windows")'.build-dependencies]
bindgen = "0.71.1"
bindgen = { workspace = true }

View File

@@ -423,7 +423,7 @@
"enableHardwareAccelerationDesc" | i18n
}}</small>
</div>
<div class="form-group" *ngIf="showSshAgentOption">
<div class="form-group">
<div class="checkbox">
<label for="enableSshAgent">
<input

View File

@@ -22,7 +22,6 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import {
VaultTimeout,
VaultTimeoutAction,
@@ -67,7 +66,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
showAlwaysShowDock = false;
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
showSshAgentOption = false;
showOpenAtLoginOption = false;
isWindows: boolean;
isLinux: boolean;
@@ -223,7 +221,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
return;
}
this.showSshAgentOption = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;

View File

@@ -51,17 +51,13 @@ import {
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { HintComponent } from "../auth/hint.component";
import { LoginDecryptionOptionsComponentV1 } from "../auth/login/login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "../auth/login/login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-request-v1.component";
import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component";
import { SsoComponentV1 } from "../auth/sso-v1.component";
import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { VaultComponent } from "../vault/app/vault/vault.component";
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
import { SendComponent } from "./tools/send/send.component";
/**
@@ -167,33 +163,6 @@ const routes: Routes = [
},
{ path: "accessibility-cookie", component: AccessibilityCookieComponent },
{ path: "set-password", component: SetPasswordComponent },
...unauthUiRefreshSwap(
SsoComponentV1,
AnonLayoutWrapperComponent,
{
path: "sso",
},
{
path: "sso",
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
{
path: "send",
component: SendComponent,
@@ -209,139 +178,10 @@ const routes: Routes = [
component: RemovePasswordComponent,
canActivate: [authGuard],
},
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-with-device",
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
} satisfies AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap(
HintComponent,
AnonLayoutWrapperComponent,
{
path: "hint",
canActivate: [unauthGuardFn()],
},
{
path: "",
children: [
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginComponentV1,
AnonLayoutWrapperComponent,
{
path: "login",
component: LoginComponentV1,
canActivate: [maxAccountsGuardFn()],
},
{
path: "",
children: [
{
path: "login",
canActivate: [maxAccountsGuardFn()],
data: {
pageTitle: {
key: "logInToBitwarden",
},
pageIcon: VaultIcon,
},
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginDecryptionOptionsComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
),
{
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "",
component: AnonLayoutWrapperComponent,
@@ -383,6 +223,110 @@ const routes: Routes = [
},
],
},
{
path: "login",
canActivate: [maxAccountsGuardFn()],
data: {
pageTitle: {
key: "logInToBitwarden",
},
pageIcon: VaultIcon,
},
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
{
path: "sso",
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
} satisfies AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "lock",
canActivate: [lockGuard()],

View File

@@ -10,10 +10,12 @@ import {
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { filter, firstValueFrom, map, Subject, takeUntil, timeout, withLatestFrom } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
@@ -157,7 +159,10 @@ export class AppComponent implements OnInit, OnDestroy {
private stateEventRunnerService: StateEventRunnerService,
private accountService: AccountService,
private organizationService: OrganizationService,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
ngOnInit() {
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {

View File

@@ -46,8 +46,6 @@ import { HeaderComponent } from "./layout/header.component";
import { NavComponent } from "./layout/nav.component";
import { SearchComponent } from "./layout/search/search.component";
import { SharedModule } from "./shared/shared.module";
import { AddEditComponent as SendAddEditComponent } from "./tools/send/add-edit.component";
import { SendComponent } from "./tools/send/send.component";
@NgModule({
imports: [
@@ -60,6 +58,7 @@ import { SendComponent } from "./tools/send/send.component";
DeleteAccountComponent,
UserVerificationComponent,
DecryptionFailureDialogComponent,
NavComponent,
],
declarations: [
AccessibilityCookieComponent,
@@ -76,13 +75,10 @@ import { SendComponent } from "./tools/send/send.component";
FolderAddEditComponent,
HeaderComponent,
HintComponent,
NavComponent,
PasswordHistoryComponent,
PremiumComponent,
RemovePasswordComponent,
SearchComponent,
SendAddEditComponent,
SendComponent,
SetPasswordComponent,
SettingsComponent,
ShareComponent,

View File

@@ -0,0 +1,36 @@
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@Component({
standalone: true,
template: `
<div
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
>
<h1 style="color: black">Select your passkey</h1>
<br />
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="closeModal()"
>
Close
</button>
</div>
`,
})
export class Fido2PlaceholderComponent {
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly router: Router,
) {}
async closeModal() {
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
}
}

View File

@@ -1,10 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterLink, RouterLinkActive } from "@angular/router";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@Component({
selector: "app-nav",
templateUrl: "nav.component.html",
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive],
})
export class NavComponent {
items: any[] = [

View File

@@ -46,6 +46,7 @@ import {
AuthService,
AuthService as AuthServiceAbstraction,
} from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
@@ -102,6 +103,7 @@ import {
BiometricsService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management-ui";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service";
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
@@ -366,6 +368,7 @@ const safeProviders: SafeProvider[] = [
useClass: DesktopSetPasswordJitService,
deps: [
ApiService,
MasterPasswordApiService,
KeyService,
EncryptService,
I18nServiceAbstraction,
@@ -430,6 +433,11 @@ const safeProviders: SafeProvider[] = [
useClass: DesktopLoginApprovalComponentService,
deps: [I18nServiceAbstraction],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsServiceAbstraction, I18nServiceAbstraction],
}),
];
@NgModule({

View File

@@ -1,9 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DatePipe } from "@angular/common";
import { CommonModule, DatePipe } from "@angular/common";
import { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -16,11 +17,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { CalloutModule, DialogService, ToastService } from "@bitwarden/components";
@Component({
selector: "app-send-add-edit",
templateUrl: "add-edit.component.html",
standalone: true,
imports: [CommonModule, JslibModule, ReactiveFormsModule, CalloutModule],
})
export class AddEditComponent extends BaseAddEditComponent {
constructor(

View File

@@ -1,7 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -17,6 +20,7 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service.
import { DialogService, ToastService } from "@bitwarden/components";
import { invokeMenu, RendererMenuItem } from "../../../utils";
import { NavComponent } from "../../layout/nav.component";
import { SearchBarService } from "../../layout/search/search-bar.service";
import { AddEditComponent } from "./add-edit.component";
@@ -32,6 +36,8 @@ const BroadcasterSubscriptionId = "SendComponent";
@Component({
selector: "app-send",
templateUrl: "send.component.html",
standalone: true,
imports: [CommonModule, JslibModule, FormsModule, NavComponent, AddEditComponent],
})
export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy {
@ViewChild(AddEditComponent) addEditComponent: AddEditComponent;

View File

@@ -1,157 +0,0 @@
<div id="login-page" class="page-top-padding">
<form
id="login-page"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
[formGroup]="formGroup"
attr.aria-hidden="{{ showingModal }}"
>
<div id="content" class="content" style="padding-top: 50px">
<a (click)="invalidateEmail()" class="tw-cursor-pointer">
<img class="logo-image" alt="Bitwarden" />
</a>
<p class="lead">{{ "loginOrCreateNewAccount" | i18n }}</p>
<!-- start email -->
<ng-container *ngIf="!validatedEmail; else loginPage">
<div class="box last">
<div class="box-content">
<div class="box-content-row" appBoxRow>
<label for="email">{{ "emailAddress" | i18n }}</label>
<input
id="email"
type="email"
formControlName="email"
appInputVerbatim="false"
(keyup.enter)="continue()"
/>
</div>
</div>
<environment-selector #environmentSelector (onOpenSelfHostedSettings)="settings()">
</environment-selector>
</div>
<div class="checkbox remember-email">
<label for="rememberEmail">
<input
id="rememberEmail"
type="checkbox"
name="rememberEmail"
formControlName="rememberEmail"
/>
{{ "rememberEmail" | i18n }}
</label>
</div>
<div class="buttons with-rows">
<div class="buttons-row">
<button type="button" class="btn primary block" (click)="continue()">
{{ "continue" | i18n }}
</button>
</div>
</div>
<div class="sub-options">
<p class="no-margin">{{ "newAroundHere" | i18n }}</p>
<button type="button" class="text text-primary" routerLink="/signup">
{{ "createAccount" | i18n }}
</button>
</div>
</ng-container>
<ng-template [formGroup]="formGroup" #loginPage>
<div class="box last">
<div class="box-content">
<div class="box-content-row box-content-row-flex" appBoxRow>
<div class="row-main">
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
<input
id="masterPassword"
type="{{ showPassword ? 'text' : 'password' }}"
class="monospaced"
formControlName="masterPassword"
appInputVerbatim
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
[attr.aria-pressed]="showPassword"
(click)="togglePassword()"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
</div>
</div>
</div>
</div>
<div class="box last" [hidden]="!showCaptcha()">
<div class="box-content">
<iframe
id="hcaptcha_iframe"
style="margin-top: 20px"
sandbox="allow-scripts allow-same-origin"
></iframe>
<div class="box-content-row">
<button
class="btn block"
type="button"
routerLink="/accessibility-cookie"
(click)="saveEmailSettings()"
>
<i class="bwi bwi-universal-access" aria-hidden="true"></i>
{{ "loadAccessibilityCookie" | i18n }}
</button>
</div>
</div>
</div>
<div class="buttons with-rows">
<div class="buttons-row">
<button type="submit" class="btn primary block" [disabled]="form.loading">
<b [hidden]="form.loading"
><i class="bwi bwi-sign-in" aria-hidden="true"></i>
{{ "loginWithMasterPassword" | i18n }}</b
>
<i class="bwi bwi-spinner bwi-spin" [hidden]="!form.loading" aria-hidden="true"></i>
</button>
</div>
<div class="buttons-row" *ngIf="showLoginWithDevice">
<button type="button" class="btn block" (click)="startAuthRequestLogin()">
<i class="bwi bwi-mobile" aria-hidden="true"></i>
{{ "logInWithAnotherDevice" | i18n }}
</button>
</div>
<div class="buttons-row">
<button
type="button"
(click)="launchSsoBrowser('desktop', 'bitwarden://sso-callback')"
class="btn block"
>
<i class="bwi bwi-provider" aria-hidden="true"></i>
{{ "enterpriseSingleSignOn" | i18n }}
</button>
</div>
</div>
<div class="sub-options">
<button
type="button"
class="text text-primary password-hint-btn"
routerLink="/hint"
(click)="saveEmailSettings()"
>
{{ "getMasterPasswordHint" | i18n }}
</button>
<div>
<p class="no-margin">{{ "loggingInAs" | i18n }} {{ loggedEmail }}</p>
<a [routerLink]="[]" (click)="toggleValidateEmail(false)">{{ "notYou" | i18n }}</a>
</div>
</div>
</ng-template>
</div>
</form>
</div>
<ng-template #environment></ng-template>

View File

@@ -1,266 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, firstValueFrom, takeUntil, tap } from "rxjs";
import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { EnvironmentComponent } from "../environment.component";
const BroadcasterSubscriptionId = "LoginComponent";
@Component({
selector: "app-login",
templateUrl: "login-v1.component.html",
})
export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDestroy {
@ViewChild("environment", { read: ViewContainerRef, static: true })
environmentModal: ViewContainerRef;
protected componentDestroyed$: Subject<void> = new Subject();
webVaultHostname = "";
showingModal = false;
private deferFocus: boolean = null;
get loggedEmail() {
return this.formGroup.value.email;
}
constructor(
devicesApiService: DevicesApiServiceAbstraction,
appIdService: AppIdService,
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
i18nService: I18nService,
syncService: SyncService,
private modalService: ModalService,
platformUtilsService: PlatformUtilsService,
stateService: StateService,
environmentService: EnvironmentService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
cryptoFunctionService: CryptoFunctionService,
private broadcasterService: BroadcasterService,
ngZone: NgZone,
private messagingService: MessagingService,
logService: LogService,
formBuilder: FormBuilder,
formValidationErrorService: FormValidationErrorsService,
route: ActivatedRoute,
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
toastService: ToastService,
private configService: ConfigService,
) {
super(
devicesApiService,
appIdService,
loginStrategyService,
router,
platformUtilsService,
i18nService,
stateService,
environmentService,
passwordGenerationService,
cryptoFunctionService,
logService,
ngZone,
formBuilder,
formValidationErrorService,
route,
loginEmailService,
ssoLoginService,
toastService,
);
this.onSuccessfulLogin = () => {
return syncService.fullSync(true);
};
}
async ngOnInit() {
this.listenForUnauthUiRefreshFlagChanges();
await super.ngOnInit();
await this.getLoginWithDevice(this.loggedEmail);
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
case "windowHidden":
this.onWindowHidden();
break;
case "windowIsFocused":
if (this.deferFocus === null) {
this.deferFocus = !message.windowIsFocused;
if (!this.deferFocus) {
this.focusInput();
}
} else if (this.deferFocus && message.windowIsFocused) {
this.focusInput();
this.deferFocus = false;
}
break;
default:
}
});
});
this.messagingService.send("getWindowIsFocused");
}
ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.componentDestroyed$.next();
this.componentDestroyed$.complete();
}
private listenForUnauthUiRefreshFlagChanges() {
this.configService
.getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh)
.pipe(
tap(async (flag) => {
if (flag) {
const qParams = await firstValueFrom(this.route.queryParams);
const uniqueQueryParams = {
...qParams,
// adding a unique timestamp to the query params to force a reload
t: new Date().getTime().toString(),
};
await this.router.navigate(["/"], {
queryParams: uniqueQueryParams,
});
}
}),
takeUntil(this.componentDestroyed$),
)
.subscribe();
}
async settings() {
const [modal, childComponent] = await this.modalService.openViewRef(
EnvironmentComponent,
this.environmentModal,
);
modal.onShown.pipe(takeUntil(this.componentDestroyed$)).subscribe(() => {
this.showingModal = true;
});
modal.onClosed.pipe(takeUntil(this.componentDestroyed$)).subscribe(() => {
this.showingModal = false;
});
// eslint-disable-next-line rxjs/no-async-subscribe
childComponent.onSaved.pipe(takeUntil(this.componentDestroyed$)).subscribe(async () => {
modal.close();
await this.getLoginWithDevice(this.loggedEmail);
});
}
onWindowHidden() {
this.showPassword = false;
}
async continue() {
await super.validateEmail();
if (!this.formGroup.controls.email.valid) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
message: this.i18nService.t("invalidEmail"),
});
return;
}
this.focusInput();
}
async submit() {
if (!this.validatedEmail) {
return;
}
await super.submit();
if (this.captchaSiteKey) {
const content = document.getElementById("content") as HTMLDivElement;
content.setAttribute("style", "width:335px");
}
}
private focusInput() {
const email = this.loggedEmail;
document.getElementById(email == null || email === "" ? "email" : "masterPassword")?.focus();
}
async launchSsoBrowser(clientId: string, ssoRedirectUri: string) {
if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) {
return super.launchSsoBrowser(clientId, ssoRedirectUri);
}
const email = this.formGroup.controls.email.value;
// Save off email for SSO
await this.ssoLoginService.setSsoEmail(email);
// Generate necessary sso params
const passwordOptions: any = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
};
const state = await this.passwordGenerationService.generatePassword(passwordOptions);
const ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256");
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
// Save sso params
await this.ssoLoginService.setSsoState(state);
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
try {
await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state, email);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccured"),
this.i18nService.t("ssoError"),
);
}
}
/**
* Force the validatedEmail flag to false, which will show the login page.
*/
invalidateEmail() {
this.validatedEmail = false;
}
}

View File

@@ -1,80 +0,0 @@
<div id="login-with-device-page">
<div id="content" class="content">
<img class="logo-image" alt="Bitwarden" />
<ng-container *ngIf="state == StateEnum.StandardAuthRequest">
<p class="lead text-center">{{ "logInRequestSent" | i18n }}</p>
<div class="box last">
<div class="box-content">
<div class="box-content-row" appBoxRow>
<div class="section">
<p class="section">
{{ "notificationSentDevicePart1" | i18n }}
<a
bitLink
linkType="primary"
class="tw-cursor-pointer"
[href]="deviceManagementUrl"
target="_blank"
rel="noreferrer"
>{{ "notificationSentDeviceAnchor" | i18n }}</a
>. {{ "notificationSentDevicePart2" | i18n }}
</p>
</div>
<div class="fingerprint section">
<h4>{{ "fingerprintPhraseHeader" | i18n }}</h4>
<code>{{ fingerprintPhrase }}</code>
</div>
<div class="section" *ngIf="showResendNotification">
<a [routerLink]="[]" disabled="true" (click)="startAuthRequestLogin()">{{
"resendNotification" | i18n
}}</a>
</div>
<div class="sub-options another-method">
<p class="no-margin description-text">
{{ "needAnotherOption" | i18n }}
<a type="button" class="text text-primary" (click)="back()">
{{ "viewAllLoginOptions" | i18n }}
</a>
</p>
</div>
</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="state == StateEnum.AdminAuthRequest">
<p class="lead text-center">{{ "adminApprovalRequested" | i18n }}</p>
<div class="box last">
<div class="box-content">
<div class="box-content-row" appBoxRow>
<div class="section">
<p class="section">{{ "adminApprovalRequestSentToAdmins" | i18n }}</p>
<p class="section">{{ "youWillBeNotifiedOnceApproved" | i18n }}</p>
</div>
<div class="fingerprint section">
<h4>{{ "fingerprintPhraseHeader" | i18n }}</h4>
<code>{{ fingerprintPhrase }}</code>
</div>
<div class="sub-options another-method">
<p class="no-margin description-text">
{{ "troubleLoggingIn" | i18n }}
<a type="button" class="text text-primary" (click)="back()">
{{ "viewAllLoginOptions" | i18n }}
</a>
</p>
</div>
</div>
</div>
</div>
</ng-container>
</div>
</div>
<ng-template #environment></ng-template>

View File

@@ -1,117 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Location } from "@angular/common";
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { Router } from "@angular/router";
import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import {
AuthRequestServiceAbstraction,
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KeyService } from "@bitwarden/key-management";
import { EnvironmentComponent } from "../environment.component";
@Component({
selector: "app-login-via-auth-request",
templateUrl: "login-via-auth-request-v1.component.html",
})
export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 {
@ViewChild("environment", { read: ViewContainerRef, static: true })
environmentModal: ViewContainerRef;
showingModal = false;
constructor(
protected router: Router,
keyService: KeyService,
cryptoFunctionService: CryptoFunctionService,
appIdService: AppIdService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
apiService: ApiService,
authService: AuthService,
logService: LogService,
environmentService: EnvironmentService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
anonymousHubService: AnonymousHubService,
validationService: ValidationService,
private modalService: ModalService,
syncService: SyncService,
loginEmailService: LoginEmailServiceAbstraction,
deviceTrustService: DeviceTrustServiceAbstraction,
authRequestService: AuthRequestServiceAbstraction,
loginStrategyService: LoginStrategyServiceAbstraction,
accountService: AccountService,
private location: Location,
toastService: ToastService,
) {
super(
router,
keyService,
cryptoFunctionService,
appIdService,
passwordGenerationService,
apiService,
authService,
logService,
environmentService,
i18nService,
platformUtilsService,
anonymousHubService,
validationService,
accountService,
loginEmailService,
deviceTrustService,
authRequestService,
loginStrategyService,
toastService,
);
this.onSuccessfulLogin = () => {
return syncService.fullSync(true);
};
}
async settings() {
const [modal, childComponent] = await this.modalService.openViewRef(
EnvironmentComponent,
this.environmentModal,
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
modal.onShown.subscribe(() => {
this.showingModal = true;
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
modal.onClosed.subscribe(() => {
this.showingModal = false;
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
childComponent.onSaved.subscribe(() => {
modal.close();
});
}
back() {
this.location.back();
}
}

View File

@@ -6,17 +6,10 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components
import { SharedModule } from "../../app/shared/shared.module";
import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "./login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component";
@NgModule({
imports: [SharedModule, RouterModule],
declarations: [
LoginComponentV1,
LoginViaAuthRequestComponentV1,
EnvironmentSelectorComponent,
LoginDecryptionOptionsComponentV1,
],
exports: [LoginComponentV1, LoginViaAuthRequestComponentV1],
declarations: [EnvironmentSelectorComponent, LoginDecryptionOptionsComponentV1],
exports: [],
})
export class LoginModule {}

View File

@@ -9,6 +9,7 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -40,6 +41,7 @@ export class SetPasswordComponent extends BaseSetPasswordComponent implements On
policyApiService: PolicyApiServiceAbstraction,
policyService: PolicyService,
router: Router,
masterPasswordApiService: MasterPasswordApiService,
syncService: SyncService,
route: ActivatedRoute,
private broadcasterService: BroadcasterService,
@@ -63,6 +65,7 @@ export class SetPasswordComponent extends BaseSetPasswordComponent implements On
policyApiService,
policyService,
router,
masterPasswordApiService,
apiService,
syncService,
route,

View File

@@ -25,16 +25,6 @@ export class MainSshAgentService {
private logService: LogService,
private messagingService: MessagingService,
) {
ipcMain.handle(
"sshagent.importkey",
async (
event: any,
{ privateKey, password }: { privateKey: string; password?: string },
): Promise<sshagent.SshKeyImportResult> => {
return sshagent.importKey(privateKey, password);
},
);
ipcMain.handle("sshagent.init", async (event: any, message: any) => {
this.init();
});

View File

@@ -24,8 +24,6 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
@@ -58,23 +56,13 @@ export class SshAgentService implements OnDestroy {
private toastService: ToastService,
private i18nService: I18nService,
private desktopSettingsService: DesktopSettingsService,
private configService: ConfigService,
private accountService: AccountService,
) {}
async init() {
this.configService
.getFeatureFlag$(FeatureFlag.SSHAgent)
.pipe(
concatMap(async (enabled) => {
this.isFeatureFlagEnabled = enabled;
if (!(await ipc.platform.sshAgent.isLoaded()) && enabled) {
await ipc.platform.sshAgent.init();
}
}),
takeUntil(this.destroy$),
)
.subscribe();
if (!(await ipc.platform.sshAgent.isLoaded())) {
await ipc.platform.sshAgent.init();
}
await this.initListeners();
}

View File

@@ -3060,9 +3060,6 @@
"adminApprovalRequestSentToAdmins": {
"message": "Your request has been sent to your admin."
},
"youWillBeNotifiedOnceApproved": {
"message": "You will be notified once approved."
},
"troubleLoggingIn": {
"message": "Trouble logging in?"
},
@@ -3532,9 +3529,6 @@
"unknownApplication": {
"message": "An application"
},
"sshKeyPasswordUnsupported": {
"message": "Importing password protected SSH keys is not yet supported"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
},
@@ -3544,7 +3538,7 @@
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
},
"sshKeyPasted": {
"sshKeyImported": {
"message": "SSH key imported successfully"
},
"fileSavedToDevice": {

View File

@@ -284,6 +284,8 @@ export class Main {
this.migrationRunner.run().then(
async () => {
await this.toggleHardwareAcceleration();
// Reset modal mode to make sure main window is displayed correctly
await this.desktopSettingsService.resetInModalMode();
await this.windowMain.init();
await this.i18nService.init();
await this.messagingMain.init();

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
import { firstValueFrom } from "rxjs";
@@ -9,6 +10,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev } from "../utils";
import { WindowMain } from "./window.main";
@@ -49,6 +51,11 @@ export class TrayMain {
label: this.i18nService.t("showHide"),
click: () => this.toggleWindow(),
},
{
visible: isDev(),
label: "Fake Popup",
click: () => this.fakePopup(),
},
{ type: "separator" },
{
label: this.i18nService.t("exit"),
@@ -190,7 +197,7 @@ export class TrayMain {
this.hideDock();
}
} else {
this.windowMain.win.show();
this.windowMain.show();
if (this.isDarwin()) {
this.showDock();
}
@@ -203,4 +210,38 @@ export class TrayMain {
this.windowMain.win.close();
}
}
/**
* This method is used to test modal behavior during development and could be removed in the future.
* @returns
*/
private async fakePopup() {
if (this.windowMain.win == null || this.windowMain.win.isDestroyed()) {
await this.windowMain.createWindow("modal-app");
return;
}
// Restyle existing
const existingWin = this.windowMain.win;
await this.desktopSettingsService.setInModalMode(true);
await existingWin.loadURL(
url.format({
protocol: "file:",
//pathname: `${__dirname}/index.html`,
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: "/passkeys",
query: {
redirectUrl: "/passkeys",
},
}),
{
userAgent: cleanUserAgent(existingWin.webContents.userAgent),
},
);
existingWin.once("ready-to-show", () => {
existingWin.show();
});
}
}

View File

@@ -5,7 +5,7 @@ import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron";
import { firstValueFrom } from "rxjs";
import { concatMap, firstValueFrom, pairwise } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
@@ -14,6 +14,7 @@ import { processisolations } from "@bitwarden/desktop-napi";
import { BiometricStateService } from "@bitwarden/key-management";
import { WindowState } from "../platform/models/domain/window-state";
import { applyMainWindowStyles, applyPopupModalStyles } from "../platform/popup-modal-styles";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev, isLinux, isMac, isMacAppStore, isWindows } from "../utils";
@@ -77,6 +78,24 @@ export class WindowMain {
}
});
this.desktopSettingsService.inModalMode$
.pipe(
pairwise(),
concatMap(async ([lastValue, newValue]) => {
if (lastValue && !newValue) {
// Reset the window state to the main window state
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
// Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode.
this.win.hide();
} else if (!lastValue && newValue) {
// Apply the popup modal styles
applyPopupModalStyles(this.win);
this.win.show();
}
}),
)
.subscribe();
this.desktopSettingsService.preventScreenshots$.subscribe((prevent) => {
if (this.win == null) {
return;
@@ -182,7 +201,20 @@ export class WindowMain {
});
}
async createWindow(): Promise<void> {
/// Show the window with main window styles
show() {
if (this.win != null) {
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
this.win.show();
}
}
/**
* Creates the main window. The template argument is used to determine the styling of the window and what url will be loaded.
* When the template is "modal-app", the window will be styled as a modal and the passkeys page will be loaded.
* TODO: We might want to refactor the template argument to accomodate more target pages, e.g. ssh-agent.
*/
async createWindow(template: "full-app" | "modal-app" = "full-app"): Promise<void> {
this.windowStates[mainWindowSizeKey] = await this.getWindowState(
this.defaultWidth,
this.defaultHeight,
@@ -216,6 +248,12 @@ export class WindowMain {
},
});
if (template === "modal-app") {
applyPopupModalStyles(this.win);
} else {
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
}
this.win.webContents.on("dom-ready", () => {
this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0;
});
@@ -225,21 +263,41 @@ export class WindowMain {
}
// Show it later since it might need to be maximized.
this.win.show();
// use once event to avoid flash on unstyled content.
this.win.once("ready-to-show", () => {
this.win.show();
});
// and load the index.html of the app.
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.win.loadURL(
url.format({
protocol: "file:",
pathname: path.join(__dirname, "/index.html"),
slashes: true,
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
if (template === "full-app") {
// and load the index.html of the app.
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
void this.win.loadURL(
url.format({
protocol: "file:",
pathname: path.join(__dirname, "/index.html"),
slashes: true,
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
} else {
// we're in modal mode - load the passkeys page
await this.win.loadURL(
url.format({
protocol: "file:",
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: "/passkeys",
query: {
redirectUrl: "/passkeys",
},
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
}
// Open the DevTools.
if (isDev()) {
@@ -336,6 +394,12 @@ export class WindowMain {
return;
}
const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$);
if (inModalMode) {
return;
}
try {
const bounds = win.getBounds();
@@ -346,9 +410,14 @@ export class WindowMain {
}
}
this.windowStates[configKey].isMaximized = win.isMaximized();
// We treat fullscreen as maximized (would be even better to store isFullscreen as its own flag).
this.windowStates[configKey].isMaximized = win.isMaximized() || win.isFullScreen();
this.windowStates[configKey].displayBounds = screen.getDisplayMatching(bounds).bounds;
// Maybe store these as well?
// win.isFocused();
// win.isVisible();
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
this.windowStates[configKey].x = bounds.x;
this.windowStates[configKey].y = bounds.y;

View File

@@ -0,0 +1,52 @@
import { BrowserWindow } from "electron";
import { WindowState } from "./models/domain/window-state";
// change as needed, however limited by mainwindow minimum size
const popupWidth = 680;
const popupHeight = 500;
export function applyPopupModalStyles(window: BrowserWindow) {
window.unmaximize();
window.setSize(popupWidth, popupHeight);
window.center();
window.setWindowButtonVisibility?.(false);
window.setMenuBarVisibility?.(false);
window.setResizable(false);
window.setAlwaysOnTop(true);
// Adjusting from full screen is a bit more hassle
if (window.isFullScreen()) {
window.setFullScreen(false);
window.once("leave-full-screen", () => {
window.setSize(popupWidth, popupHeight);
window.center();
});
}
}
export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) {
window.setMinimumSize(680, 500);
// need to guard against null/undefined values
if (existingWindowState?.width && existingWindowState?.height) {
window.setSize(existingWindowState.width, existingWindowState.height);
}
if (existingWindowState?.x && existingWindowState?.y) {
window.setPosition(existingWindowState.x, existingWindowState.y);
}
window.setWindowButtonVisibility?.(true);
window.setMenuBarVisibility?.(true);
window.setResizable(true);
window.setAlwaysOnTop(false);
// We're currently not recovering the maximized state, mostly due to conflicts with hiding the window.
// window.setFullScreen(existingWindowState.isMaximized);
// if (existingWindowState.isMaximized) {
// window.maximize();
// }
}

View File

@@ -1,4 +1,3 @@
import { sshagent as ssh } from "desktop_native/napi";
import { ipcRenderer } from "electron";
import { DeviceType } from "@bitwarden/common/enums";
@@ -64,13 +63,6 @@ const sshAgent = {
clearKeys: async () => {
return await ipcRenderer.invoke("sshagent.clearkeys");
},
importKey: async (key: string, password: string): Promise<ssh.SshKeyImportResult> => {
const res = await ipcRenderer.invoke("sshagent.importkey", {
privateKey: key,
password: password,
});
return res;
},
isLoaded(): Promise<boolean> {
return ipcRenderer.invoke("sshagent.isloaded");
},

View File

@@ -75,6 +75,10 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "
clearOn: [], // User setting, no need to clear
});
const IN_MODAL_MODE = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "inModalMode", {
deserializer: (b) => b,
});
const PREVENT_SCREENSHOTS = new KeyDefinition<boolean>(
DESKTOP_SETTINGS_DISK,
"preventScreenshots",
@@ -170,6 +174,10 @@ export class DesktopSettingsService {
*/
minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean));
private readonly inModalModeState = this.stateProvider.getGlobal(IN_MODAL_MODE);
inModalMode$ = this.inModalModeState.state$.pipe(map(Boolean));
constructor(private stateProvider: StateProvider) {
this.window$ = this.windowState.state$.pipe(
map((window) =>
@@ -178,6 +186,14 @@ export class DesktopSettingsService {
);
}
/**
* This is used to clear the setting on application start to make sure we don't end up
* stuck in modal mode if the application is force-closed in modal mode.
*/
async resetInModalMode() {
await this.inModalModeState.update(() => false);
}
async setHardwareAcceleration(enabled: boolean) {
await this.hwState.update(() => enabled);
}
@@ -286,6 +302,14 @@ export class DesktopSettingsService {
await this.stateProvider.getUser(userId, MINIMIZE_ON_COPY).update(() => value);
}
/**
* Sets the modal mode of the application. Setting this changes the windows-size and other properties.
* @param value `true` if the application is in modal mode, `false` if it is not.
*/
async setInModalMode(value: boolean) {
await this.inModalModeState.update(() => value);
}
/**
* Sets the setting for whether or not the screenshot protection is enabled.
* @param value `true` if the screenshot protection is enabled, `false` if it is not.

View File

@@ -1,7 +1,6 @@
@import "variables.scss";
#login-page,
#login-with-device-page,
#lock-page,
#sso-page,
#set-password-page,
@@ -191,7 +190,6 @@
}
#login-page,
#login-with-device-page,
#login-decryption-options-page {
flex-direction: column;
justify-content: unset;
@@ -222,41 +220,6 @@
}
}
#login-with-device-page {
.content {
display: block;
padding-top: 70px;
width: 350px !important;
.fingerprint {
margin: auto;
width: 315px;
.fingerpint-header {
padding-left: 15px;
}
}
.section {
margin-bottom: 30px;
}
.another-method {
display: flex;
margin: auto;
.description-text {
padding-right: 5px;
}
}
code {
@include themify($themes) {
color: themed("codeColor");
}
}
}
}
#login-approval-page {
.section-title {
padding: 20px;

View File

@@ -512,6 +512,15 @@
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
></i>
</button>
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'importSshKeyFromClipboard' | i18n }}"
(click)="importSshKeyFromClipboard()"
>
<i class="bwi bwi-lg bwi-paste" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box-content-row box-content-row-flex" appBoxRow>
@@ -559,16 +568,6 @@
</button>
</div>
</div>
<div class="box-content-row box-content-row-flex" appBoxRow>
<button
type="button"
class="row-btn"
appStopClick
(click)="importSshKeyFromClipboard()"
>
{{ "importSshKeyFromClipboard" | i18n }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -3,8 +3,6 @@
import { DatePipe } from "@angular/common";
import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { NgForm } from "@angular/forms";
import { sshagent as sshAgent } from "desktop_native/napi";
import { lastValueFrom } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
@@ -25,8 +23,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui";
import { PasswordRepromptService } from "@bitwarden/vault";
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "AddEditComponent";
@@ -60,6 +57,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
toastService: ToastService,
cipherAuthorizationService: CipherAuthorizationService,
sdkService: SdkService,
sshImportPromptService: SshImportPromptService,
) {
super(
cipherService,
@@ -82,6 +80,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
cipherAuthorizationService,
toastService,
sdkService,
sshImportPromptService,
);
}
@@ -159,69 +158,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
this.cipher.revisionDate = cipher.revisionDate;
}
async importSshKeyFromClipboard(password: string = "") {
const key = await this.platformUtilsService.readFromClipboard();
const parsedKey = await ipc.platform.sshAgent.importKey(key, password);
if (parsedKey == null) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("invalidSshKey"),
});
return;
}
switch (parsedKey.status) {
case sshAgent.SshKeyImportStatus.ParsingError:
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("invalidSshKey"),
});
return;
case sshAgent.SshKeyImportStatus.UnsupportedKeyType:
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("sshKeyTypeUnsupported"),
});
return;
case sshAgent.SshKeyImportStatus.PasswordRequired:
case sshAgent.SshKeyImportStatus.WrongPassword:
if (password !== "") {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("sshKeyWrongPassword"),
});
} else {
password = await this.getSshKeyPassword();
if (password === "") {
return;
}
await this.importSshKeyFromClipboard(password);
}
return;
default:
this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey;
this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey;
this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint;
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("sshKeyPasted"),
});
}
}
async getSshKeyPassword(): Promise<string> {
const dialog = this.dialogService.open<string>(SshKeyPasswordPromptComponent, {
ariaModal: true,
});
return await lastValueFrom(dialog.closed);
}
truncateString(value: string, length: number) {
return value.length > length ? value.substring(0, length) + "..." : value;
}

View File

@@ -82,7 +82,6 @@
<li
class="filter-option"
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SshKey }"
*ngIf="isSshKeysEnabled"
>
<span class="filter-buttons">
<button

View File

@@ -1,21 +1,13 @@
import { Component, OnInit } from "@angular/core";
import { Component } from "@angular/core";
import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/type-filter.component";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@Component({
selector: "app-type-filter",
templateUrl: "type-filter.component.html",
})
export class TypeFilterComponent extends BaseTypeFilterComponent implements OnInit {
isSshKeysEnabled = false;
constructor(private configService: ConfigService) {
export class TypeFilterComponent extends BaseTypeFilterComponent {
constructor() {
super();
}
async ngOnInit(): Promise<void> {
this.isSshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
}
}

View File

@@ -84,7 +84,7 @@
</ng-container>
<small *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>

View File

@@ -45,22 +45,16 @@
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header>
<div class="row">
<div class="col-3" *ngIf="!hideVaultFilters">
<div class="groupings">
<div class="content">
<div class="inner-content">
<app-organization-vault-filter
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
</div>
</div>
<div class="tw-flex tw-flex-row">
<div class="tw-w-1/4 tw-mr-5" *ngIf="!hideVaultFilters">
<app-organization-vault-filter
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
<div [class]="hideVaultFilters ? 'col-12' : 'col-9'">
<div [class]="hideVaultFilters ? 'tw-w-4/5' : 'tw-w-3/4'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"
@@ -140,7 +134,7 @@
*ngIf="performingInitialLoad"
>
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>

View File

@@ -24,6 +24,7 @@ import {
switchMap,
takeUntil,
tap,
catchError,
} from "rxjs/operators";
import {
@@ -76,6 +77,7 @@ import {
PasswordRepromptService,
} from "@bitwarden/vault";
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
import {
ResellerWarning,
ResellerWarningService,
@@ -256,6 +258,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private organizationBillingService: OrganizationBillingServiceAbstraction,
private resellerWarningService: ResellerWarningService,
private accountService: AccountService,
private billingNotificationService: BillingNotificationService,
) {}
async ngOnInit() {
@@ -636,12 +639,18 @@ export class VaultComponent implements OnInit, OnDestroy {
combineLatest([
of(org),
this.organizationApiService.getSubscription(org.id),
this.organizationBillingService.getPaymentSource(org.id),
from(this.organizationBillingService.getPaymentSource(org.id)).pipe(
catchError((error: unknown) => {
this.billingNotificationService.handleError(error);
return of(null);
}),
),
]),
),
map(([org, sub, paymentSource]) => {
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource);
}),
map(([org, sub, paymentSource]) =>
this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource),
),
filter((result) => result !== null),
);
this.resellerWarning$ = organization$.pipe(

View File

@@ -124,7 +124,7 @@
buttonType="primary"
[disabled]="loading || dialogReadonly"
>
{{ "save" | i18n }}
{{ buttonDisplayName | i18n }}
</button>
<button
type="button"

View File

@@ -24,6 +24,8 @@ import {
OrganizationUserUserMiniResponse,
CollectionResponse,
CollectionView,
CollectionService,
Collection,
} from "@bitwarden/admin-console/common";
import {
getOrganizationById,
@@ -32,13 +34,17 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SelectModule, BitValidators, DialogService, ToastService } from "@bitwarden/components";
import { openChangePlanDialog } from "../../../../../billing/organizations/change-plan-dialog.component";
import { SharedModule } from "../../../../../shared";
import { GroupApiService, GroupView } from "../../../core";
import { freeOrgCollectionLimitValidator } from "../../validators/free-org-collection-limit.validator";
import { PermissionMode } from "../access-selector/access-selector.component";
import {
AccessItemType,
@@ -55,6 +61,19 @@ export enum CollectionDialogTabType {
Access = 1,
}
/**
* Enum representing button labels for the "Add New Collection" dialog.
*
* @readonly
* @enum {string}
*/
enum ButtonType {
/** Displayed when the user has reached the maximum number of collections allowed for the organization. */
Upgrade = "upgrade",
/** Displayed when the user can still add more collections within the allowed limit. */
Save = "save",
}
export interface CollectionDialogParams {
collectionId?: string;
organizationId: string;
@@ -78,6 +97,7 @@ export enum CollectionDialogAction {
Saved = "saved",
Canceled = "canceled",
Deleted = "deleted",
Upgrade = "upgrade",
}
@Component({
@@ -107,6 +127,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
protected PermissionMode = PermissionMode;
protected showDeleteButton = false;
protected showAddAccessWarning = false;
protected collections: Collection[];
protected buttonDisplayName: ButtonType = ButtonType.Save;
private orgExceedingCollectionLimit!: Organization;
constructor(
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
@@ -122,6 +145,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private accountService: AccountService,
private toastService: ToastService,
private collectionService: CollectionService,
private configService: ConfigService,
) {
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
}
@@ -151,6 +176,23 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.patchValue({ selectedOrg: this.params.organizationId });
await this.loadOrg(this.params.organizationId);
}
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
);
if (isBreadcrumbEventLogsEnabled) {
this.collections = await this.collectionService.getAll();
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(this.organizations$, this.collections, this.i18nService),
);
this.formGroup.updateValueAndValidity();
}
this.organizationSelected.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((_) => {
this.organizationSelected.markAsTouched();
this.formGroup.updateValueAndValidity();
});
}
async loadOrg(orgId: string) {
@@ -263,6 +305,10 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
});
}
get organizationSelected() {
return this.formGroup.controls.selectedOrg;
}
protected get collectionId() {
return this.params.collectionId;
}
@@ -287,6 +333,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.markAllAsTouched();
if (this.buttonDisplayName == ButtonType.Upgrade) {
this.close(CollectionDialogAction.Upgrade);
this.changePlan(this.orgExceedingCollectionLimit);
return;
}
if (this.formGroup.invalid) {
const accessTabError = this.formGroup.controls.access.hasError("managePermissionRequired");
@@ -369,6 +421,16 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private changePlan(org: Organization) {
openChangePlanDialog(this.dialogService, {
data: {
organizationId: org.id,
subscription: null,
productTierType: org.productTierType,
},
});
}
private handleAddAccessWarning(): boolean {
if (
!this.organization?.allowAdminAccessToAllCollectionItems &&

View File

@@ -0,0 +1,78 @@
import { AbstractControl, FormControl, ValidationErrors } from "@angular/forms";
import { lastValueFrom, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { freeOrgCollectionLimitValidator } from "./free-org-collection-limit.validator";
describe("freeOrgCollectionLimitValidator", () => {
let i18nService: I18nService;
beforeEach(() => {
i18nService = {
t: (key: string) => key,
} as any;
});
it("returns null if organization is not found", async () => {
const orgs: Organization[] = [];
const validator = freeOrgCollectionLimitValidator(of(orgs), [], i18nService);
const control = new FormControl("org-id");
const result: Observable<ValidationErrors> = validator(control) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if control is not an instance of FormControl", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const control = {} as AbstractControl;
const result: Observable<ValidationErrors | null> = validator(
control,
) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if control is not provided", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const result: Observable<ValidationErrors | null> = validator(
undefined as any,
) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if organization has not reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 2 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;
const value = await lastValueFrom(result$);
expect(value).toBeNull();
});
it("returns error if organization has reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 1 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;
const value = await lastValueFrom(result$);
expect(value).toEqual({
cannotCreateCollections: { message: "cannotCreateCollection" },
});
});
});

View File

@@ -0,0 +1,44 @@
import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms";
import { map, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
export function freeOrgCollectionLimitValidator(
orgs: Observable<Organization[]>,
collections: Collection[],
i18nService: I18nService,
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!(control instanceof FormControl)) {
return of(null);
}
const orgId = control.value;
if (!orgId) {
return of(null);
}
return orgs.pipe(
map((organizations) => organizations.find((org) => org.id === orgId)),
map((org) => {
if (!org) {
return null;
}
const orgCollections = collections.filter((c) => c.organizationId === org.id);
const hasReachedLimit = org.maxCollections === orgCollections.length;
if (hasReachedLimit) {
return {
cannotCreateCollections: { message: i18nService.t("cannotCreateCollection") },
};
}
return null;
}),
);
};
}

View File

@@ -2,11 +2,13 @@
// @ts-strict-ignore
import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import * as jq from "jquery";
import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@@ -95,7 +97,10 @@ export class AppComponent implements OnDestroy, OnInit {
private apiService: ApiService,
private appIdService: AppIdService,
private processReloadService: ProcessReloadServiceAbstraction,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
ngOnInit() {
this.i18nService.locale$.pipe(takeUntil(this.destroy$)).subscribe((locale) => {

View File

@@ -1,129 +0,0 @@
<form
[bitSubmit]="submitForm.bind(null, false)"
[appApiAction]="formPromise"
[formGroup]="formGroup"
>
<ng-container *ngIf="!validatedEmail">
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
<input bitInput type="email" formControlName="email" appAutofocus />
</bit-form-field>
</div>
<div class="tw-mb-3 tw-flex tw-items-start">
<bit-form-control class="tw-mb-0">
<input type="checkbox" bitCheckbox formControlName="rememberEmail" />
<bit-label>{{ "rememberEmail" | i18n }}</bit-label>
</bit-form-control>
</div>
<div class="tw-mb-3">
<button
bitButton
type="submit"
buttonType="primary"
class="tw-w-full"
(click)="validateEmail()"
>
<span> {{ "continue" | i18n }} </span>
</button>
</div>
<div class="tw-mb-3 tw-flex tw-flex-col tw-items-center tw-justify-center">
<p class="tw-mb-3">{{ "or" | i18n }}</p>
<a
bitLink
block
linkType="primary"
routerLink="/login-with-passkey"
(mousedown)="$event.preventDefault()"
>
<span><i class="bwi bwi-passkey"></i> {{ "logInWithPasskey" | i18n }}</span>
</a>
</div>
<hr />
<p class="tw-m-0 tw-text-sm">
{{ "newAroundHere" | i18n }}
<!-- Two notes:
(1) We check the value and validity of email so we don't send an invalid email to autofill
on load of register for both enter and mouse based navigation
(2) We use mousedown to trigger navigation so that the onBlur form validation does not fire
and move the create account link down the page on click which causes the user to miss actually
clicking on the link. Mousedown fires before onBlur.
-->
<a
bitLink
routerLink="/signup"
[queryParams]="emailFormControl.valid ? { email: emailFormControl.value } : {}"
(mousedown)="goToRegister()"
>
{{ "createAccount" | i18n }}
</a>
</p>
</ng-container>
<div [ngClass]="{ 'tw-hidden': !validatedEmail }">
<div class="tw-mb-6 tw-h-28">
<bit-form-field class="!tw-mb-1">
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input type="password" bitInput #masterPasswordInput formControlName="masterPassword" />
<button type="button" bitSuffix bitIconButton bitPasswordInputToggle></button>
</bit-form-field>
<a
bitLink
class="tw-mt-2"
routerLink="/hint"
(mousedown)="goToHint()"
(click)="saveEmailSettings()"
>{{ "getMasterPasswordHint" | i18n }}</a
>
</div>
<div [hidden]="!showCaptcha()">
<iframe id="hcaptcha_iframe" height="80" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<div class="tw-mb-3 tw-flex tw-space-x-4">
<button bitButton buttonType="primary" bitFormButton type="submit" [block]="true">
<span> {{ "loginWithMasterPassword" | i18n }} </span>
</button>
</div>
<div class="tw-mb-3" *ngIf="showLoginWithDevice">
<button
bitButton
type="button"
[block]="true"
buttonType="secondary"
(click)="startAuthRequestLogin()"
>
<span> <i class="bwi bwi-mobile"></i> {{ "loginWithDevice" | i18n }} </span>
</button>
</div>
<div class="tw-mb-3">
<a
routerLink="/sso"
[queryParams]="{ email: formGroup.value.email }"
(click)="saveEmailSettings()"
bitButton
buttonType="secondary"
class="tw-w-full"
>
<i class="bwi bwi-provider tw-mr-2"></i>
{{ "enterpriseSingleSignOn" | i18n }}
</a>
</div>
<hr />
<div class="tw-m-0 tw-text-sm">
<p class="tw-mb-1">{{ "loggingInAs" | i18n }} {{ loggedEmail }}</p>
<a bitLink [routerLink]="[]" (click)="toggleValidateEmail(false)">{{ "notYou" | i18n }}</a>
</div>
</div>
</form>

View File

@@ -1,224 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, NgZone, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { takeUntil } from "rxjs";
import { first } from "rxjs/operators";
import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { RouterService } from "../../core";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../organization-invite/organization-invite";
@Component({
selector: "app-login",
templateUrl: "login-v1.component.html",
})
export class LoginComponentV1 extends BaseLoginComponent implements OnInit {
showResetPasswordAutoEnrollWarning = false;
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions;
policies: Policy[];
constructor(
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
devicesApiService: DevicesApiServiceAbstraction,
appIdService: AppIdService,
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
i18nService: I18nService,
route: ActivatedRoute,
platformUtilsService: PlatformUtilsService,
environmentService: EnvironmentService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
private passwordStrengthService: PasswordStrengthServiceAbstraction,
cryptoFunctionService: CryptoFunctionService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: InternalPolicyService,
logService: LogService,
ngZone: NgZone,
protected stateService: StateService,
private routerService: RouterService,
formBuilder: FormBuilder,
formValidationErrorService: FormValidationErrorsService,
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
toastService: ToastService,
) {
super(
devicesApiService,
appIdService,
loginStrategyService,
router,
platformUtilsService,
i18nService,
stateService,
environmentService,
passwordGenerationService,
cryptoFunctionService,
logService,
ngZone,
formBuilder,
formValidationErrorService,
route,
loginEmailService,
ssoLoginService,
toastService,
);
this.onSuccessfulLoginNavigate = this.goAfterLogIn;
}
submitForm = async (showToast = true) => {
return await this.submitFormHelper(showToast);
};
private async submitFormHelper(showToast: boolean) {
await super.submit(showToast);
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
// If there is a query parameter called 'org', set previousUrl to `/create-organization?org=paramValue`
if (qParams.org != null) {
const route = this.router.createUrlTree(["create-organization"], {
queryParams: { plan: qParams.org },
});
this.routerService.setPreviousUrl(route.toString());
}
/**
* If there is a query parameter called 'sponsorshipToken', that means they are coming
* from an email for sponsoring a families organization. If so, then set the prevousUrl
* to `/setup/families-for-enterprise?token=paramValue`
*/
if (qParams.sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
queryParams: { token: qParams.sponsorshipToken },
});
this.routerService.setPreviousUrl(route.toString());
}
await super.ngOnInit();
});
// If there's an existing org invite, use it to get the password policies
const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite();
if (orgInvite != null) {
await this.initPasswordPolicies(orgInvite);
}
}
async goAfterLogIn(userId: UserId) {
const masterPassword = this.formGroup.value.masterPassword;
// Check master password against policy
if (this.enforcedPasswordPolicyOptions != null) {
const strengthResult = this.passwordStrengthService.getPasswordStrength(
masterPassword,
this.formGroup.value.email,
);
const masterPasswordScore = strengthResult == null ? null : strengthResult.score;
// If invalid, save policies and require update
if (
!this.policyService.evaluateMasterPassword(
masterPasswordScore,
masterPassword,
this.enforcedPasswordPolicyOptions,
)
) {
const policiesData: { [id: string]: PolicyData } = {};
this.policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p)));
await this.policyService.replace(policiesData, userId);
await this.router.navigate(["update-password"]);
return;
}
}
this.loginEmailService.clearValues();
await this.router.navigate([this.successRoute]);
}
async goToHint() {
await this.saveEmailSettings();
await this.router.navigateByUrl("/hint");
}
async goToRegister() {
if (this.emailFormControl.valid) {
await this.router.navigate(["/signup"], {
queryParams: { email: this.emailFormControl.value },
});
return;
}
await this.router.navigate(["/signup"]);
}
protected override async handleMigrateEncryptionKey(result: AuthResult): Promise<boolean> {
if (!result.requiresEncryptionKeyMigration) {
return false;
}
await this.router.navigate(["migrate-legacy-encryption"]);
return true;
}
private async initPasswordPolicies(invite: OrganizationInvite): Promise<void> {
try {
this.policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
if (this.policies == null) {
return;
}
const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions(
this.policies,
invite.organizationId,
);
// Set to true if policy enabled and auto-enroll enabled
this.showResetPasswordAutoEnrollWarning =
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions;
});
}
}

View File

@@ -1,70 +0,0 @@
<div
class="tw-mx-auto tw-mt-5 tw-flex tw-max-w-lg tw-flex-col tw-items-center tw-justify-center tw-p-8"
>
<div>
<img class="logo logo-themed" alt="Bitwarden" />
<ng-container *ngIf="state == StateEnum.StandardAuthRequest">
<p class="tw-mx-4 tw-mb-4 tw-mt-3 tw-text-center tw-text-xl">
{{ "loginOrCreateNewAccount" | i18n }}
</p>
<div
class="tw-mt-3 tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6"
>
<h2 class="tw-mb-6 tw-text-xl tw-font-semibold">{{ "logInRequestSent" | i18n }}</h2>
<p class="tw-mb-6">
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<div class="tw-mb-6">
<h4 class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</h4>
<p>
<code>{{ fingerprintPhrase }}</code>
</p>
</div>
<div class="tw-my-10" *ngIf="showResendNotification">
<a [routerLink]="[]" disabled="true" (click)="startAuthRequestLogin()">{{
"resendNotification" | i18n
}}</a>
</div>
<hr />
<div class="tw-mt-3">
{{ "loginWithDeviceEnabledNote" | i18n }}
<a routerLink="/login">{{ "viewAllLoginOptions" | i18n }}</a>
</div>
</div>
</ng-container>
<ng-container *ngIf="state == StateEnum.AdminAuthRequest">
<div
class="tw-mt-3 tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6"
>
<h2 class="tw-mb-6 tw-text-xl tw-font-semibold">{{ "adminApprovalRequested" | i18n }}</h2>
<div>
<p class="tw-mb-6">{{ "adminApprovalRequestSentToAdmins" | i18n }}</p>
<p class="tw-mb-6">{{ "youWillBeNotifiedOnceApproved" | i18n }}</p>
</div>
<div class="tw-mb-6">
<h4 class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</h4>
<p>
<code>{{ fingerprintPhrase }}</code>
</p>
</div>
<hr />
<div class="tw-mt-3">
{{ "troubleLoggingIn" | i18n }}
<a routerLink="/login-initiated">{{ "viewAllLoginOptions" | i18n }}</a>
</div>
</div>
</ng-container>
</div>
</div>

View File

@@ -1,9 +0,0 @@
import { Component } from "@angular/core";
import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component";
@Component({
selector: "app-login-via-auth-request",
templateUrl: "login-via-auth-request-v1.component.html",
})
export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 {}

View File

@@ -5,23 +5,11 @@ import { CheckboxModule } from "@bitwarden/components";
import { SharedModule } from "../../../app/shared";
import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "./login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component";
import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component";
@NgModule({
imports: [SharedModule, CheckboxModule],
declarations: [
LoginComponentV1,
LoginViaAuthRequestComponentV1,
LoginDecryptionOptionsComponentV1,
LoginViaWebAuthnComponent,
],
exports: [
LoginComponentV1,
LoginViaAuthRequestComponentV1,
LoginDecryptionOptionsComponentV1,
LoginViaWebAuthnComponent,
],
declarations: [LoginDecryptionOptionsComponentV1, LoginViaWebAuthnComponent],
exports: [LoginDecryptionOptionsComponentV1, LoginViaWebAuthnComponent],
})
export class LoginModule {}

View File

@@ -5,10 +5,10 @@ import { Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
@@ -50,7 +50,7 @@ export class ChangePasswordComponent
private auditService: AuditService,
private cipherService: CipherService,
private syncService: SyncService,
private apiService: ApiService,
private masterPasswordApiService: MasterPasswordApiService,
private router: Router,
dialogService: DialogService,
private userVerificationService: UserVerificationService,
@@ -213,14 +213,14 @@ export class ChangePasswordComponent
try {
if (this.rotateUserKey) {
this.formPromise = this.apiService.postPassword(request).then(async () => {
this.formPromise = this.masterPasswordApiService.postPassword(request).then(async () => {
// we need to save this for local masterkey verification during rotation
await this.masterPasswordService.setMasterKeyHash(newLocalKeyHash, userId as UserId);
await this.masterPasswordService.setMasterKey(newMasterKey, userId as UserId);
return this.updateKey();
});
} else {
this.formPromise = this.apiService.postPassword(request);
this.formPromise = this.masterPasswordApiService.postPassword(request);
}
await this.formPromise;

View File

@@ -9,7 +9,13 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
import { CipherViewComponent, DefaultTaskService, TaskService } from "@bitwarden/vault";
import {
ChangeLoginPasswordService,
CipherViewComponent,
DefaultChangeLoginPasswordService,
DefaultTaskService,
TaskService,
} from "@bitwarden/vault";
import { WebViewPasswordHistoryService } from "../../../../vault/services/web-view-password-history.service";
@@ -34,6 +40,7 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService {
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: PremiumUpgradePromptNoop },
{ provide: TaskService, useClass: DefaultTaskService },
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
],
})
export class EmergencyViewDialogComponent {

View File

@@ -180,8 +180,20 @@ export class DeviceManagementComponent {
private updateDeviceTable(devices: Array<DeviceView>): void {
this.dataSource.data = devices
.map((device: DeviceView): DeviceTableData | null => {
if (!device.id || !device.type || !device.creationDate) {
this.validationService.showError(new Error("Invalid device data"));
if (device.id == undefined) {
this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing")));
return null;
}
if (device.type == undefined) {
this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing")));
return null;
}
if (device.creationDate == undefined) {
this.validationService.showError(
new Error(this.i18nService.t("deviceCreationDateMissing")),
);
return null;
}

View File

@@ -58,6 +58,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BillingNotificationService } from "../services/billing-notification.service";
import { BillingSharedModule } from "../shared/billing-shared.module";
import { PaymentComponent } from "../shared/payment/payment.component";
@@ -208,6 +209,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
private taxService: TaxServiceAbstraction,
private accountService: AccountService,
private organizationBillingService: OrganizationBillingService,
private billingNotificationService: BillingNotificationService,
) {}
async ngOnInit(): Promise<void> {
@@ -228,10 +230,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
const { accountCredit, paymentSource } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
try {
const { accountCredit, paymentSource } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
} catch (error) {
this.billingNotificationService.handleError(error);
}
}
if (!this.selfHosted) {

View File

@@ -23,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components";
import { BillingNotificationService } from "../../services/billing-notification.service";
import { TrialFlowService } from "../../services/trial-flow.service";
import {
AddCreditDialogResult,
@@ -66,6 +67,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
private organizationService: OrganizationService,
private accountService: AccountService,
protected syncService: SyncService,
private billingNotificationService: BillingNotificationService,
) {
this.activatedRoute.params
.pipe(
@@ -115,47 +117,52 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
protected load = async (): Promise<void> => {
this.loading = true;
const { accountCredit, paymentSource, subscriptionStatus } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
this.subscriptionStatus = subscriptionStatus;
try {
const { accountCredit, paymentSource, subscriptionStatus } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
this.subscriptionStatus = subscriptionStatus;
if (this.organizationId) {
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId,
);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const organizationPromise = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
if (this.organizationId) {
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId,
);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const organizationPromise = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
organizationSubscriptionPromise,
organizationPromise,
]);
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.organizationSubscriptionResponse,
paymentSource,
);
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
organizationSubscriptionPromise,
organizationPromise,
]);
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.organizationSubscriptionResponse,
paymentSource,
);
}
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
if (this.launchPaymentModalAutomatically) {
window.setTimeout(async () => {
await this.changePayment();
this.launchPaymentModalAutomatically = false;
this.location.replaceState(this.location.path(), "", {});
}, 800);
}
} catch (error) {
this.billingNotificationService.handleError(error);
} finally {
this.loading = false;
}
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
if (this.launchPaymentModalAutomatically) {
window.setTimeout(async () => {
await this.changePayment();
this.launchPaymentModalAutomatically = false;
this.location.replaceState(this.location.path(), "", {});
}, 800);
}
this.loading = false;
};
protected updatePaymentMethod = async (): Promise<void> => {

View File

@@ -0,0 +1,76 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ToastService } from "@bitwarden/components";
import { BillingNotificationService } from "./billing-notification.service";
describe("BillingNotificationService", () => {
let service: BillingNotificationService;
let logService: MockProxy<LogService>;
let toastService: MockProxy<ToastService>;
beforeEach(() => {
logService = mock<LogService>();
toastService = mock<ToastService>();
service = new BillingNotificationService(logService, toastService);
});
describe("handleError", () => {
it("should log error and show toast for ErrorResponse", () => {
const error = new ErrorResponse(["test error"], 400);
expect(() => service.handleError(error)).toThrow();
expect(logService.error).toHaveBeenCalledWith(error);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: error.getSingleMessage(),
});
});
it("shows error toast with the provided error", () => {
const error = new ErrorResponse(["test error"], 400);
expect(() => service.handleError(error, "Test Title")).toThrow();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "Test Title",
message: error.getSingleMessage(),
});
});
it("should only log error for non-ErrorResponse", () => {
const error = new Error("test error");
expect(() => service.handleError(error)).toThrow();
expect(logService.error).toHaveBeenCalledWith(error);
expect(toastService.showToast).not.toHaveBeenCalled();
});
});
describe("showSuccess", () => {
it("shows success toast with default title when provided title is empty", () => {
const message = "test message";
service.showSuccess(message);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: "",
message,
});
});
it("should show success toast with custom title", () => {
const message = "test message";
service.showSuccess(message, "Success Title");
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: "Success Title",
message,
});
});
});
});

View File

@@ -0,0 +1,35 @@
import { Injectable } from "@angular/core";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ToastService } from "@bitwarden/components";
@Injectable({
providedIn: "root",
})
export class BillingNotificationService {
constructor(
private logService: LogService,
private toastService: ToastService,
) {}
handleError(error: unknown, title: string = "") {
this.logService.error(error);
if (error instanceof ErrorResponse) {
this.toastService.showToast({
variant: "error",
title: title,
message: error.getSingleMessage(),
});
}
throw error;
}
showSuccess(message: string, title: string = "") {
this.toastService.showToast({
variant: "success",
title: title,
message: message,
});
}
}

View File

@@ -1,4 +1,8 @@
import { NgModule } from "@angular/core";
@NgModule({})
import { BillingNotificationService } from "./billing-notification.service";
@NgModule({
providers: [BillingNotificationService],
})
export class BillingServicesModule {}

View File

@@ -50,6 +50,7 @@ import {
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
@@ -96,6 +97,7 @@ import {
DefaultThemeStateService,
ThemeStateService,
} from "@bitwarden/common/platform/theming/theme-state.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
KdfConfigService,
@@ -103,6 +105,7 @@ import {
BiometricsService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management-ui";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import { flagEnabled } from "../../utils/flags";
import { PolicyListService } from "../admin-console/core/policy-list.service";
@@ -277,6 +280,7 @@ const safeProviders: SafeProvider[] = [
useClass: WebSetPasswordJitService,
deps: [
ApiService,
MasterPasswordApiService,
KeyServiceAbstraction,
EncryptService,
I18nServiceAbstraction,
@@ -349,6 +353,11 @@ const safeProviders: SafeProvider[] = [
useClass: WebLoginDecryptionOptionsService,
deps: [MessagingService, RouterService, AcceptOrganizationInviteService],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction],
}),
];
@NgModule({

View File

@@ -3,6 +3,7 @@ import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -75,7 +76,7 @@ export class MigrateFromLegacyEncryptionComponent {
} catch (e) {
// If the error is due to missing folders, we can delete all folders and try again
if (
e instanceof Error &&
e instanceof ErrorResponse &&
e.message === "All existing folders must be included in the rotation."
) {
const deleteFolders = await this.dialogService.openSimpleDialog({

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