mirror of
https://github.com/bitwarden/browser
synced 2026-02-01 17:23:37 +00:00
Tools/pm 29918/implement send auth flows (#18270)
* [PM-29918] Implement new Send auth flows * [PM-29918] Fix types * Trigger Claude code review * [PM-29918] Address PR review comments * [PM-29918] Remove duplicate AuthType const
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
@switch (viewState) {
|
||||
@switch (viewState()) {
|
||||
@case ("auth") {
|
||||
<app-send-auth [id]="id" [key]="key" (accessGranted)="onAccessGranted($event)"></app-send-auth>
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
<app-send-view
|
||||
[id]="id"
|
||||
[key]="key"
|
||||
[accessToken]="sendAccessToken"
|
||||
[sendResponse]="sendAccessResponse"
|
||||
[accessRequest]="sendAccessRequest"
|
||||
(authRequired)="onAuthRequired()"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
|
||||
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
|
||||
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
|
||||
|
||||
@@ -17,44 +19,45 @@ const SendViewState = Object.freeze({
|
||||
} as const);
|
||||
type SendViewState = (typeof SendViewState)[keyof typeof SendViewState];
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-send-access",
|
||||
templateUrl: "access.component.html",
|
||||
imports: [SendAuthComponent, SendViewComponent, SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AccessComponent implements OnInit {
|
||||
viewState: SendViewState = SendViewState.View;
|
||||
readonly viewState = signal<SendViewState>(SendViewState.Auth);
|
||||
id: string;
|
||||
key: string;
|
||||
|
||||
sendAccessToken: SendAccessToken | null = null;
|
||||
sendAccessResponse: SendAccessResponse | null = null;
|
||||
sendAccessRequest: SendAccessRequest = new SendAccessRequest();
|
||||
|
||||
constructor(private route: ActivatedRoute) {}
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.params.subscribe(async (params) => {
|
||||
ngOnInit() {
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
this.id = params.sendId;
|
||||
this.key = params.key;
|
||||
|
||||
if (this.id && this.key) {
|
||||
this.viewState = SendViewState.View;
|
||||
this.sendAccessResponse = null;
|
||||
this.sendAccessRequest = new SendAccessRequest();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAuthRequired() {
|
||||
this.viewState = SendViewState.Auth;
|
||||
this.viewState.set(SendViewState.Auth);
|
||||
}
|
||||
|
||||
onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) {
|
||||
onAccessGranted(event: {
|
||||
response?: SendAccessResponse;
|
||||
request?: SendAccessRequest;
|
||||
accessToken?: SendAccessToken;
|
||||
}) {
|
||||
this.sendAccessResponse = event.response;
|
||||
this.sendAccessRequest = event.request;
|
||||
this.viewState = SendViewState.View;
|
||||
this.sendAccessToken = event.accessToken;
|
||||
this.viewState.set(SendViewState.View);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
@if (!enterOtp()) {
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "email" | i18n }}</bit-label>
|
||||
<input bitInput type="text" [formControl]="email" required appInputVerbatim appAutofocus />
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading()"
|
||||
[block]="true"
|
||||
>
|
||||
<span>{{ "sendCode" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
|
||||
<input bitInput type="text" [formControl]="otp" required appInputVerbatim appAutofocus />
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading()"
|
||||
[block]="true"
|
||||
>
|
||||
<span>{{ "viewSend" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@Component({
|
||||
selector: "app-send-access-email",
|
||||
templateUrl: "send-access-email.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendAccessEmailComponent implements OnInit, OnDestroy {
|
||||
protected readonly formGroup = input.required<FormGroup>();
|
||||
protected readonly enterOtp = input.required<boolean>();
|
||||
protected email: FormControl;
|
||||
protected otp: FormControl;
|
||||
|
||||
readonly loading = input.required<boolean>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit() {
|
||||
this.email = new FormControl("", Validators.required);
|
||||
this.otp = new FormControl("", Validators.required);
|
||||
this.formGroup().addControl("email", this.email);
|
||||
this.formGroup().addControl("otp", this.otp);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.formGroup().removeControl("email");
|
||||
this.formGroup().removeControl("otp");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<p class="tw-text-wrap tw-break-all">{{ send.file.fileName }}</p>
|
||||
<p class="tw-text-wrap tw-break-all">{{ send().file.fileName }}</p>
|
||||
<button bitButton type="button" buttonType="primary" [bitAction]="download" [block]="true">
|
||||
<i class="bwi bwi-download" aria-hidden="true"></i>
|
||||
{{ "downloadAttachments" | i18n }} ({{ send.file.sizeName }})
|
||||
{{ "downloadAttachments" | i18n }} ({{ send().file.sizeName }})
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -15,40 +18,39 @@ import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-send-access-file",
|
||||
templateUrl: "send-access-file.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendAccessFileComponent {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() send: SendAccessView;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() decKey: SymmetricCryptoKey;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() accessRequest: SendAccessRequest;
|
||||
readonly send = input<SendAccessView | null>(null);
|
||||
readonly decKey = input<SymmetricCryptoKey | null>(null);
|
||||
readonly accessRequest = input<SendAccessRequest | null>(null);
|
||||
readonly accessToken = input<SendAccessToken | null>(null);
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private encryptService: EncryptService,
|
||||
private fileDownloadService: FileDownloadService,
|
||||
private sendApiService: SendApiService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
protected download = async () => {
|
||||
if (this.send == null || this.decKey == null) {
|
||||
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
const accessToken = this.accessToken();
|
||||
const accessRequest = this.accessRequest();
|
||||
const authMissing = (sendEmailOtp && !accessToken) || (!sendEmailOtp && !accessRequest);
|
||||
if (this.send() == null || this.decKey() == null || authMissing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadData = await this.sendApiService.getSendFileDownloadData(
|
||||
this.send,
|
||||
this.accessRequest,
|
||||
);
|
||||
const downloadData = sendEmailOtp
|
||||
? await this.sendApiService.getSendFileDownloadDataV2(this.send(), accessToken)
|
||||
: await this.sendApiService.getSendFileDownloadData(this.send(), accessRequest);
|
||||
|
||||
if (Utils.isNullOrWhitespace(downloadData.url)) {
|
||||
this.toastService.showToast({
|
||||
@@ -71,9 +73,9 @@ export class SendAccessFileComponent {
|
||||
|
||||
try {
|
||||
const encBuf = await EncArrayBuffer.fromResponse(response);
|
||||
const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey);
|
||||
const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey());
|
||||
this.fileDownloadService.download({
|
||||
fileName: this.send.file.fileName,
|
||||
fileName: this.send().file.fileName,
|
||||
blobData: decBuf,
|
||||
downloadMethod: "save",
|
||||
});
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
<p bitTypography="body1">{{ "sendProtectedPassword" | i18n }}</p>
|
||||
<p bitTypography="body1">{{ "sendProtectedPasswordDontKnow" | i18n }}</p>
|
||||
<div class="tw-mb-3" [formGroup]="formGroup">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "password" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="password"
|
||||
formControlName="password"
|
||||
required
|
||||
appInputVerbatim
|
||||
appAutofocus
|
||||
/>
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading"
|
||||
[block]="true"
|
||||
>
|
||||
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "password" | i18n }}</bit-label>
|
||||
<input bitInput type="password" [formControl]="password" required appInputVerbatim appAutofocus />
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading()"
|
||||
[block]="true"
|
||||
>
|
||||
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-send-access-password",
|
||||
templateUrl: "send-access-password.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendAccessPasswordComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected formGroup = this.formBuilder.group({
|
||||
password: ["", [Validators.required]],
|
||||
});
|
||||
protected readonly formGroup = input.required<FormGroup>();
|
||||
protected password: FormControl;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() loading: boolean;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() setPasswordEvent = new EventEmitter<string>();
|
||||
readonly loading = input.required<boolean>();
|
||||
|
||||
constructor(private formBuilder: FormBuilder) {}
|
||||
constructor() {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.formGroup.controls.password.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((val) => {
|
||||
this.setPasswordEvent.emit(val);
|
||||
});
|
||||
ngOnInit() {
|
||||
this.password = new FormControl("", Validators.required);
|
||||
this.formGroup().addControl("password", this.password);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.formGroup().removeControl("password");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
<form (ngSubmit)="onSubmit(password)">
|
||||
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
|
||||
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
|
||||
@if (loading()) {
|
||||
<div class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-text-main tw-text-center" *ngIf="error">
|
||||
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
|
||||
</div>
|
||||
|
||||
<app-send-access-password
|
||||
*ngIf="!unavailable"
|
||||
(setPasswordEvent)="password = $event"
|
||||
[loading]="loading"
|
||||
></app-send-access-password>
|
||||
}
|
||||
<form [formGroup]="sendAccessForm" (ngSubmit)="onSubmit()">
|
||||
@if (error()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (unavailable()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
@switch (sendAuthType()) {
|
||||
@case (authType.Password) {
|
||||
<app-send-access-password
|
||||
[loading]="loading()"
|
||||
[formGroup]="sendAccessForm"
|
||||
></app-send-access-password>
|
||||
}
|
||||
@case (authType.Email) {
|
||||
<app-send-access-email
|
||||
[formGroup]="sendAccessForm"
|
||||
[enterOtp]="enterOtp()"
|
||||
[loading]="loading()"
|
||||
></app-send-access-email>
|
||||
}
|
||||
}
|
||||
}
|
||||
</form>
|
||||
|
||||
@@ -1,86 +1,211 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input, OnInit, output, signal } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
emailAndOtpRequiredEmailSent,
|
||||
emailInvalid,
|
||||
emailRequired,
|
||||
otpInvalid,
|
||||
passwordHashB64Invalid,
|
||||
passwordHashB64Required,
|
||||
SendAccessDomainCredentials,
|
||||
SendAccessToken,
|
||||
SendHashedPasswordB64,
|
||||
sendIdInvalid,
|
||||
SendOtp,
|
||||
SendTokenService,
|
||||
} from "@bitwarden/common/auth/send-access";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
|
||||
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
|
||||
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { SendAccessEmailComponent } from "./send-access-email.component";
|
||||
import { SendAccessPasswordComponent } from "./send-access-password.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-send-auth",
|
||||
templateUrl: "send-auth.component.html",
|
||||
imports: [SendAccessPasswordComponent, SharedModule],
|
||||
imports: [SendAccessPasswordComponent, SendAccessEmailComponent, SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendAuthComponent {
|
||||
readonly id = input.required<string>();
|
||||
readonly key = input.required<string>();
|
||||
export class SendAuthComponent implements OnInit {
|
||||
protected readonly id = input.required<string>();
|
||||
protected readonly key = input.required<string>();
|
||||
|
||||
accessGranted = output<{
|
||||
response: SendAccessResponse;
|
||||
request: SendAccessRequest;
|
||||
protected accessGranted = output<{
|
||||
response?: SendAccessResponse;
|
||||
request?: SendAccessRequest;
|
||||
accessToken?: SendAccessToken;
|
||||
}>();
|
||||
|
||||
loading = false;
|
||||
error = false;
|
||||
unavailable = false;
|
||||
password?: string;
|
||||
authType = AuthType;
|
||||
|
||||
private accessRequest!: SendAccessRequest;
|
||||
private expiredAuthAttempts = 0;
|
||||
|
||||
readonly loading = signal<boolean>(false);
|
||||
readonly error = signal<boolean>(false);
|
||||
readonly unavailable = signal<boolean>(false);
|
||||
readonly sendAuthType = signal<AuthType>(AuthType.None);
|
||||
readonly enterOtp = signal<boolean>(false);
|
||||
|
||||
sendAccessForm = this.formBuilder.group<{ password?: string; email?: string; otp?: string }>({});
|
||||
|
||||
constructor(
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private sendApiService: SendApiService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private formBuilder: FormBuilder,
|
||||
private configService: ConfigService,
|
||||
private sendTokenService: SendTokenService,
|
||||
) {}
|
||||
|
||||
async onSubmit(password: string) {
|
||||
this.password = password;
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
this.unavailable = false;
|
||||
ngOnInit() {
|
||||
void this.onSubmit();
|
||||
}
|
||||
|
||||
async onSubmit() {
|
||||
this.loading.set(true);
|
||||
this.unavailable.set(false);
|
||||
this.error.set(false);
|
||||
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
if (sendEmailOtp) {
|
||||
await this.attemptV2Access();
|
||||
} else {
|
||||
await this.attemptV1Access();
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
private async attemptV1Access() {
|
||||
try {
|
||||
const keyArray = Utils.fromUrlB64ToArray(this.key());
|
||||
this.accessRequest = new SendAccessRequest();
|
||||
|
||||
const passwordHash = await this.cryptoFunctionService.pbkdf2(
|
||||
this.password,
|
||||
keyArray,
|
||||
"sha256",
|
||||
SEND_KDF_ITERATIONS,
|
||||
);
|
||||
this.accessRequest.password = Utils.fromBufferToB64(passwordHash);
|
||||
|
||||
const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest);
|
||||
this.accessGranted.emit({ response: sendResponse, request: this.accessRequest });
|
||||
const accessRequest = new SendAccessRequest();
|
||||
if (this.sendAuthType() === AuthType.Password) {
|
||||
const password = this.sendAccessForm.value.password;
|
||||
if (password == null) {
|
||||
return;
|
||||
}
|
||||
accessRequest.password = await this.getPasswordHashB64(password, this.key());
|
||||
}
|
||||
const sendResponse = await this.sendApiService.postSendAccess(this.id(), accessRequest);
|
||||
this.accessGranted.emit({ request: accessRequest, response: sendResponse });
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse) {
|
||||
if (e.statusCode === 404) {
|
||||
this.unavailable = true;
|
||||
} else if (e.statusCode === 400) {
|
||||
if (e.statusCode === 401) {
|
||||
this.sendAuthType.set(AuthType.Password);
|
||||
} else if (e.statusCode === 404) {
|
||||
this.unavailable.set(true);
|
||||
} else {
|
||||
this.error.set(true);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
this.error = true;
|
||||
}
|
||||
} else {
|
||||
this.error = true;
|
||||
this.error.set(true);
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptV2Access(): Promise<void> {
|
||||
let sendAccessCreds: SendAccessDomainCredentials | null = null;
|
||||
if (this.sendAuthType() === AuthType.Email) {
|
||||
const email = this.sendAccessForm.value.email;
|
||||
if (email == null) {
|
||||
return;
|
||||
}
|
||||
if (!this.enterOtp()) {
|
||||
sendAccessCreds = { kind: "email", email };
|
||||
} else {
|
||||
const otp = this.sendAccessForm.value.otp as SendOtp;
|
||||
if (otp == null) {
|
||||
return;
|
||||
}
|
||||
sendAccessCreds = { kind: "email_otp", email, otp };
|
||||
}
|
||||
} else if (this.sendAuthType() === AuthType.Password) {
|
||||
const password = this.sendAccessForm.value.password;
|
||||
if (password == null) {
|
||||
return;
|
||||
}
|
||||
const passwordHashB64 = await this.getPasswordHashB64(password, this.key());
|
||||
sendAccessCreds = { kind: "password", passwordHashB64 };
|
||||
}
|
||||
const response = !sendAccessCreds
|
||||
? await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(this.id()))
|
||||
: await firstValueFrom(this.sendTokenService.getSendAccessToken$(this.id(), sendAccessCreds));
|
||||
if (response instanceof SendAccessToken) {
|
||||
this.expiredAuthAttempts = 0;
|
||||
this.accessGranted.emit({ accessToken: response });
|
||||
} else if (response.kind === "expired") {
|
||||
if (this.expiredAuthAttempts > 2) {
|
||||
return;
|
||||
}
|
||||
this.expiredAuthAttempts++;
|
||||
await this.attemptV2Access();
|
||||
} else if (response.kind === "expected_server") {
|
||||
this.expiredAuthAttempts = 0;
|
||||
if (emailRequired(response.error)) {
|
||||
this.sendAuthType.set(AuthType.Email);
|
||||
} else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) {
|
||||
this.enterOtp.set(true);
|
||||
} else if (otpInvalid(response.error)) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidVerificationCode"),
|
||||
});
|
||||
} else if (passwordHashB64Required(response.error)) {
|
||||
this.sendAuthType.set(AuthType.Password);
|
||||
} else if (passwordHashB64Invalid(response.error)) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidSendPassword"),
|
||||
});
|
||||
} else if (sendIdInvalid(response.error)) {
|
||||
this.unavailable.set(true);
|
||||
} else {
|
||||
this.error.set(true);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: response.error.error_description ?? "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.expiredAuthAttempts = 0;
|
||||
this.error.set(true);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: response.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getPasswordHashB64(password: string, key: string) {
|
||||
const keyArray = Utils.fromUrlB64ToArray(key);
|
||||
const passwordHash = await this.cryptoFunctionService.pbkdf2(
|
||||
password,
|
||||
keyArray,
|
||||
"sha256",
|
||||
SEND_KDF_ITERATIONS,
|
||||
);
|
||||
return Utils.fromBufferToB64(passwordHash) as SendHashedPasswordB64;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
<bit-callout *ngIf="hideEmail" type="warning" title="{{ 'warning' | i18n }}">
|
||||
{{ "viewSendHiddenEmailWarning" | i18n }}
|
||||
<a bitLink href="https://bitwarden.com/help/receive-send/" target="_blank" rel="noreferrer">{{
|
||||
"learnMore" | i18n
|
||||
}}</a
|
||||
>.
|
||||
</bit-callout>
|
||||
@if (hideEmail()) {
|
||||
<bit-callout type="warning" title="{{ 'warning' | i18n }}">
|
||||
{{ "viewSendHiddenEmailWarning" | i18n }}
|
||||
<a bitLink href="https://bitwarden.com/help/receive-send/" target="_blank" rel="noreferrer">{{
|
||||
"learnMore" | i18n
|
||||
}}</a>
|
||||
</bit-callout>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="!loading; else spinner">
|
||||
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
|
||||
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
|
||||
</div>
|
||||
<div class="tw-text-main tw-text-center" *ngIf="error">
|
||||
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
|
||||
</div>
|
||||
<div *ngIf="send && !error && !unavailable">
|
||||
<p class="tw-text-center">
|
||||
<b>{{ send.name }}</b>
|
||||
</p>
|
||||
<hr />
|
||||
<!-- Text -->
|
||||
<ng-container *ngIf="send.type === sendType.Text">
|
||||
<app-send-access-text [send]="send"></app-send-access-text>
|
||||
</ng-container>
|
||||
<!-- File -->
|
||||
<ng-container *ngIf="send.type === sendType.File">
|
||||
<app-send-access-file
|
||||
[send]="send"
|
||||
[decKey]="decKey"
|
||||
[accessRequest]="accessRequest()"
|
||||
></app-send-access-file>
|
||||
</ng-container>
|
||||
<p *ngIf="expirationDate" class="tw-text-center tw-text-muted">
|
||||
Expires: {{ expirationDate | date: "medium" }}
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #spinner>
|
||||
@if (loading()) {
|
||||
<div class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
@@ -44,4 +16,39 @@
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
} @else {
|
||||
@if (unavailable()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (error()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (send()) {
|
||||
<div>
|
||||
<p class="tw-text-center">
|
||||
<b>{{ send().name }}</b>
|
||||
</p>
|
||||
<hr />
|
||||
@switch (send().type) {
|
||||
@case (sendType.Text) {
|
||||
<app-send-access-text [send]="send()"></app-send-access-text>
|
||||
}
|
||||
@case (sendType.File) {
|
||||
<app-send-access-file
|
||||
[send]="send()"
|
||||
[decKey]="decKey"
|
||||
[accessRequest]="accessRequest()"
|
||||
[accessToken]="accessToken()"
|
||||
></app-send-access-file>
|
||||
}
|
||||
}
|
||||
@if (expirationDate()) {
|
||||
<p class="tw-text-center tw-text-muted">Expires: {{ expirationDate() | date: "medium" }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
|
||||
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
@@ -34,17 +38,25 @@ import { SendAccessTextComponent } from "./send-access-text.component";
|
||||
export class SendViewComponent implements OnInit {
|
||||
readonly id = input.required<string>();
|
||||
readonly key = input.required<string>();
|
||||
readonly accessToken = input<SendAccessToken | null>(null);
|
||||
readonly sendResponse = input<SendAccessResponse | null>(null);
|
||||
readonly accessRequest = input<SendAccessRequest>(new SendAccessRequest());
|
||||
|
||||
authRequired = output<void>();
|
||||
|
||||
send: SendAccessView | null = null;
|
||||
readonly send = signal<SendAccessView | null>(null);
|
||||
readonly expirationDate = computed<Date | null>(() => this.send()?.expirationDate ?? null);
|
||||
readonly creatorIdentifier = computed<string | null>(
|
||||
() => this.send()?.creatorIdentifier ?? null,
|
||||
);
|
||||
readonly hideEmail = computed<boolean>(
|
||||
() => this.send() != null && this.creatorIdentifier() == null,
|
||||
);
|
||||
readonly loading = signal<boolean>(false);
|
||||
readonly unavailable = signal<boolean>(false);
|
||||
readonly error = signal<boolean>(false);
|
||||
|
||||
sendType = SendType;
|
||||
loading = true;
|
||||
unavailable = false;
|
||||
error = false;
|
||||
hideEmail = false;
|
||||
decKey!: SymmetricCryptoKey;
|
||||
|
||||
constructor(
|
||||
@@ -53,50 +65,48 @@ export class SendViewComponent implements OnInit {
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private layoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private cdRef: ChangeDetectorRef,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
get expirationDate() {
|
||||
if (this.send == null || this.send.expirationDate == null) {
|
||||
return null;
|
||||
}
|
||||
return this.send.expirationDate;
|
||||
}
|
||||
|
||||
get creatorIdentifier() {
|
||||
if (this.send == null || this.send.creatorIdentifier == null) {
|
||||
return null;
|
||||
}
|
||||
return this.send.creatorIdentifier;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.load();
|
||||
ngOnInit() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
private async load() {
|
||||
this.unavailable = false;
|
||||
this.error = false;
|
||||
this.hideEmail = false;
|
||||
this.loading = true;
|
||||
|
||||
let response = this.sendResponse();
|
||||
this.loading.set(true);
|
||||
this.unavailable.set(false);
|
||||
this.error.set(false);
|
||||
|
||||
try {
|
||||
if (!response) {
|
||||
response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest());
|
||||
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
let response: SendAccessResponse;
|
||||
if (sendEmailOtp) {
|
||||
const accessToken = this.accessToken();
|
||||
if (!accessToken) {
|
||||
this.authRequired.emit();
|
||||
return;
|
||||
}
|
||||
response = await this.sendApiService.postSendAccessV2(accessToken);
|
||||
} else {
|
||||
const sendResponse = this.sendResponse();
|
||||
if (!sendResponse) {
|
||||
this.authRequired.emit();
|
||||
return;
|
||||
}
|
||||
response = sendResponse;
|
||||
}
|
||||
|
||||
const keyArray = Utils.fromUrlB64ToArray(this.key());
|
||||
const sendAccess = new SendAccess(response);
|
||||
this.decKey = await this.keyService.makeSendKey(keyArray);
|
||||
this.send = await sendAccess.decrypt(this.decKey);
|
||||
const decSend = await sendAccess.decrypt(this.decKey);
|
||||
this.send.set(decSend);
|
||||
} catch (e) {
|
||||
this.send.set(null);
|
||||
if (e instanceof ErrorResponse) {
|
||||
if (e.statusCode === 401) {
|
||||
this.authRequired.emit();
|
||||
} else if (e.statusCode === 404) {
|
||||
this.unavailable = true;
|
||||
this.unavailable.set(true);
|
||||
} else if (e.statusCode === 400) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
@@ -104,28 +114,23 @@ export class SendViewComponent implements OnInit {
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
this.error = true;
|
||||
this.error.set(true);
|
||||
}
|
||||
} else {
|
||||
this.error = true;
|
||||
this.error.set(true);
|
||||
}
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.hideEmail =
|
||||
this.creatorIdentifier == null && !this.loading && !this.unavailable && !response;
|
||||
|
||||
this.hideEmail = this.send != null && this.creatorIdentifier == null;
|
||||
|
||||
if (this.creatorIdentifier != null) {
|
||||
const creatorIdentifier = this.creatorIdentifier();
|
||||
if (creatorIdentifier != null) {
|
||||
this.layoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageSubtitle: {
|
||||
key: "sendAccessCreatorIdentifier",
|
||||
placeholders: [this.creatorIdentifier],
|
||||
placeholders: [creatorIdentifier],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12699,5 +12699,8 @@
|
||||
},
|
||||
"emailProtected": {
|
||||
"message": "Email protected"
|
||||
},
|
||||
"invalidSendPassword": {
|
||||
"message": "Invalid Send password"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { Send } from "../models/domain/send";
|
||||
@@ -16,6 +18,10 @@ export abstract class SendApiService {
|
||||
request: SendAccessRequest,
|
||||
apiUrl?: string,
|
||||
): Promise<SendAccessResponse>;
|
||||
abstract postSendAccessV2(
|
||||
accessToken: SendAccessToken,
|
||||
apiUrl?: string,
|
||||
): Promise<SendAccessResponse>;
|
||||
abstract getSends(): Promise<ListResponse<SendResponse>>;
|
||||
abstract postSend(request: SendRequest): Promise<SendResponse>;
|
||||
abstract postFileTypeSend(request: SendRequest): Promise<SendFileUploadDataResponse>;
|
||||
@@ -28,6 +34,11 @@ export abstract class SendApiService {
|
||||
request: SendAccessRequest,
|
||||
apiUrl?: string,
|
||||
): Promise<SendFileDownloadDataResponse>;
|
||||
abstract getSendFileDownloadDataV2(
|
||||
send: SendAccessView,
|
||||
accessToken: SendAccessToken,
|
||||
apiUrl?: string,
|
||||
): Promise<SendFileDownloadDataResponse>;
|
||||
abstract renewSendFileUploadUrl(
|
||||
sendId: string,
|
||||
fileId: string,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { ErrorResponse } from "../../../models/response/error.response";
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
@@ -52,6 +54,25 @@ export class SendApiService implements SendApiServiceAbstraction {
|
||||
return new SendAccessResponse(r);
|
||||
}
|
||||
|
||||
async postSendAccessV2(
|
||||
accessToken: SendAccessToken,
|
||||
apiUrl?: string,
|
||||
): Promise<SendAccessResponse> {
|
||||
const setAuthTokenHeader = (headers: Headers) => {
|
||||
headers.set("Authorization", "Bearer " + accessToken.token);
|
||||
};
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/sends/access",
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
apiUrl,
|
||||
setAuthTokenHeader,
|
||||
);
|
||||
return new SendAccessResponse(r);
|
||||
}
|
||||
|
||||
async getSendFileDownloadData(
|
||||
send: SendAccessView,
|
||||
request: SendAccessRequest,
|
||||
@@ -72,6 +93,26 @@ export class SendApiService implements SendApiServiceAbstraction {
|
||||
return new SendFileDownloadDataResponse(r);
|
||||
}
|
||||
|
||||
async getSendFileDownloadDataV2(
|
||||
send: SendAccessView,
|
||||
accessToken: SendAccessToken,
|
||||
apiUrl?: string,
|
||||
): Promise<SendFileDownloadDataResponse> {
|
||||
const setAuthTokenHeader = (headers: Headers) => {
|
||||
headers.set("Authorization", "Bearer " + accessToken.token);
|
||||
};
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/sends/access/file/" + send.file.id,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
apiUrl,
|
||||
setAuthTokenHeader,
|
||||
);
|
||||
return new SendFileDownloadDataResponse(r);
|
||||
}
|
||||
|
||||
async getSends(): Promise<ListResponse<SendResponse>> {
|
||||
const r = await this.apiService.send("GET", "/sends", null, true, true);
|
||||
return new ListResponse(r, SendResponse);
|
||||
|
||||
Reference in New Issue
Block a user