-
{{ "sendAccessUnavailable" | i18n }}
+@if (loading()) {
+
+
+ {{ "loading" | i18n }}
-
-
{{ "unexpectedErrorSend" | i18n }}
-
-
-
+}
+
diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts
index b360044a8b6..13e82bd4cfa 100644
--- a/apps/web/src/app/tools/send/send-access/send-auth.component.ts
+++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts
@@ -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
();
- readonly key = input.required();
+export class SendAuthComponent implements OnInit {
+ protected readonly id = input.required();
+ protected readonly key = input.required();
- 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(false);
+ readonly error = signal(false);
+ readonly unavailable = signal(false);
+ readonly sendAuthType = signal(AuthType.None);
+ readonly enterOtp = signal(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 {
+ 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;
+ }
}
diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.html b/apps/web/src/app/tools/send/send-access/send-view.component.html
index dd0b770b261..3536499ddad 100644
--- a/apps/web/src/app/tools/send/send-access/send-view.component.html
+++ b/apps/web/src/app/tools/send/send-access/send-view.component.html
@@ -1,41 +1,13 @@
-
- {{ "viewSendHiddenEmailWarning" | i18n }}
- {{
- "learnMore" | i18n
- }}.
-
+@if (hideEmail()) {
+
+ {{ "viewSendHiddenEmailWarning" | i18n }}
+ {{
+ "learnMore" | i18n
+ }}
+
+}
-
-
-
{{ "sendAccessUnavailable" | i18n }}
-
-
-
{{ "unexpectedErrorSend" | i18n }}
-
-
-
- {{ send.name }}
-
-
-
-
-
-
-
-
-
-
-
- Expires: {{ expirationDate | date: "medium" }}
-
-
-
-
+@if (loading()) {
{{ "loading" | i18n }}
-
+} @else {
+ @if (unavailable()) {
+
+
{{ "sendAccessUnavailable" | i18n }}
+
+ }
+ @if (error()) {
+
+
{{ "unexpectedErrorSend" | i18n }}
+
+ }
+ @if (send()) {
+
+
+ {{ send().name }}
+
+
+ @switch (send().type) {
+ @case (sendType.Text) {
+
+ }
+ @case (sendType.File) {
+
+ }
+ }
+ @if (expirationDate()) {
+
Expires: {{ expirationDate() | date: "medium" }}
+ }
+
+ }
+}
diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts
index 060dc1958b1..1ab9a121ace 100644
--- a/apps/web/src/app/tools/send/send-access/send-view.component.ts
+++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts
@@ -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();
readonly key = input.required();
+ readonly accessToken = input(null);
readonly sendResponse = input(null);
readonly accessRequest = input(new SendAccessRequest());
authRequired = output();
- send: SendAccessView | null = null;
+ readonly send = signal(null);
+ readonly expirationDate = computed(() => this.send()?.expirationDate ?? null);
+ readonly creatorIdentifier = computed(
+ () => this.send()?.creatorIdentifier ?? null,
+ );
+ readonly hideEmail = computed(
+ () => this.send() != null && this.creatorIdentifier() == null,
+ );
+ readonly loading = signal(false);
+ readonly unavailable = signal(false);
+ readonly error = signal(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();
}
}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 932b58cf22a..a01e0b91e71 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -12699,5 +12699,8 @@
},
"emailProtected": {
"message": "Email protected"
+ },
+ "invalidSendPassword": {
+ "message": "Invalid Send password"
}
}
diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts
index 80c4410af11..a7e36d8c8b1 100644
--- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts
+++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts
@@ -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;
+ abstract postSendAccessV2(
+ accessToken: SendAccessToken,
+ apiUrl?: string,
+ ): Promise;
abstract getSends(): Promise>;
abstract postSend(request: SendRequest): Promise;
abstract postFileTypeSend(request: SendRequest): Promise;
@@ -28,6 +34,11 @@ export abstract class SendApiService {
request: SendAccessRequest,
apiUrl?: string,
): Promise;
+ abstract getSendFileDownloadDataV2(
+ send: SendAccessView,
+ accessToken: SendAccessToken,
+ apiUrl?: string,
+ ): Promise;
abstract renewSendFileUploadUrl(
sendId: string,
fileId: string,
diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts
index 1c931b7ad98..f09117316d8 100644
--- a/libs/common/src/tools/send/services/send-api.service.ts
+++ b/libs/common/src/tools/send/services/send-api.service.ts
@@ -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 {
+ 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 {
+ 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> {
const r = await this.apiService.send("GET", "/sends", null, true, true);
return new ListResponse(r, SendResponse);