1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-27 04:03:00 +02:00

[PM-2014] Passkey registration (#5396)

* [PM-2014] feat: scaffold new fido2 login component and module

* [PM-1024] feat: add content to login settings component

* [PM-1024] feat: add badge and button aria label

* [PM-2014] feat: create new dialog

* feat: add ability to remove form field bottom margin

(cherry picked from commit 05925ff77ed47f3865c2aecade8271390d9e2fa6)

* [PM-2014] feat: disable dialog close button

* [PM-2014] feat: implement mocked failing wizard flow

* [PM-2014] feat: add icons and other content

* [PM-2014] feat: change wording to "creating" password

* [PM-2014] feat: add new auth and auth core modules

* [PM-2014] chore: move fido2-login-settings to auth module

* [PM-2014] chore: expose using barrel files

* [PM-2014] feat: fetch webauthn challenge

* [PM-2014] chore: refactor api logic into new api service and move ui logic into existing service

* [PM-2014] feat: add tests for new credential options

* [PM-2014] feat: return undefined when credential creation fails

* [PM-2014] feat: implement credential creation

* [PM-2014] feat: add passkey naming ui

* [PM-2014] feat: add support for creation token

* [PM-2014] feat: implement credential saving

* [PM-2014] feat: Basic list of credentials

* [PM-2014] feat: improve async data loading

* [PM-2014] feat: finish up list UI

* [PM-2014] fix: loading state not being set properly

* [PM-2014] feat: improve aria labels

* [PM-2014] feat: show toast on passkey saved

* [PM-2014] feat: add delete dialog

* [PM-2014] feat: implement deletion without user verification

* [PM-2014] feat: add user verification to delete

* [PM-2014] feat: change to danger button

* [PM-2014] feat: show `save` if passkeys already exist

* [PM-2014] feat: add passkey limit

* [PM-2014] feat: improve error on delete

* [PM-2014] feat: add support for feature flag

* [PM-2014] feat: update copy

* [PM-2014] feat: reduce remove button margin

* [PM-2014] feat: refactor submit method

* [PM-2014] feat: autofocus fields

* [PM-2014] fix: move error handling to components

After discussing it with Jake we decided that following convention was best.

* [PM-2014] feat: change toast depending on existing passkeys

* [PM-2014] chore: rename everything from `fido2` to `webauthn`

* [PM-2014] fix: `CoreAuthModule` duplicate import

* [PM-2014] feat: change to new figma design `Encryption not supported`

* [PM-2014] fix: add missing href

* [PM-2014] fix: misaligned badge

* [PM-2014] chore: remove whitespace

* [PM-2014] fix: dialog close bug

* [PM-2014] fix: badge alignment not applying properly

* [PM-2014] fix: remove redundant align class

* [PM-2014] chore: move CoreAuthModule to AuthModule

* [PM-2014] feat: create new settings module

* [PM-2014] feat: move change password component to settings module

* [PM-2014] chore: tweak loose components recommendation

* [PM-2014] fix: remove deprecated pattern

* [PM-2014] chore: rename everything to `WebauthnLogin` to follow new naming scheme

* [PM-2014] chore: document requests and responses

* [PM-2014] fix: remove `undefined`

* [PM-2014] fix: clarify webauthn login service

* [PM-2014] fix: use `getCredentials$()`

* [PM-2014] fix: badge alignment using important statement

* [PM-2014] fix: remove sm billing flag

* [PM-2014] fix: `CoreAuthModule` double import

* [PM-2014] fix: unimported component (issue due to conflict with master)

* [PM-2014] fix: unawaited promise bug
This commit is contained in:
Andreas Coroiu 2023-10-10 15:10:26 +02:00 committed by GitHub
parent b2aa33f5a3
commit 725ee08640
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1088 additions and 10 deletions

View File

@ -0,0 +1,12 @@
import { NgModule } from "@angular/core";
import { CoreAuthModule } from "./core";
import { SettingsModule } from "./settings/settings.module";
@NgModule({
imports: [CoreAuthModule, SettingsModule],
declarations: [],
providers: [],
exports: [SettingsModule],
})
export class AuthModule {}

View File

@ -0,0 +1,15 @@
import { NgModule, Optional, SkipSelf } from "@angular/core";
import { WebauthnLoginApiService } from "./services/webauthn-login/webauthn-login-api.service";
import { WebauthnLoginService } from "./services/webauthn-login/webauthn-login.service";
@NgModule({
providers: [WebauthnLoginService, WebauthnLoginApiService],
})
export class CoreAuthModule {
constructor(@Optional() @SkipSelf() parentModule?: CoreAuthModule) {
if (parentModule) {
throw new Error("CoreAuthModule is already loaded. Import it in AuthModule only");
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./services";
export * from "./core.module";

View File

@ -0,0 +1 @@
export * from "./webauthn-login";

View File

@ -0,0 +1 @@
export * from "./webauthn-login.service";

View File

@ -0,0 +1,18 @@
import { WebauthnLoginAttestationResponseRequest } from "./webauthn-login-attestation-response.request";
/**
* Request sent to the server to save a newly created webauthn login credential.
*/
export class SaveCredentialRequest {
/** The response recieved from the authenticator. This contains the public key */
deviceResponse: WebauthnLoginAttestationResponseRequest;
/** Nickname chosen by the user to identify this credential */
name: string;
/**
* Token required by the server to complete the creation.
* It contains encrypted information that the server needs to verify the credential.
*/
token: string;
}

View File

@ -0,0 +1,27 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { WebauthnLoginAuthenticatorResponseRequest } from "./webauthn-login-authenticator-response.request";
/**
* The response recieved from an authentiator after a successful attestation.
* This request is used to save newly created webauthn login credentials to the server.
*/
export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest {
response: {
attestationObject: string;
clientDataJson: string;
};
constructor(credential: PublicKeyCredential) {
super(credential);
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error("Invalid authenticator response");
}
this.response = {
attestationObject: Utils.fromBufferToB64(credential.response.attestationObject),
clientDataJson: Utils.fromBufferToB64(credential.response.clientDataJSON),
};
}
}

View File

@ -0,0 +1,19 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
/**
* An abstract class that represents responses recieved from the webauthn authenticator.
* It contains data that is commonly returned during different types of authenticator interactions.
*/
export abstract class WebauthnLoginAuthenticatorResponseRequest {
id: string;
rawId: string;
type: string;
extensions: Record<string, unknown>;
constructor(credential: PublicKeyCredential) {
this.id = credential.id;
this.rawId = Utils.fromBufferToB64(credential.rawId);
this.type = credential.type;
this.extensions = {}; // Extensions are handled client-side
}
}

View File

@ -0,0 +1,22 @@
import { ChallengeResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
/**
* Options provided by the server to be used during attestation (i.e. creation of a new webauthn credential)
*/
export class WebauthnLoginCredentialCreateOptionsResponse extends BaseResponse {
/** Options to be provided to the webauthn authenticator */
options: ChallengeResponse;
/**
* Contains an encrypted version of the {@link options}.
* Used by the server to validate the attestation response of newly created credentials.
*/
token: string;
constructor(response: unknown) {
super(response);
this.options = new ChallengeResponse(this.getResponseProperty("options"));
this.token = this.getResponseProperty("token");
}
}

View File

@ -0,0 +1,17 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
/**
* A webauthn login credential recieved from the server.
*/
export class WebauthnLoginCredentialResponse extends BaseResponse {
id: string;
name: string;
prfSupport: boolean;
constructor(response: unknown) {
super(response);
this.id = this.getResponseProperty("id");
this.name = this.getResponseProperty("name");
this.prfSupport = this.getResponseProperty("prfSupport");
}
}

View File

@ -0,0 +1,40 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { Verification } from "@bitwarden/common/types/verification";
import { SaveCredentialRequest } from "./request/save-credential.request";
import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauthn-login-credential-create-options.response";
import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response";
@Injectable()
export class WebauthnLoginApiService {
constructor(
private apiService: ApiService,
private userVerificationService: UserVerificationService
) {}
async getCredentialCreateOptions(
verification: Verification
): Promise<WebauthnLoginCredentialCreateOptionsResponse> {
const request = await this.userVerificationService.buildRequest(verification);
const response = await this.apiService.send("POST", "/webauthn/options", request, true, true);
return new WebauthnLoginCredentialCreateOptionsResponse(response);
}
async saveCredential(request: SaveCredentialRequest): Promise<boolean> {
await this.apiService.send("POST", "/webauthn", request, true, true);
return true;
}
getCredentials(): Promise<ListResponse<WebauthnLoginCredentialResponse>> {
return this.apiService.send("GET", "/webauthn", null, true, true);
}
async deleteCredential(credentialId: string, verification: Verification): Promise<void> {
const request = await this.userVerificationService.buildRequest(verification);
await this.apiService.send("POST", `/webauthn/${credentialId}/delete`, request, true, true);
}
}

View File

@ -0,0 +1,63 @@
import { mock, MockProxy } from "jest-mock-extended";
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
import { WebauthnLoginApiService } from "./webauthn-login-api.service";
import { WebauthnLoginService } from "./webauthn-login.service";
describe("WebauthnService", () => {
let apiService!: MockProxy<WebauthnLoginApiService>;
let credentials: MockProxy<CredentialsContainer>;
let webauthnService!: WebauthnLoginService;
beforeAll(() => {
// Polyfill missing class
window.PublicKeyCredential = class {} as any;
window.AuthenticatorAttestationResponse = class {} as any;
apiService = mock<WebauthnLoginApiService>();
credentials = mock<CredentialsContainer>();
webauthnService = new WebauthnLoginService(apiService, credentials);
});
describe("createCredential", () => {
it("should return undefined when navigator.credentials throws", async () => {
credentials.create.mockRejectedValue(new Error("Mocked error"));
const options = createCredentialCreateOptions();
const result = await webauthnService.createCredential(options);
expect(result).toBeUndefined();
});
it("should return credential when navigator.credentials does not throw", async () => {
const credential = createDeviceResponse();
credentials.create.mockResolvedValue(credential as PublicKeyCredential);
const options = createCredentialCreateOptions();
const result = await webauthnService.createCredential(options);
expect(result).toBe(credential);
});
});
});
function createCredentialCreateOptions(): CredentialCreateOptionsView {
return new CredentialCreateOptionsView(Symbol() as any, Symbol() as any);
}
function createDeviceResponse(): PublicKeyCredential {
const credential = {
id: "dGVzdA==",
rawId: new Uint8Array([0x74, 0x65, 0x73, 0x74]),
type: "public-key",
response: {
attestationObject: new Uint8Array([0, 0, 0]),
clientDataJSON: "eyJ0ZXN0IjoidGVzdCJ9",
},
} as any;
Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
Object.setPrototypeOf(credential.response, AuthenticatorAttestationResponse.prototype);
return credential;
}

View File

@ -0,0 +1,109 @@
import { Injectable, Optional } from "@angular/core";
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Verification } from "@bitwarden/common/types/verification";
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
import { WebauthnCredentialView } from "../../views/webauth-credential.view";
import { SaveCredentialRequest } from "./request/save-credential.request";
import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request";
import { WebauthnLoginApiService } from "./webauthn-login-api.service";
@Injectable()
export class WebauthnLoginService {
private navigatorCredentials: CredentialsContainer;
private _refresh$ = new BehaviorSubject<void>(undefined);
private _loading$ = new BehaviorSubject<boolean>(true);
private readonly credentials$ = this._refresh$.pipe(
tap(() => this._loading$.next(true)),
switchMap(() => this.fetchCredentials$()),
tap(() => this._loading$.next(false)),
shareReplay({ bufferSize: 1, refCount: true })
);
readonly loading$ = this._loading$.asObservable();
constructor(
private apiService: WebauthnLoginApiService,
@Optional() navigatorCredentials?: CredentialsContainer,
@Optional() private logService?: LogService
) {
// Default parameters don't work when used with Angular DI
this.navigatorCredentials = navigatorCredentials ?? navigator.credentials;
}
async getCredentialCreateOptions(
verification: Verification
): Promise<CredentialCreateOptionsView> {
const response = await this.apiService.getCredentialCreateOptions(verification);
return new CredentialCreateOptionsView(response.options, response.token);
}
async createCredential(
credentialOptions: CredentialCreateOptionsView
): Promise<PublicKeyCredential | undefined> {
const nativeOptions: CredentialCreationOptions = {
publicKey: credentialOptions.options,
};
try {
const response = await this.navigatorCredentials.create(nativeOptions);
if (!(response instanceof PublicKeyCredential)) {
return undefined;
}
return response;
} catch (error) {
this.logService?.error(error);
return undefined;
}
}
async saveCredential(
credentialOptions: CredentialCreateOptionsView,
deviceResponse: PublicKeyCredential,
name: string
) {
const request = new SaveCredentialRequest();
request.deviceResponse = new WebauthnLoginAttestationResponseRequest(deviceResponse);
request.token = credentialOptions.token;
request.name = name;
await this.apiService.saveCredential(request);
this.refresh();
}
/**
* List of webauthn credentials saved on the server.
*
* **Note:**
* - Subscribing might trigger a network request if the credentials haven't been fetched yet.
* - The observable is shared and will not create unnecessary duplicate requests.
* - The observable will automatically re-fetch if the user adds or removes a credential.
* - The observable is lazy and will only fetch credentials when subscribed to.
* - Don't subscribe to this in the constructor of a long-running service, as it will keep the observable alive.
*/
getCredentials$(): Observable<WebauthnCredentialView[]> {
return this.credentials$;
}
getCredential$(credentialId: string): Observable<WebauthnCredentialView> {
return this.credentials$.pipe(
map((credentials) => credentials.find((c) => c.id === credentialId)),
filter((c) => c !== undefined)
);
}
async deleteCredential(credentialId: string, verification: Verification): Promise<void> {
await this.apiService.deleteCredential(credentialId, verification);
this.refresh();
}
private fetchCredentials$(): Observable<WebauthnCredentialView[]> {
return from(this.apiService.getCredentials()).pipe(map((response) => response.data));
}
private refresh() {
this._refresh$.next();
}
}

View File

@ -0,0 +1,5 @@
import { ChallengeResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
export class CredentialCreateOptionsView {
constructor(readonly options: ChallengeResponse, readonly token: string) {}
}

View File

@ -0,0 +1,5 @@
export class WebauthnCredentialView {
id: string;
name: string;
prfSupport: boolean;
}

View File

@ -0,0 +1,2 @@
export * from "./auth.module";
export * from "./core";

View File

@ -6,7 +6,14 @@
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate autocomplete="off">
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
autocomplete="off"
class="tw-mb-14"
>
<div class="row">
<div class="col-6">
<div class="form-group">
@ -118,3 +125,7 @@
{{ "changeMasterPassword" | i18n }}
</button>
</form>
<app-webauthn-login-settings
*ngIf="showWebauthnLoginSettings$ | async"
></app-webauthn-login-settings>

View File

@ -1,6 +1,6 @@
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, Observable } from "rxjs";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -11,12 +11,13 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { EmergencyAccessStatusType } from "@bitwarden/common/auth/enums/emergency-access-status-type";
import { EmergencyAccessUpdateRequest } from "@bitwarden/common/auth/models/request/emergency-access-update.request";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UpdateKeyRequest } from "@bitwarden/common/models/request/update-key.request";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@ -50,6 +51,8 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
checkForBreaches = true;
characterMinimumMessage = "";
protected showWebauthnLoginSettings$: Observable<boolean>;
constructor(
i18nService: I18nService,
cryptoService: CryptoService,
@ -65,13 +68,13 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
private apiService: ApiService,
private sendService: SendService,
private organizationService: OrganizationService,
private keyConnectorService: KeyConnectorService,
private router: Router,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserService: OrganizationUserService,
dialogService: DialogService,
private userVerificationService: UserVerificationService,
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
private configService: ConfigServiceAbstraction
) {
super(
i18nService,
@ -86,6 +89,10 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
}
async ngOnInit() {
this.showWebauthnLoginSettings$ = this.configService.getFeatureFlag$(
FeatureFlag.PasswordlessLogin
);
if (!(await this.userVerificationService.hasMasterPassword())) {
this.router.navigate(["/settings/security/two-factor"]);
}

View File

@ -0,0 +1,16 @@
import { NgModule } from "@angular/core";
import { PasswordCalloutComponent } from "@bitwarden/auth";
import { SharedModule } from "../../shared";
import { ChangePasswordComponent } from "./change-password.component";
import { WebauthnLoginSettingsModule } from "./webauthn-login-settings";
@NgModule({
imports: [SharedModule, WebauthnLoginSettingsModule, PasswordCalloutComponent],
declarations: [ChangePasswordComponent],
providers: [],
exports: [WebauthnLoginSettingsModule, ChangePasswordComponent],
})
export class SettingsModule {}

View File

@ -0,0 +1,70 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large">
<span bitDialogTitle
>{{ "loginWithPasskey" | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted">{{ "newPasskey" | i18n }}</span>
</span>
<ng-container bitDialogContent>
<ng-container *ngIf="currentStep === 'userVerification'">
<p bitTypography="body1">
{{ "passkeyEnterMasterPassword" | i18n }}
</p>
<bit-form-field disableMargin formGroupName="userVerification">
<bit-label>{{ "masterPassword" | i18n }}</bit-label>
<input type="password" bitInput formControlName="masterPassword" appAutofocus />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
<bit-hint>{{ "confirmIdentity" | i18n }}</bit-hint>
</bit-form-field>
</ng-container>
<div *ngIf="currentStep === 'credentialCreation'" class="tw-flex tw-flex-col tw-items-center">
<bit-icon [icon]="Icons.CreatePasskeyIcon" class="tw-mb-6"></bit-icon>
<h3 bitTypography="h3">{{ "creatingPasskeyLoading" | i18n }}</h3>
<p bitTypography="body1">{{ "creatingPasskeyLoadingInfo" | i18n }}</p>
</div>
<div
*ngIf="currentStep === 'credentialCreationFailed'"
class="tw-flex tw-flex-col tw-items-center"
>
<bit-icon [icon]="Icons.CreatePasskeyFailedIcon" class="tw-mb-6"></bit-icon>
<h3 bitTypography="h3">{{ "errorCreatingPasskey" | i18n }}</h3>
<p bitTypography="body1">{{ "errorCreatingPasskeyInfo" | i18n }}</p>
</div>
<div *ngIf="currentStep === 'credentialNaming'">
<h3 bitTypography="h3">{{ "passkeySuccessfullyCreated" | i18n }}</h3>
<p bitTypography="body1">
{{ "customPasskeyNameInfo" | i18n }}
</p>
<bit-form-field disableMargin formGroupName="credentialNaming">
<bit-label>{{ "customName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" appAutofocus />
<bit-hint>{{
"charactersCurrentAndMaximum"
| i18n : formGroup.value.credentialNaming.name.length : NameMaxCharacters
}}</bit-hint>
</bit-form-field>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
<ng-container *ngIf="currentStep === 'userVerification'">
{{ "continue" | i18n }}
</ng-container>
<ng-container *ngIf="currentStep === 'credentialCreation'">
{{ "continue" | i18n }}
</ng-container>
<ng-container *ngIf="currentStep === 'credentialCreationFailed'">
{{ "tryAgain" | i18n }}
</ng-container>
<ng-container *ngIf="currentStep === 'credentialNaming'">
{{ ((hasPasskeys$ | async) ? "save" : "enable") | i18n }}
</ng-container>
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,178 @@
import { DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom, map, Observable } from "rxjs";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { WebauthnLoginService } from "../../../core";
import { CredentialCreateOptionsView } from "../../../core/views/credential-create-options.view";
import { CreatePasskeyFailedIcon } from "./create-passkey-failed.icon";
import { CreatePasskeyIcon } from "./create-passkey.icon";
export enum CreateCredentialDialogResult {
Success,
}
type Step =
| "userVerification"
| "credentialCreation"
| "credentialCreationFailed"
| "credentialNaming";
@Component({
templateUrl: "create-credential-dialog.component.html",
})
export class CreateCredentialDialogComponent implements OnInit {
protected readonly NameMaxCharacters = 50;
protected readonly CreateCredentialDialogResult = CreateCredentialDialogResult;
protected readonly Icons = { CreatePasskeyIcon, CreatePasskeyFailedIcon };
protected currentStep: Step = "userVerification";
protected formGroup = this.formBuilder.group({
userVerification: this.formBuilder.group({
masterPassword: ["", [Validators.required]],
}),
credentialNaming: this.formBuilder.group({
name: ["", Validators.maxLength(50)],
}),
});
protected credentialOptions?: CredentialCreateOptionsView;
protected deviceResponse?: PublicKeyCredential;
protected hasPasskeys$?: Observable<boolean>;
constructor(
private formBuilder: FormBuilder,
private dialogRef: DialogRef,
private webauthnService: WebauthnLoginService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private logService: LogService
) {}
ngOnInit(): void {
this.hasPasskeys$ = this.webauthnService
.getCredentials$()
.pipe(map((credentials) => credentials.length > 0));
}
protected submit = async () => {
this.dialogRef.disableClose = true;
try {
switch (this.currentStep) {
case "userVerification":
return await this.submitUserVerification();
case "credentialCreationFailed":
return await this.submitCredentialCreationFailed();
case "credentialCreation":
return await this.submitCredentialCreation();
case "credentialNaming":
return await this.submitCredentialNaming();
}
} finally {
this.dialogRef.disableClose = false;
}
};
protected async submitUserVerification() {
this.formGroup.controls.userVerification.markAllAsTouched();
if (this.formGroup.controls.userVerification.invalid) {
return;
}
try {
this.credentialOptions = await this.webauthnService.getCredentialCreateOptions({
type: VerificationType.MasterPassword,
secret: this.formGroup.value.userVerification.masterPassword,
});
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 400) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("error"),
this.i18nService.t("invalidMasterPassword")
);
} else {
this.logService?.error(error);
this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError"));
}
return;
}
this.currentStep = "credentialCreation";
await this.submitCredentialCreation();
}
protected async submitCredentialCreation() {
this.deviceResponse = await this.webauthnService.createCredential(this.credentialOptions);
if (this.deviceResponse === undefined) {
this.currentStep = "credentialCreationFailed";
return;
}
this.currentStep = "credentialNaming";
}
protected async submitCredentialCreationFailed() {
this.currentStep = "credentialCreation";
await this.submitCredentialCreation();
}
protected async submitCredentialNaming() {
this.formGroup.controls.credentialNaming.markAllAsTouched();
if (this.formGroup.controls.credentialNaming.invalid) {
return;
}
const name = this.formGroup.value.credentialNaming.name;
try {
await this.webauthnService.saveCredential(
this.credentialOptions,
this.deviceResponse,
this.formGroup.value.credentialNaming.name
);
} catch (error) {
this.logService?.error(error);
this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError"));
return;
}
if (await firstValueFrom(this.hasPasskeys$)) {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("passkeySaved", name)
);
} else {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("loginWithPasskeyEnabled")
);
}
this.dialogRef.close(CreateCredentialDialogResult.Success);
}
}
/**
* Strongly typed helper to open a CreateCredentialDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openCreateCredentialDialog = (
dialogService: DialogService,
config: DialogConfig<unknown>
) => {
return dialogService.open<CreateCredentialDialogResult | undefined, unknown>(
CreateCredentialDialogComponent,
config
);
};

View File

@ -0,0 +1,28 @@
import { svgIcon } from "@bitwarden/components";
export const CreatePasskeyFailedIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" width="163" height="115" fill="none">
<path class="tw-fill-secondary-500" fill-rule="evenodd" d="M31 19.46H9v22h22v-22Zm-24-2v26h26v-26H7Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M0 43.46a4 4 0 0 1 4-4h32a4 4 0 0 1 4 4v7h-4v-7H4v16.747l1.705 2.149a4 4 0 0 1 .866 2.486v22.205a4 4 0 0 1-1 2.645L4 91.475v17.985h32V91.475l-1.572-1.783a4 4 0 0 1-1-2.645V64.842a4 4 0 0 1 .867-2.486L36 60.207V56.46h4v3.747a4 4 0 0 1-.867 2.487l-1.704 2.148v22.205L39 88.83a4 4 0 0 1 1 2.645v17.985a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V91.475a4 4 0 0 1 1-2.645l1.571-1.783V64.842L.867 62.694A4 4 0 0 1 0 60.207V43.46Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M19.74 63.96a.5.5 0 0 1 .355.147l2.852 2.866a.5.5 0 0 1 .146.353V77.56c2.585 1.188 4.407 3.814 4.407 6.865 0 4.183-3.357 7.534-7.5 7.534-4.144 0-7.5-3.376-7.5-7.534a7.546 7.546 0 0 1 4.478-6.894v-1.443a.5.5 0 0 1 .146-.353l1.275-1.281-1.322-1.33a.5.5 0 0 1 0-.705l.332-.334-.262-.263a.5.5 0 0 1-.005-.7l1.332-1.377-1.445-1.452a.5.5 0 0 1-.145-.352v-1.114a.5.5 0 0 1 .145-.352l2.357-2.369a.5.5 0 0 1 .355-.147Zm-1.856 3.075v.7l1.645 1.654a.5.5 0 0 1 .005.7l-1.332 1.377.267.268a.5.5 0 0 1 0 .705l-.333.334 1.323 1.329a.5.5 0 0 1 0 .705l-1.48 1.488v1.57a.5.5 0 0 1-.32.466 6.545 6.545 0 0 0-4.159 6.095c0 3.61 2.913 6.534 6.5 6.534 3.588 0 6.5-2.901 6.5-6.534 0-2.749-1.707-5.105-4.095-6.074a.5.5 0 0 1-.312-.463V67.532L19.74 65.17l-1.857 1.866ZM20 85.623a1.27 1.27 0 0 0-1.268 1.276c0 .702.56 1.276 1.268 1.276.712 0 1.268-.555 1.268-1.276A1.27 1.27 0 0 0 20 85.623Zm-2.268 1.276A2.27 2.27 0 0 1 20 84.623a2.27 2.27 0 0 1 2.268 2.276c0 1.269-1 2.276-2.268 2.276a2.27 2.27 0 0 1-2.268-2.276ZM57.623 114a1 1 0 0 1 1-1h63.048a1 1 0 0 1 0 2H58.623a1 1 0 0 1-1-1Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M78.022 114V95.654h2V114h-2ZM98.418 114V95.654h2V114h-2Z" clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M16 14.46c0-7.732 6.268-14 14-14h119c7.732 0 14 6.268 14 14v68c0 7.732-6.268 14-14 14H39.5v-4H149c5.523 0 10-4.477 10-10v-68c0-5.523-4.477-10-10-10H30c-5.523 0-10 4.477-10 10v5h-4v-5Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M25 15.46a6 6 0 0 1 6-6h117a6 6 0 0 1 6 6v66a6 6 0 0 1-6 6H36.5v-2H148a4 4 0 0 0 4-4v-66a4 4 0 0 0-4-4H31a4 4 0 0 0-4 4v3h-2v-3Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500"
d="M104.269 32.86a1.42 1.42 0 0 0-1.007-.4h-25.83c-.39 0-.722.132-1.007.4a1.26 1.26 0 0 0-.425.947v16.199c0 1.207.25 2.407.75 3.597a13.22 13.22 0 0 0 1.861 3.165c.74.919 1.62 1.817 2.646 2.69a30.93 30.93 0 0 0 2.834 2.172c.868.577 1.77 1.121 2.712 1.636.942.516 1.612.862 2.007 1.043.394.181.714.326.95.42.18.083.373.128.583.128.21 0 .403-.041.582-.128.241-.099.557-.239.956-.42.394-.181 1.064-.532 2.006-1.043a36.595 36.595 0 0 0 2.712-1.636c.867-.576 1.813-1.302 2.838-2.171a19.943 19.943 0 0 0 2.646-2.69 13.24 13.24 0 0 0 1.862-3.166 9.19 9.19 0 0 0 .749-3.597V33.812c.005-.367-.14-.684-.425-.952Zm-3.329 17.298c0 5.864-10.593 10.916-10.593 10.916V35.93h10.593v14.228Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" d="M18 24.46h-5v-2h5v2ZM27 24.46h-5v-2h5v2Z"
clip-rule="evenodd" />
<path class="tw-fill-danger-500"
d="M51.066 66.894a2.303 2.303 0 0 1-2.455-.5l-10.108-9.797L28.375 66.4l-.002.002a2.294 2.294 0 0 1-3.185.005 2.24 2.24 0 0 1-.506-2.496c.117-.27.286-.518.503-.728l10.062-9.737-9.945-9.623a2.258 2.258 0 0 1-.698-1.6c-.004-.314.06-.619.176-.894a2.254 2.254 0 0 1 1.257-1.222 2.305 2.305 0 0 1 1.723.014c.267.11.518.274.732.486l10.01 9.682 9.995-9.688.009-.008a2.292 2.292 0 0 1 3.159.026c.425.411.68.98.684 1.59a2.242 2.242 0 0 1-.655 1.6l-.01.01-9.926 9.627 10.008 9.7.029.027a2.237 2.237 0 0 1 .53 2.496l-.002.004a2.258 2.258 0 0 1-1.257 1.222Z" />
</svg>
`;

View File

@ -0,0 +1,26 @@
import { svgIcon } from "@bitwarden/components";
export const CreatePasskeyIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" width="163" height="116" fill="none">
<path class="tw-fill-secondary-500" fill-rule="evenodd" d="M31 19.58H9v22h22v-22Zm-24-2v26h26v-26H7Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M0 43.58a4 4 0 0 1 4-4h32a4 4 0 0 1 4 4v7h-4v-7H4v16.747l1.705 2.149a4 4 0 0 1 .866 2.486v22.204a4 4 0 0 1-1 2.646L4 91.595v17.985h32V91.595l-1.572-1.783a4 4 0 0 1-1-2.646V64.962a4 4 0 0 1 .867-2.486L36 60.327V56.58h4v3.747a4 4 0 0 1-.867 2.486l-1.704 2.149v22.204L39 88.95a4 4 0 0 1 1 2.646v17.985a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V91.595a4 4 0 0 1 1-2.646l1.571-1.783V64.962L.867 62.813A4 4 0 0 1 0 60.327V43.58Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M19.74 64.08a.5.5 0 0 1 .355.147l2.852 2.866a.5.5 0 0 1 .146.352V77.68c2.585 1.189 4.407 3.814 4.407 6.865 0 4.183-3.357 7.535-7.5 7.535-4.144 0-7.5-3.377-7.5-7.535a7.546 7.546 0 0 1 4.478-6.894V76.21a.5.5 0 0 1 .146-.353l1.275-1.282-1.322-1.329a.5.5 0 0 1 0-.705l.332-.334-.262-.263a.5.5 0 0 1-.005-.7l1.332-1.377-1.445-1.452a.5.5 0 0 1-.145-.353v-1.113a.5.5 0 0 1 .145-.353l2.357-2.368a.5.5 0 0 1 .355-.147Zm-1.856 3.074v.7l1.645 1.654a.5.5 0 0 1 .005.7l-1.332 1.377.267.268a.5.5 0 0 1 0 .706l-.333.334 1.323 1.329a.5.5 0 0 1 0 .705l-1.48 1.488v1.57a.5.5 0 0 1-.32.466 6.545 6.545 0 0 0-4.159 6.094c0 3.61 2.913 6.535 6.5 6.535 3.588 0 6.5-2.902 6.5-6.535 0-2.748-1.707-5.104-4.095-6.073a.5.5 0 0 1-.312-.463V67.651l-2.352-2.364-1.857 1.866ZM20 85.742a1.27 1.27 0 0 0-1.268 1.277c0 .701.56 1.276 1.268 1.276.712 0 1.268-.555 1.268-1.276A1.27 1.27 0 0 0 20 85.742Zm-2.268 1.277A2.27 2.27 0 0 1 20 84.742a2.27 2.27 0 0 1 2.268 2.277c0 1.268-1 2.276-2.268 2.276a2.27 2.27 0 0 1-2.268-2.276ZM41.796 42.844a1 1 0 0 1 1.413.058l5.526 6A1 1 0 0 1 48 50.58H27a1 1 0 1 1 0-2h18.72l-3.982-4.323a1 1 0 0 1 .058-1.413ZM33.315 62.315a1 1 0 0 1-1.413-.058l-5.526-6a1 1 0 0 1 .735-1.677h21a1 1 0 1 1 0 2h-18.72l3.982 4.322a1 1 0 0 1-.058 1.413ZM57.623 114.12a1 1 0 0 1 1-1h63.048a1 1 0 1 1 0 2H58.623a1 1 0 0 1-1-1Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M78.022 114.12V95.774h2v18.346h-2ZM98.418 114.12V95.774h2v18.346h-2Z" clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M16 14.58c0-7.732 6.268-14 14-14h119c7.732 0 14 6.268 14 14v68c0 7.732-6.268 14-14 14H39.5v-4H149c5.523 0 10-4.478 10-10v-68c0-5.523-4.477-10-10-10H30c-5.523 0-10 4.477-10 10v5h-4v-5Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M25 15.58a6 6 0 0 1 6-6h117a6 6 0 0 1 6 6v66a6 6 0 0 1-6 6H36.5v-2H148a4 4 0 0 0 4-4v-66a4 4 0 0 0-4-4H31a4 4 0 0 0-4 4v3h-2v-3Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500"
d="M104.269 32.98a1.42 1.42 0 0 0-1.007-.4h-25.83c-.39 0-.722.132-1.007.4a1.26 1.26 0 0 0-.425.947v16.199c0 1.207.25 2.406.75 3.597a13.222 13.222 0 0 0 1.861 3.165c.74.919 1.62 1.817 2.646 2.69a30.93 30.93 0 0 0 2.834 2.172c.868.577 1.77 1.121 2.712 1.636.942.515 1.612.861 2.007 1.043.394.18.714.325.95.42.18.082.373.128.583.128.21 0 .403-.042.582-.128.241-.099.557-.24.956-.42.394-.182 1.064-.532 2.006-1.043a36.56 36.56 0 0 0 2.712-1.636c.867-.577 1.813-1.302 2.838-2.172a19.943 19.943 0 0 0 2.646-2.69 13.24 13.24 0 0 0 1.862-3.165c.5-1.187.749-2.386.749-3.597V33.93c.005-.367-.14-.684-.425-.952Zm-3.329 17.298c0 5.864-10.593 10.916-10.593 10.916V36.049h10.593v14.23Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" d="M18 24.58h-5v-2h5v2ZM27 24.58h-5v-2h5v2Z"
clip-rule="evenodd" />
</svg>
`;

View File

@ -0,0 +1,34 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large">
<span bitDialogTitle
>{{ "removePasskey" | i18n }}
<span *ngIf="credential" class="tw-text-sm tw-normal-case tw-text-muted">{{
credential.name
}}</span>
</span>
<ng-container bitDialogContent>
<ng-container *ngIf="!credential">
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
</ng-container>
<ng-container *ngIf="credential">
<p bitTypography="body1">{{ "removePasskeyInfo" | i18n }}</p>
<bit-form-field disableMargin>
<bit-label>{{ "masterPassword" | i18n }}</bit-label>
<input type="password" bitInput formControlName="masterPassword" />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
<bit-hint>{{ "confirmIdentity" | i18n }}</bit-hint>
</bit-form-field>
</ng-container>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="danger">
{{ "remove" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,95 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { WebauthnLoginService } from "../../../core";
import { WebauthnCredentialView } from "../../../core/views/webauth-credential.view";
export interface DeleteCredentialDialogParams {
credentialId: string;
}
@Component({
templateUrl: "delete-credential-dialog.component.html",
})
export class DeleteCredentialDialogComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected formGroup = this.formBuilder.group({
masterPassword: ["", [Validators.required]],
});
protected credential?: WebauthnCredentialView;
constructor(
@Inject(DIALOG_DATA) private params: DeleteCredentialDialogParams,
private formBuilder: FormBuilder,
private dialogRef: DialogRef,
private webauthnService: WebauthnLoginService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private logService: LogService
) {}
ngOnInit(): void {
this.webauthnService
.getCredential$(this.params.credentialId)
.pipe(takeUntil(this.destroy$))
.subscribe((credential) => (this.credential = credential));
}
submit = async () => {
if (this.credential === undefined) {
return;
}
this.dialogRef.disableClose = true;
try {
await this.webauthnService.deleteCredential(this.credential.id, {
type: VerificationType.MasterPassword,
secret: this.formGroup.value.masterPassword,
});
this.platformUtilsService.showToast("success", null, this.i18nService.t("passkeyRemoved"));
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 400) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("error"),
this.i18nService.t("invalidMasterPassword")
);
} else {
this.logService.error(error);
this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError"));
}
return false;
} finally {
this.dialogRef.disableClose = false;
}
this.dialogRef.close();
};
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
/**
* Strongly typed helper to open a DeleteCredentialDialogComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openDeleteCredentialDialogComponent = (
dialogService: DialogService,
config: DialogConfig<DeleteCredentialDialogParams>
) => {
return dialogService.open<unknown>(DeleteCredentialDialogComponent, config);
};

View File

@ -0,0 +1 @@
export * from "./webauthn-login-settings.module";

View File

@ -0,0 +1,71 @@
<h2 bitTypography="h2">
{{ "loginWithPasskey" | i18n }}
<ng-container *ngIf="hasData">
<span *ngIf="hasCredentials" bitBadge badgeType="success" class="!tw-align-middle">{{
"on" | i18n
}}</span>
<span *ngIf="!hasCredentials" bitBadge badgeType="secondary" class="!tw-align-middle">{{
"off" | i18n
}}</span>
</ng-container>
<ng-container *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
</ng-container>
</h2>
<p bitTypography="body1">
{{ "loginWithPasskeyInfo" | i18n }}
<a bitLink href="https://bitwarden.com/help/login-with-passkeys">{{
"learnMoreAboutPasswordless" | i18n
}}</a>
</p>
<table *ngIf="hasCredentials" class="tw-mb-5">
<tr *ngFor="let credential of credentials">
<td class="tw-p-2 tw-pl-0 tw-font-semibold">{{ credential.name }}</td>
<td class="tw-p-2 tw-pr-0">
<ng-container *ngIf="credential.prfSupport">
<i class="bwi bwi-lock-encrypted"></i>
{{ "supportsEncryption" | i18n }}
</ng-container>
<span bitTypography="body1" class="tw-text-muted">
{{ "encryptionNotSupported" | i18n }}
</span>
</td>
<td class="tw-py-2 tw-pl-10 tw-pr-0">
<button
type="button"
bitLink
[disabled]="loading"
[attr.aria-label]="('remove' | i18n) + ' ' + credential.name"
(click)="deleteCredential(credential.id)"
>
{{ "remove" | i18n }}
</button>
</td>
</tr>
</table>
<p bitTypography="body2" *ngIf="limitReached">{{ "passkeyLimitReachedInfo" | i18n }}</p>
<ng-container *ngIf="hasData && !limitReached">
<button
*ngIf="hasCredentials"
type="button"
bitButton
[disabled]="loading"
(click)="createCredential()"
>
{{ "newPasskey" | i18n }}
</button>
<button
*ngIf="!hasCredentials"
type="button"
bitButton
[attr.aria-label]="('enable' | i18n) + ' ' + ('loginWithPasskey' | i18n)"
[disabled]="loading"
(click)="createCredential()"
>
{{ "enable" | i18n }}
</button>
</ng-container>

View File

@ -0,0 +1,72 @@
import { Component, HostBinding, OnDestroy, OnInit } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { WebauthnLoginService } from "../../core";
import { WebauthnCredentialView } from "../../core/views/webauth-credential.view";
import { openCreateCredentialDialog } from "./create-credential-dialog/create-credential-dialog.component";
import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
@Component({
selector: "app-webauthn-login-settings",
templateUrl: "webauthn-login-settings.component.html",
host: {
"aria-live": "polite",
},
})
export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected readonly MaxCredentialCount = 5;
protected credentials?: WebauthnCredentialView[];
protected loading = true;
constructor(
private webauthnService: WebauthnLoginService,
private dialogService: DialogService
) {}
@HostBinding("attr.aria-busy")
get ariaBusy() {
return this.loading ? "true" : "false";
}
get hasCredentials() {
return this.credentials && this.credentials.length > 0;
}
get hasData() {
return this.credentials !== undefined;
}
get limitReached() {
return this.credentials?.length >= this.MaxCredentialCount;
}
ngOnInit(): void {
this.webauthnService
.getCredentials$()
.pipe(takeUntil(this.destroy$))
.subscribe((credentials) => (this.credentials = credentials));
this.webauthnService.loading$
.pipe(takeUntil(this.destroy$))
.subscribe((loading) => (this.loading = loading));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
protected createCredential() {
openCreateCredentialDialog(this.dialogService, {});
}
protected deleteCredential(credentialId: string) {
openDeleteCredentialDialogComponent(this.dialogService, { data: { credentialId } });
}
}

View File

@ -0,0 +1,19 @@
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { SharedModule } from "../../../shared/shared.module";
import { CreateCredentialDialogComponent } from "./create-credential-dialog/create-credential-dialog.component";
import { DeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.component";
@NgModule({
imports: [SharedModule, FormsModule, ReactiveFormsModule],
declarations: [
WebauthnLoginSettingsComponent,
CreateCredentialDialogComponent,
DeleteCredentialDialogComponent,
],
exports: [WebauthnLoginSettingsComponent],
})
export class WebauthnLoginSettingsModule {}

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { OrganizationUserModule } from "./admin-console/organizations/users/organization-user.module";
import { AuthModule } from "./auth";
import { LoginModule } from "./auth/login/login.module";
import { TrialInitiationModule } from "./auth/trial-initiation/trial-initiation.module";
import { LooseComponentsModule, SharedModule } from "./shared";
@ -16,6 +17,7 @@ import { VaultFilterModule } from "./vault/individual-vault/vault-filter/vault-f
OrganizationBadgeModule,
OrganizationUserModule,
LoginModule,
AuthModule,
],
exports: [
SharedModule,

View File

@ -25,7 +25,6 @@ import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component"
import { RegisterFormModule } from "../auth/register-form/register-form.module";
import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component";
import { ChangePasswordComponent } from "../auth/settings/change-password.component";
import { DeauthorizeSessionsComponent } from "../auth/settings/deauthorize-sessions.component";
import { EmergencyAccessAddEditComponent } from "../auth/settings/emergency-access/emergency-access-add-edit.component";
import { EmergencyAccessAttachmentsComponent } from "../auth/settings/emergency-access/emergency-access-attachments.component";
@ -119,7 +118,6 @@ import { SharedModule } from "./shared.module";
ApiKeyComponent,
AttachmentsComponent,
ChangeEmailComponent,
ChangePasswordComponent,
CollectionsComponent,
DeauthorizeSessionsComponent,
DeleteAccountComponent,
@ -204,7 +202,6 @@ import { SharedModule } from "./shared.module";
ApiKeyComponent,
AttachmentsComponent,
ChangeEmailComponent,
ChangePasswordComponent,
CollectionsComponent,
DeauthorizeSessionsComponent,
DeleteAccountComponent,

View File

@ -611,6 +611,72 @@
"loginWithMasterPassword": {
"message": "Log in with master password"
},
"loginWithPasskey": {
"message": "Log in with passkey"
},
"loginWithPasskeyInfo": {
"message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity."
},
"newPasskey": {
"message": "New passkey"
},
"learnMoreAboutPasswordless": {
"message": "Learn more about passwordless"
},
"passkeyEnterMasterPassword": {
"message": "Enter your master password to modify log in with passkey settings."
},
"creatingPasskeyLoading": {
"message": "Creating passkey..."
},
"creatingPasskeyLoadingInfo": {
"message": "Keep this window open and follow prompts from your browser."
},
"errorCreatingPasskey": {
"message": "Error creating passkey"
},
"errorCreatingPasskeyInfo": {
"message": "There was a problem creating your passkey."
},
"passkeySuccessfullyCreated": {
"message": "Passkey successfully created!"
},
"customName": {
"message": "Custom name"
},
"customPasskeyNameInfo": {
"message": "Name your passkey to help you identify it."
},
"encryptionNotSupported": {
"message": "Encryption not supported"
},
"loginWithPasskeyEnabled": {
"message": "Log in with passkey turned on"
},
"passkeySaved": {
"message": "$NAME$ saved",
"placeholders": {
"name": {
"content": "$1",
"example": "Personal yubikey"
}
}
},
"passkeyRemoved": {
"message": "Passkey removed"
},
"removePasskey": {
"message": "Remove passkey"
},
"removePasskeyInfo": {
"message": "If all passkeys are removed, you will be unable to log into new devices without your master password."
},
"passkeyLimitReachedInfo": {
"message": "Passkey limit reached. Remove a passkey to add another."
},
"tryAgain": {
"message": "Try again"
},
"createAccount": {
"message": "Create account"
},
@ -5406,6 +5472,19 @@
"required": {
"message": "required"
},
"charactersCurrentAndMaximum": {
"message": "$CURRENT$/$MAX$ character maximum",
"placeholders": {
"current": {
"content": "$1",
"example": "0"
},
"max": {
"content": "$2",
"example": "100"
}
}
},
"characterMaximum": {
"message": "$MAX$ character maximum",
"placeholders": {
@ -5754,6 +5833,9 @@
"on": {
"message": "On"
},
"off": {
"message": "Off"
},
"members": {
"message": "Members"
},

View File

@ -2,6 +2,7 @@ export enum FeatureFlag {
DisplayEuEnvironmentFlag = "display-eu-environment",
DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning",
TrustedDeviceEncryption = "trusted-device-encryption",
PasswordlessLogin = "passwordless-login",
AutofillV2 = "autofill-v2",
BrowserFilelessImport = "browser-fileless-import",
}

View File

@ -1,5 +1,5 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Directive, HostListener, Input, Optional } from "@angular/core";
import { Directive, HostBinding, HostListener, Input, Optional } from "@angular/core";
@Directive({
selector: "[bitDialogClose]",
@ -9,7 +9,17 @@ export class DialogCloseDirective {
constructor(@Optional() public dialogRef: DialogRef) {}
@HostListener("click") close(): void {
@HostBinding("attr.disabled")
get disableClose() {
return this.dialogRef?.disableClose ? true : null;
}
@HostListener("click")
close(): void {
if (this.disableClose) {
return;
}
this.dialogRef.close(this.dialogResult);
}
}