1
0
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:
Mike Amirault
2026-01-28 09:32:02 -05:00
committed by GitHub
parent c2da621663
commit 65b224646d
15 changed files with 493 additions and 223 deletions

View File

@@ -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()"

View File

@@ -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);
}
}

View File

@@ -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>
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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",
});

View File

@@ -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>

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>
}
}

View File

@@ -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();
}
}

View File

@@ -12699,5 +12699,8 @@
},
"emailProtected": {
"message": "Email protected"
},
"invalidSendPassword": {
"message": "Invalid Send password"
}
}

View File

@@ -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,

View File

@@ -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);