1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-31 00:33:33 +00:00

Merge branch 'main' into beeep/dev-container

This commit is contained in:
Conner Turnbull
2026-01-28 11:10:45 -05:00
49 changed files with 1270 additions and 779 deletions

3
.github/CODEOWNERS vendored
View File

@@ -221,6 +221,9 @@ apps/web/src/locales/en/messages.json
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
# Scanning tools
.checkmarx/ @bitwarden/team-appsec
## Overrides
# For the time being platform owns tsconfig and jest config
# These overrides will be removed after Nx is implemented

View File

@@ -152,7 +152,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -260,7 +260,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -392,7 +392,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -130,7 +130,7 @@ jobs:
} >> "$GITHUB_ENV"
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -326,7 +326,7 @@ jobs:
choco install nasm --no-progress
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -183,7 +183,7 @@ jobs:
uses: bitwarden/gh-actions/free-disk-space@main
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -339,7 +339,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -487,7 +487,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -755,7 +755,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -1000,7 +1000,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -1240,7 +1240,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -1515,7 +1515,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -58,7 +58,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true'

View File

@@ -64,7 +64,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -26,7 +26,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -216,7 +216,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
registry-url: "https://registry.npmjs.org/"

View File

@@ -76,7 +76,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -36,7 +36,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@@ -4610,11 +4619,11 @@
"message": "URI match detection is how Bitwarden identifies autofill suggestions.",
"description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item."
},
"regExAdvancedOptionWarning": {
"regExAdvancedOptionWarning": {
"message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.",
"description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy"
},
"startsWithAdvancedOptionWarning": {
"startsWithAdvancedOptionWarning": {
"message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.",
"description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy"
},
@@ -4622,7 +4631,7 @@
"message": "More about match detection",
"description": "Link to match detection docs on warning dialog for advance match strategy"
},
"uriAdvancedOption":{
"uriAdvancedOption": {
"message": "Advanced options",
"description": "Advanced option placeholder for uri option component"
},
@@ -4812,7 +4821,7 @@
}
}
},
"copyFieldCipherName": {
"copyFieldCipherName": {
"message": "Copy $FIELD$, $CIPHERNAME$",
"description": "Title for a button that copies a field value to the clipboard.",
"placeholders": {
@@ -4844,7 +4853,7 @@
"adminConsole": {
"message": "Admin Console"
},
"admin" :{
"admin": {
"message": "Admin"
},
"automaticUserConfirmation": {
@@ -4853,7 +4862,7 @@
"automaticUserConfirmationHint": {
"message": "Automatically confirm pending users while this device is unlocked"
},
"autoConfirmOnboardingCallout":{
"autoConfirmOnboardingCallout": {
"message": "Save time with automatic user confirmation"
},
"autoConfirmWarning": {
@@ -5793,7 +5802,7 @@
"hasItemsVaultNudgeTitle": {
"message": "Welcome to your vault!"
},
"phishingPageTitleV2":{
"phishingPageTitleV2": {
"message": "Phishing attempt detected"
},
"phishingPageSummary": {
@@ -5813,7 +5822,7 @@
"message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.",
"description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this."
},
"phishingPageLearnMore" : {
"phishingPageLearnMore": {
"message": "Learn more about phishing detection"
},
"protectedBy": {
@@ -5981,7 +5990,7 @@
"cardNumberLabel": {
"message": "Card number"
},
"removeMasterPasswordForOrgUserKeyConnector":{
"removeMasterPasswordForOrgUserKeyConnector": {
"message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain."
},
"continueWithLogIn": {
@@ -5999,10 +6008,10 @@
"verifyYourOrganization": {
"message": "Verify your organization to log in"
},
"organizationVerified":{
"organizationVerified": {
"message": "Organization verified"
},
"domainVerified":{
"domainVerified": {
"message": "Domain verified"
},
"leaveOrganizationContent": {
@@ -6120,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@@ -15,7 +15,7 @@ export class IpcContentScriptManagerService {
}
configService
.getFeatureFlag$(FeatureFlag.IpcChannelFramework)
.getFeatureFlag$(FeatureFlag.ContentScriptIpcChannelFramework)
.pipe(
mergeMap(async (enabled) => {
if (!enabled) {

View File

@@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing";
import { ReplaySubject } from "rxjs";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
Environment,
EnvironmentService,
@@ -46,23 +45,16 @@ describe("PeopleTableDataSource", () => {
isCloud: () => false,
} as Environment);
const mockConfigService = {
getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()),
} as any;
const mockEnvironmentService = {
environment$: environmentSubject.asObservable(),
} as any;
TestBed.configureTestingModule({
providers: [
{ provide: ConfigService, useValue: mockConfigService },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
],
providers: [{ provide: EnvironmentService, useValue: mockEnvironmentService }],
});
dataSource = TestBed.runInInjectionContext(
() => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService),
() => new TestPeopleTableDataSource(mockEnvironmentService),
);
});

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { computed, Signal } from "@angular/core";
import { Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Observable, Subject, map } from "rxjs";
@@ -9,8 +9,6 @@ import {
ProviderUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { TableDataSource } from "@bitwarden/components";
@@ -27,8 +25,7 @@ export type ProviderUser = ProviderUserUserDetailsResponse;
export const MaxCheckedCount = 500;
/**
* Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud
* feature flag is enabled on cloud environments.
* Maximum for bulk reinvite limit in cloud environments.
*/
export const CloudBulkReinviteLimit = 8000;
@@ -78,18 +75,15 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
confirmedUserCount: number;
revokedUserCount: number;
/** True when increased bulk limit feature is enabled (feature flag + cloud environment) */
/** True when increased bulk limit feature is enabled (cloud environment) */
readonly isIncreasedBulkLimitEnabled: Signal<boolean>;
constructor(configService: ConfigService, environmentService: EnvironmentService) {
constructor(environmentService: EnvironmentService) {
super();
const featureFlagEnabled = toSignal(
configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
this.isIncreasedBulkLimitEnabled = toSignal(
environmentService.environment$.pipe(map((env) => env.isCloud())),
);
const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud())));
this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud());
}
override set data(data: T[]) {
@@ -224,12 +218,9 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
}
/**
* Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag.
* Returns checked users in visible order, optionally limited to the specified count.
*
* When the feature flag is enabled: Returns checked users in visible order, limited to the specified count.
* When the feature flag is disabled: Returns all checked users without applying any limit.
*
* @param limit The maximum number of users to return (only applied when feature flag is enabled)
* @param limit The maximum number of users to return
* @returns The checked users array
*/
getCheckedUsersWithLimit(limit: number): T[] {

View File

@@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -124,7 +123,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
@@ -139,7 +137,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
this.dataSource = new MembersTableDataSource(this.environmentService);
const organization$ = this.route.params.pipe(
concatMap((params) =>

View File

@@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -100,7 +99,6 @@ export class vNextMembersComponent {
private policyService = inject(PolicyService);
private policyApiService = inject(PolicyApiServiceAbstraction);
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
private configService = inject(ConfigService);
private environmentService = inject(EnvironmentService);
private memberExportService = inject(MemberExportService);
@@ -114,7 +112,7 @@ export class vNextMembersComponent {
protected statusToggle = new BehaviorSubject<OrganizationUserStatusType | undefined>(undefined);
protected readonly dataSource: Signal<MembersTableDataSource> = signal(
new MembersTableDataSource(this.configService, this.environmentService),
new MembersTableDataSource(this.environmentService),
);
protected readonly organization: Signal<Organization | undefined>;
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
@@ -389,7 +387,7 @@ export class vNextMembersComponent {
// Capture the original count BEFORE enforcing the limit
const originalInvitedCount = allInvitedUsers.length;
// When feature flag is enabled, limit invited users and uncheck the excess
// In cloud environments, limit invited users and uncheck the excess
let filteredUsers: OrganizationUserView[];
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
filteredUsers = this.dataSource().limitAndUncheckExcess(
@@ -418,7 +416,7 @@ export class vNextMembersComponent {
this.validationService.showError(result.failed);
}
// When feature flag is enabled, show toast instead of dialog
// In cloud environments, show toast instead of dialog
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
const selectedCount = originalInvitedCount;
const invitedCount = filteredUsers.length;
@@ -441,7 +439,7 @@ export class vNextMembersComponent {
});
}
} else {
// Feature flag disabled - show legacy dialog
// In self-hosted environments, show legacy dialog
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,

View File

@@ -17,7 +17,6 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
@@ -32,7 +31,6 @@ describe("MemberActionsService", () => {
let service: MemberActionsService;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let organizationUserService: MockProxy<OrganizationUserService>;
let configService: MockProxy<ConfigService>;
let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
const organizationId = newGuid() as OrganizationId;
@@ -44,7 +42,6 @@ describe("MemberActionsService", () => {
beforeEach(() => {
organizationUserApiService = mock<OrganizationUserApiService>();
organizationUserService = mock<OrganizationUserService>();
configService = mock<ConfigService>();
organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
mockOrganization = {
@@ -68,7 +65,6 @@ describe("MemberActionsService", () => {
MemberActionsService,
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: OrganizationUserService, useValue: organizationUserService },
{ provide: ConfigService, useValue: configService },
{
provide: OrganizationMetadataServiceAbstraction,
useValue: organizationMetadataService,
@@ -279,308 +275,247 @@ describe("MemberActionsService", () => {
});
describe("bulkReinvite", () => {
const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId];
it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => {
const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId);
const mockResponse = new ListResponse(
{
data: userIdsBatch.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
describe("when feature flag is false", () => {
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(false));
});
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
it("should successfully reinvite multiple users", async () => {
const mockResponse = new ListResponse(
{
data: userIds.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result.failed).toEqual([]);
expect(result.successful).toBeDefined();
expect(result.successful).toEqual(mockResponse);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIds,
);
});
it("should handle bulk reinvite errors", async () => {
const errorMessage = "Bulk reinvite failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(3);
expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage });
});
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
expect(result.failed).toHaveLength(0);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIdsBatch,
);
});
describe("when feature flag is true (batching behavior)", () => {
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(true));
});
it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => {
const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId);
const mockResponse = new ListResponse(
{
data: userIdsBatch.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const mockResponse2 = new ListResponse(
{
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
expect(result.failed).toHaveLength(0);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
1,
);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIdsBatch,
);
});
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(totalUsers);
expect(result.failed).toHaveLength(0);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
1,
organizationId,
userIdsBatch.slice(0, REQUESTS_PER_BATCH),
);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
2,
organizationId,
userIdsBatch.slice(REQUESTS_PER_BATCH),
);
});
const mockResponse2 = new ListResponse(
{
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
it("should aggregate results across multiple successful batches", async () => {
const totalUsers = REQUESTS_PER_BATCH + 50;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const mockResponse2 = new ListResponse(
{
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(totalUsers);
expect(result.failed).toHaveLength(0);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
2,
);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
1,
organizationId,
userIdsBatch.slice(0, REQUESTS_PER_BATCH),
);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
2,
organizationId,
userIdsBatch.slice(REQUESTS_PER_BATCH),
);
});
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
it("should aggregate results across multiple successful batches", async () => {
const totalUsers = REQUESTS_PER_BATCH + 50;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(totalUsers);
expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data);
expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
expect(result.failed).toHaveLength(0);
});
const mockResponse2 = new ListResponse(
{
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
it("should handle mixed individual errors across multiple batches", async () => {
const totalUsers = REQUESTS_PER_BATCH + 4;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({
id,
error: index % 10 === 0 ? "Rate limit exceeded" : null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const mockResponse2 = new ListResponse(
{
data: [
{ id: userIdsBatch[REQUESTS_PER_BATCH], error: null },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" },
],
continuationToken: null,
},
OrganizationUserBulkResponse,
);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(totalUsers);
expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(
mockResponse1.data,
);
expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
expect(result.failed).toHaveLength(0);
});
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
it("should handle mixed individual errors across multiple batches", async () => {
const totalUsers = REQUESTS_PER_BATCH + 4;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({
id,
error: index % 10 === 0 ? "Rate limit exceeded" : null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
// Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch
// Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values
const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1;
const expectedFailuresInBatch2 = 2;
const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2;
const expectedSuccesses = totalUsers - expectedTotalFailures;
const mockResponse2 = new ListResponse(
{
data: [
{ id: userIdsBatch[REQUESTS_PER_BATCH], error: null },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null },
{ id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" },
],
continuationToken: null,
},
OrganizationUserBulkResponse,
);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(expectedSuccesses);
expect(result.failed).toHaveLength(expectedTotalFailures);
expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true);
expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true);
expect(result.failed.some((f) => f.error === "User suspended")).toBe(true);
});
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
it("should aggregate all failures when all batches fail", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const errorMessage = "All batches failed";
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
// Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch
// Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values
const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1;
const expectedFailuresInBatch2 = 2;
const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2;
const expectedSuccesses = totalUsers - expectedTotalFailures;
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(expectedSuccesses);
expect(result.failed).toHaveLength(expectedTotalFailures);
expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true);
expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true);
expect(result.failed.some((f) => f.error === "User suspended")).toBe(true);
});
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(totalUsers);
expect(result.failed.every((f) => f.error === errorMessage)).toBe(true);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
});
it("should aggregate all failures when all batches fail", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const errorMessage = "All batches failed";
it("should handle empty data in batch response", async () => {
const totalUsers = REQUESTS_PER_BATCH + 50;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const mockResponse2 = new ListResponse(
{
data: [],
continuationToken: null,
},
OrganizationUserBulkResponse,
);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(totalUsers);
expect(result.failed.every((f) => f.error === errorMessage)).toBe(true);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
2,
);
});
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
it("should handle empty data in batch response", async () => {
const totalUsers = REQUESTS_PER_BATCH + 50;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
expect(result.failed).toHaveLength(0);
});
const mockResponse2 = new ListResponse(
{
data: [],
continuationToken: null,
},
OrganizationUserBulkResponse,
);
it("should process batches sequentially in order", async () => {
const totalUsers = REQUESTS_PER_BATCH * 2;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const callOrder: number[] = [];
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation(
async (orgId, ids) => {
const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2;
callOrder.push(batchIndex);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
return new ListResponse(
{
data: ids.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
},
);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
expect(result.failed).toHaveLength(0);
});
await service.bulkReinvite(mockOrganization, userIdsBatch);
it("should process batches sequentially in order", async () => {
const totalUsers = REQUESTS_PER_BATCH * 2;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const callOrder: number[] = [];
organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation(
async (orgId, ids) => {
const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2;
callOrder.push(batchIndex);
return new ListResponse(
{
data: ids.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
},
);
await service.bulkReinvite(mockOrganization, userIdsBatch);
expect(callOrder).toEqual([1, 2]);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
2,
);
});
expect(callOrder).toEqual([1, 2]);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
});
});

View File

@@ -16,9 +16,7 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService } from "@bitwarden/components";
@@ -45,7 +43,6 @@ export interface BulkActionResult {
export class MemberActionsService {
private organizationUserApiService = inject(OrganizationUserApiService);
private organizationUserService = inject(OrganizationUserService);
private configService = inject(ConfigService);
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
private apiService = inject(ApiService);
private dialogService = inject(DialogService);
@@ -175,18 +172,9 @@ export class MemberActionsService {
async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> {
this.startProcessing();
try {
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) =>
this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch),
);
if (increaseBulkReinviteLimitForCloud) {
return await this.vNextBulkReinvite(organization, userIds);
} else {
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
);
return { successful: result, failed: [] };
}
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
@@ -196,15 +184,6 @@ export class MemberActionsService {
}
}
async vNextBulkReinvite(
organization: Organization,
userIds: UserId[],
): Promise<BulkActionResult> {
return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) =>
this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch),
);
}
allowResetPassword(
orgUser: OrganizationUserView,
organization: Organization,

View File

@@ -5,3 +5,4 @@ export { POLICY_EDIT_REGISTER } from "./policy-register-token";
export { AutoConfirmPolicy } from "./policy-edit-definitions";
export { PolicyEditDialogResult } from "./policy-edit-dialog.component";
export * from "./policy-edit-dialogs";
export { PolicyOrderPipe } from "./pipes/policy-order.pipe";

View File

@@ -0,0 +1,66 @@
import { Pipe, PipeTransform } from "@angular/core";
import { BasePolicyEditDefinition } from "../base-policy-edit.component";
/**
* Order mapping for policies. Policies are ordered according to this mapping.
* Policies not in this mapping will appear at the end, maintaining their relative order.
*/
const POLICY_ORDER_MAP = new Map<string, number>([
["singleOrg", 1],
["organizationDataOwnership", 2],
["centralizeDataOwnership", 2],
["masterPassPolicyTitle", 3],
["accountRecoveryPolicy", 4],
["requireSso", 5],
["automaticAppLoginWithSSO", 6],
["twoStepLoginPolicyTitle", 7],
["blockClaimedDomainAccountCreation", 8],
["sessionTimeoutPolicyTitle", 9],
["removeUnlockWithPinPolicyTitle", 10],
["passwordGenerator", 11],
["uriMatchDetectionPolicy", 12],
["activateAutofill", 13],
["sendOptions", 14],
["disableSend", 15],
["restrictedItemTypePolicy", 16],
["freeFamiliesSponsorship", 17],
["disableExport", 18],
]);
/**
* Default order for policies not in the mapping. This ensures unmapped policies
* appear at the end while maintaining their relative order.
*/
const DEFAULT_ORDER = 999;
@Pipe({
name: "policyOrder",
standalone: true,
})
export class PolicyOrderPipe implements PipeTransform {
transform(
policies: readonly BasePolicyEditDefinition[] | null | undefined,
): BasePolicyEditDefinition[] {
if (policies == null || policies.length === 0) {
return [];
}
const sortedPolicies = [...policies];
sortedPolicies.sort((a, b) => {
const orderA = POLICY_ORDER_MAP.get(a.name) ?? DEFAULT_ORDER;
const orderB = POLICY_ORDER_MAP.get(b.name) ?? DEFAULT_ORDER;
if (orderA !== orderB) {
return orderA - orderB;
}
const indexA = policies.indexOf(a);
const indexB = policies.indexOf(b);
return indexA - indexB;
});
return sortedPolicies;
}
}

View File

@@ -15,7 +15,7 @@
} @else {
<bit-table>
<ng-template body>
@for (p of policies$ | async; track $index) {
@for (p of policies$ | async | policyOrder; track $index) {
@if (p.display$(organization, configService) | async) {
<tr bitRow>
<td bitCell ngPreserveWhitespaces>

View File

@@ -21,13 +21,14 @@ import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component";
import { PolicyOrderPipe } from "./pipes/policy-order.pipe";
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
@Component({
templateUrl: "policies.component.html",
imports: [SharedModule, HeaderModule],
imports: [SharedModule, HeaderModule, PolicyOrderPipe],
providers: [
safeProvider({
provide: PolicyListService,

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

@@ -586,6 +586,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@@ -1365,6 +1368,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@@ -12691,6 +12700,21 @@
"storageFullDescription": {
"message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage."
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
},
"whenYouRemoveStorage": {
"message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill."
},
@@ -12699,5 +12723,8 @@
},
"emailProtected": {
"message": "Email protected"
},
"invalidSendPassword": {
"message": "Invalid Send password"
}
}

View File

@@ -19,7 +19,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -85,7 +84,6 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
private providerService: ProviderService,
private router: Router,
private accountService: AccountService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
@@ -100,7 +98,7 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
this.dataSource = new MembersTableDataSource(this.environmentService);
combineLatest([
this.activatedRoute.parent.params,

View File

@@ -94,7 +94,12 @@
[bitAction]="loadMoreEvents"
*ngIf="continuationToken"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<i
*ngIf="loading"
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span>{{ "loadMore" | i18n }}</span>
</button>
</ng-container>

View File

@@ -21,7 +21,6 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider
import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -72,7 +71,6 @@ export class vNextMembersComponent {
private activatedRoute = inject(ActivatedRoute);
private providerService = inject(ProviderService);
private accountService = inject(AccountService);
private configService = inject(ConfigService);
private environmentService = inject(EnvironmentService);
private providerActionsService = inject(ProviderActionsService);
private memberActionsService = inject(MemberActionsService);
@@ -94,7 +92,7 @@ export class vNextMembersComponent {
protected statusToggle = new BehaviorSubject<ProviderUserStatusType | undefined>(undefined);
protected readonly dataSource: WritableSignal<ProvidersTableDataSource> = signal(
new ProvidersTableDataSource(this.configService, this.environmentService),
new ProvidersTableDataSource(this.environmentService),
);
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
@@ -177,7 +175,7 @@ export class vNextMembersComponent {
// Capture the original count BEFORE enforcing the limit
const originalInvitedCount = allInvitedUsers.length;
// When feature flag is enabled, limit invited users and uncheck the excess
// In cloud environments, limit invited users and uncheck the excess
let checkedInvitedUsers: ProviderUser[];
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
checkedInvitedUsers = this.dataSource().limitAndUncheckExcess(
@@ -198,7 +196,7 @@ export class vNextMembersComponent {
}
try {
// When feature flag is enabled, show toast instead of dialog
// In cloud environments, show toast instead of dialog
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
await this.apiService.postManyProviderUserReinvite(
providerId,
@@ -226,7 +224,7 @@ export class vNextMembersComponent {
});
}
} else {
// Feature flag disabled - show legacy dialog
// In self-hosted environments, show legacy dialog
const request = this.apiService.postManyProviderUserReinvite(
providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),

View File

@@ -13,7 +13,6 @@ export enum FeatureFlag {
/* Admin Console Team */
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
/* Auth */
@@ -71,7 +70,7 @@ export enum FeatureFlag {
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework",
/* Innovation */
PM19148_InnovationArchive = "pm-19148-innovation-archive",
@@ -104,7 +103,6 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
/* Autofill */
@@ -162,7 +160,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.ContentScriptIpcChannelFramework]: FALSE,
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,

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

View File

@@ -7,64 +7,22 @@
<bit-label>{{ "limitSendViews" | i18n }}</bit-label>
<input bitInput type="number" formControlName="maxAccessCount" min="1" />
<bit-hint>{{ "limitSendViewsHint" | i18n }}</bit-hint>
<bit-hint *ngIf="shouldShowCount"
>&nbsp;{{ "limitSendViewsCount" | i18n: viewsLeft }}</bit-hint
>
@if (shouldShowCount) {
<bit-hint>&nbsp;{{ "limitSendViewsCount" | i18n: viewsLeft }}</bit-hint>
}
</bit-form-field>
<bit-form-field>
<bit-label>{{ (passwordRemoved ? "newPassword" : "password") | i18n }}</bit-label>
<input bitInput type="password" formControlName="password" />
<button
data-testid="toggle-visibility-for-password"
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
*ngIf="!hasPassword"
></button>
<button
type="button"
bitIconButton="bwi-generate"
bitSuffix
[label]="'generatePassword' | i18n"
[disabled]="!config.areSendsAllowed"
(click)="generatePassword()"
data-testid="generate-password"
*ngIf="!hasPassword"
></button>
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[label]="'copyPassword' | i18n"
[disabled]="!config.areSendsAllowed || !sendOptionsForm.get('password').value"
[valueLabel]="'password' | i18n"
[appCopyClick]="sendOptionsForm.get('password').value"
showToast
*ngIf="!hasPassword"
></button>
<button
*ngIf="hasPassword"
class="tw-border-l-0 last:tw-rounded-r focus-visible:tw-border-l focus-visible:tw-ml-[-1px]"
bitSuffix
type="button"
buttonType="danger"
bitIconButton="bwi-minus-circle"
[label]="'removePassword' | i18n"
[bitAction]="removePassword"
showToast
></button>
<bit-hint>{{ "sendPasswordDescV3" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-control *ngIf="!disableHideEmail || originalSendView?.hideEmail">
<input
[disabled]="disableHideEmail && !sendOptionsForm.get('hideEmail').value"
bitCheckbox
type="checkbox"
formControlName="hideEmail"
/>
<bit-label>{{ "hideYourEmail" | i18n }}</bit-label>
</bit-form-control>
@if (!disableHideEmail || originalSendView?.hideEmail) {
<bit-form-control>
<input
[disabled]="disableHideEmail && !sendOptionsForm.get('hideEmail').value"
bitCheckbox
type="checkbox"
formControlName="hideEmail"
/>
<bit-label>{{ "hideYourEmail" | i18n }}</bit-label>
</bit-form-control>
}
<bit-form-field disableMargin>
<bit-label>{{ "privateNote" | i18n }}</bit-label>
<textarea bitInput rows="3" formControlName="notes"></textarea>

View File

@@ -5,12 +5,7 @@ import { of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { DialogService, ToastService } from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core";
import { SendFormContainer } from "../../send-form-container";
@@ -32,14 +27,9 @@ describe("SendOptionsComponent", () => {
declarations: [],
providers: [
{ provide: SendFormContainer, useValue: mockSendFormContainer },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: SendApiService, useValue: mock<SendApiService>() },
{ provide: PolicyService, useValue: mock<PolicyService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: CredentialGeneratorService, useValue: mock<CredentialGeneratorService>() },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
],
}).compileComponents();
fixture = TestBed.createComponent(SendOptionsComponent);
@@ -55,13 +45,4 @@ describe("SendOptionsComponent", () => {
it("should create", () => {
expect(component).toBeTruthy();
});
it("should emit a null password when password textbox is empty", async () => {
const newSend = {} as SendView;
mockSendFormContainer.patchSend.mockImplementation((updateFn) => updateFn(newSend));
component.sendOptionsForm.patchValue({ password: "testing" });
expect(newSend.password).toBe("testing");
component.sendOptionsForm.patchValue({ password: "" });
expect(newSend.password).toBe(null);
});
});

View File

@@ -4,32 +4,26 @@ import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BehaviorSubject, firstValueFrom, map, switchMap, tap } from "rxjs";
import { switchMap, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { pin } from "@bitwarden/common/tools/rx";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import {
TypographyModule,
AsyncActionsModule,
ButtonModule,
CardComponent,
CheckboxModule,
DialogService,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
ToastService,
TypographyModule,
SelectModule,
} from "@bitwarden/components";
import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core";
import { SendFormConfig } from "../../abstractions/send-form-config.service";
import { SendFormContainer } from "../../send-form-container";
@@ -39,6 +33,7 @@ import { SendFormContainer } from "../../send-form-container";
@Component({
selector: "tools-send-options",
templateUrl: "./send-options.component.html",
standalone: true,
imports: [
AsyncActionsModule,
ButtonModule,
@@ -51,6 +46,7 @@ import { SendFormContainer } from "../../send-form-container";
ReactiveFormsModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
],
})
@@ -64,19 +60,14 @@ export class SendOptionsComponent implements OnInit {
@Input()
originalSendView: SendView;
disableHideEmail = false;
passwordRemoved = false;
sendOptionsForm = this.formBuilder.group({
maxAccessCount: [null as number],
accessCount: [null as number],
notes: [null as string],
password: [null as string],
hideEmail: [false as boolean],
});
get hasPassword(): boolean {
return this.originalSendView && this.originalSendView.password !== null;
}
get shouldShowCount(): boolean {
return this.config.mode === "edit" && this.sendOptionsForm.value.maxAccessCount !== null;
}
@@ -91,13 +82,8 @@ export class SendOptionsComponent implements OnInit {
constructor(
private sendFormContainer: SendFormContainer,
private dialogService: DialogService,
private sendApiService: SendApiService,
private formBuilder: FormBuilder,
private policyService: PolicyService,
private i18nService: I18nService,
private toastService: ToastService,
private generatorService: CredentialGeneratorService,
private accountService: AccountService,
) {
this.sendFormContainer.registerChildForm("sendOptionsForm", this.sendOptionsForm);
@@ -113,87 +99,28 @@ export class SendOptionsComponent implements OnInit {
this.disableHideEmail = disableHideEmail;
});
this.sendOptionsForm.valueChanges
.pipe(
tap((value) => {
if (Utils.isNullOrWhitespace(value.password)) {
value.password = null;
}
}),
takeUntilDestroyed(),
)
.subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
password: value.password,
hideEmail: value.hideEmail,
notes: value.notes,
});
return send;
this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
hideEmail: value.hideEmail,
notes: value.notes,
});
return send;
});
});
}
generatePassword = async () => {
const on$ = new BehaviorSubject<GenerateRequest>({ source: "send", type: Type.password });
const account$ = this.accountService.activeAccount$.pipe(
pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }),
);
const generatedCredential = await firstValueFrom(
this.generatorService.generate$({ on$, account$ }),
);
this.sendOptionsForm.patchValue({
password: generatedCredential.credential,
});
};
removePassword = async () => {
if (!this.originalSendView || !this.originalSendView.password) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "removePassword" },
content: { key: "removePasswordConfirmation" },
type: "warning",
});
if (!confirmed) {
return false;
}
this.passwordRemoved = true;
await this.sendApiService.removePassword(this.originalSendView.id);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("removedPassword"),
});
this.originalSendView.password = null;
this.sendOptionsForm.patchValue({
password: null,
});
this.sendOptionsForm.get("password")?.enable();
};
ngOnInit() {
if (this.sendFormContainer.originalSendView) {
this.sendOptionsForm.patchValue({
maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount,
accessCount: this.sendFormContainer.originalSendView.accessCount,
password: this.hasPassword ? "************" : null, // 12 masked characters as a placeholder
hideEmail: this.sendFormContainer.originalSendView.hideEmail,
notes: this.sendFormContainer.originalSendView.notes,
});
}
if (this.hasPassword) {
this.sendOptionsForm.get("password")?.disable();
}
if (!this.config.areSendsAllowed) {
this.sendOptionsForm.disable();

View File

@@ -6,7 +6,7 @@
<bit-card>
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input appAutofocus bitInput type="text" formControlName="name" />
<input bitInput type="text" formControlName="name" />
</bit-form-field>
<tools-send-text-details
@@ -34,7 +34,7 @@
></button>
</bit-form-field>
<bit-form-field disableMargin>
<bit-form-field>
<bit-label>{{ "deletionDate" | i18n }}</bit-label>
<bit-select
id="deletionDate"
@@ -49,6 +49,80 @@
</bit-select>
<bit-hint>{{ "deletionDateDescV2" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field [disableMargin]="!sendDetailsForm.get('authType').value">
<bit-label>{{ "whoCanView" | i18n }}</bit-label>
<bit-select formControlName="authType">
@for (option of availableAuthTypes$ | async; track option.value) {
<bit-option [value]="option.value" [label]="option.name"></bit-option>
}
</bit-select>
@if (sendDetailsForm.get("authType").value === AuthType.Email) {
<bit-hint class="tw-mt-2">{{ "emailVerificationDesc" | i18n }}</bit-hint>
}
</bit-form-field>
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
<bit-form-field disableMargin>
<bit-label>{{ (passwordRemoved ? "newPassword" : "password") | i18n }}</bit-label>
<input bitInput type="password" formControlName="password" />
<div bitSuffix ngProjectAs="[bitSuffix]" class="tw-flex tw-items-center">
@if (!hasPassword) {
<button
data-testid="toggle-visibility-for-password"
type="button"
bitIconButton
size="small"
bitPasswordInputToggle
></button>
<button
type="button"
bitIconButton="bwi-generate"
size="small"
[label]="'generatePassword' | i18n"
[disabled]="!config.areSendsAllowed"
(click)="generatePassword()"
data-testid="generate-password"
></button>
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[label]="'copyPassword' | i18n"
[disabled]="!config.areSendsAllowed || !sendDetailsForm.get('password').value"
[valueLabel]="'password' | i18n"
[appCopyClick]="sendDetailsForm.get('password').value"
showToast
></button>
} @else {
<button
class="tw-border-l-0 last:tw-rounded-r focus-visible:tw-border-l focus-visible:tw-ml-[-1px]"
type="button"
buttonType="danger"
bitIconButton="bwi-minus-circle"
size="small"
[label]="'removePassword' | i18n"
[bitAction]="removePassword"
showToast
></button>
}
</div>
<bit-hint>{{ "sendPasswordDescV3" | i18n }}</bit-hint>
</bit-form-field>
}
@if (sendDetailsForm.get("authType").value === AuthType.Email) {
<bit-form-field disableMargin class="tw-mt-4">
<bit-label>{{ "emails" | i18n }}</bit-label>
<textarea
bitInput
formControlName="emails"
rows="3"
[placeholder]="'emailPlaceholder' | i18n"
></textarea>
<bit-hint>{{ "enterMultipleEmailsSeparatedByComma" | i18n }}</bit-hint>
</bit-form-field>
}
</bit-card>
<tools-send-options [config]="config" [originalSendView]="originalSendView"></tools-send-options>
</bit-section>

View File

@@ -1,4 +1,29 @@
import { DatePreset, isDatePreset, asDatePreset } from "./send-details.component";
import { DatePipe } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { DialogService, ToastService } from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core";
import { SendFormContainer } from "../../send-form-container";
import {
DatePreset,
SendDetailsComponent,
asDatePreset,
isDatePreset,
} from "./send-details.component";
describe("SendDetails DatePreset utilities", () => {
it("accepts all defined numeric presets", () => {
@@ -25,3 +50,81 @@ describe("SendDetails DatePreset utilities", () => {
});
});
});
describe("SendDetailsComponent", () => {
let component: SendDetailsComponent;
let fixture: ComponentFixture<SendDetailsComponent>;
const mockSendFormContainer = mock<SendFormContainer>();
const mockI18nService = mock<I18nService>();
const mockConfigService = mock<ConfigService>();
const mockAccountService = mock<AccountService>();
const mockBillingStateService = mock<BillingAccountProfileStateService>();
const mockGeneratorService = mock<CredentialGeneratorService>();
const mockSendApiService = mock<SendApiService>();
const mockEnvironmentService = mock<EnvironmentService>();
beforeEach(async () => {
mockEnvironmentService.environment$ = of({
getSendUrl: () => "https://send.bitwarden.com/",
} as any);
mockAccountService.activeAccount$ = of({ id: "userId" } as Account);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
mockI18nService.t.mockImplementation((k) => k);
await TestBed.configureTestingModule({
imports: [SendDetailsComponent, ReactiveFormsModule],
providers: [
{ provide: SendFormContainer, useValue: mockSendFormContainer },
{ provide: I18nService, useValue: mockI18nService },
{ provide: DatePipe, useValue: new DatePipe("en-US") },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: BillingAccountProfileStateService, useValue: mockBillingStateService },
{ provide: CredentialGeneratorService, useValue: mockGeneratorService },
{ provide: SendApiService, useValue: mockSendApiService },
{ provide: PolicyService, useValue: mock<PolicyService>() },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
],
}).compileComponents();
fixture = TestBed.createComponent(SendDetailsComponent);
component = fixture.componentInstance;
component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text };
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize authType to None if no password or emails", () => {
expect(component.sendDetailsForm.value.authType).toBe(AuthType.None);
});
it("should toggle validation based on authType", () => {
const emailsControl = component.sendDetailsForm.get("emails");
const passwordControl = component.sendDetailsForm.get("password");
// Default
expect(emailsControl?.validator).toBeNull();
expect(passwordControl?.validator).toBeNull();
// Select Email
component.sendDetailsForm.patchValue({ authType: AuthType.Email });
expect(emailsControl?.validator).not.toBeNull();
expect(passwordControl?.validator).toBeNull();
// Select Password
component.sendDetailsForm.patchValue({ authType: AuthType.Password });
expect(passwordControl?.validator).not.toBeNull();
expect(emailsControl?.validator).toBeNull();
// Select None
component.sendDetailsForm.patchValue({ authType: AuthType.None });
expect(emailsControl?.validator).toBeNull();
expect(passwordControl?.validator).toBeNull();
});
});

View File

@@ -3,13 +3,28 @@
import { CommonModule, DatePipe } from "@angular/common";
import { Component, OnInit, Input } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import {
FormBuilder,
FormControl,
ReactiveFormsModule,
Validators,
ValidatorFn,
ValidationErrors,
} from "@angular/forms";
import { firstValueFrom, BehaviorSubject, combineLatest, map, switchMap, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { pin } from "@bitwarden/common/tools/rx";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
SectionComponent,
@@ -20,7 +35,12 @@ import {
IconButtonModule,
CheckboxModule,
SelectModule,
AsyncActionsModule,
ButtonModule,
ToastService,
DialogService,
} from "@bitwarden/components";
import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core";
import { SendFormConfig } from "../../abstractions/send-form-config.service";
import { SendFormContainer } from "../../send-form-container";
@@ -78,6 +98,7 @@ export function asDatePreset(value: unknown): DatePreset | undefined {
@Component({
selector: "tools-send-details",
templateUrl: "./send-details.component.html",
standalone: true,
imports: [
SectionComponent,
SectionHeaderComponent,
@@ -92,7 +113,10 @@ export function asDatePreset(value: unknown): DatePreset | undefined {
IconButtonModule,
CheckboxModule,
CommonModule,
CommonModule,
SelectModule,
AsyncActionsModule,
ButtonModule,
],
})
export class SendDetailsComponent implements OnInit {
@@ -105,31 +129,110 @@ export class SendDetailsComponent implements OnInit {
FileSendType = SendType.File;
TextSendType = SendType.Text;
readonly AuthType = AuthType;
sendLink: string | null = null;
customDeletionDateOption: DatePresetSelectOption | null = null;
datePresetOptions: DatePresetSelectOption[] = [];
passwordRemoved = false;
emailVerificationFeatureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.SendEmailOTP);
hasPremium$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
),
);
authTypes: { name: string; value: AuthType; disabled?: boolean }[] = [
{ name: this.i18nService.t("noAuth"), value: AuthType.None },
{ name: this.i18nService.t("specificPeople"), value: AuthType.Email },
{ name: this.i18nService.t("anyOneWithPassword"), value: AuthType.Password },
];
availableAuthTypes$ = combineLatest([this.emailVerificationFeatureFlag$, this.hasPremium$]).pipe(
map(([enabled, hasPremium]) => {
if (!enabled || !hasPremium) {
return this.authTypes.filter((t) => t.value !== AuthType.Email);
}
return this.authTypes;
}),
);
sendDetailsForm = this.formBuilder.group({
name: new FormControl("", Validators.required),
selectedDeletionDatePreset: new FormControl(DatePreset.SevenDays || "", Validators.required),
authType: [AuthType.None as AuthType],
password: [null as string],
emails: [null as string],
});
get hasPassword(): boolean {
return this.originalSendView?.password != null;
}
constructor(
protected sendFormContainer: SendFormContainer,
protected formBuilder: FormBuilder,
protected i18nService: I18nService,
protected datePipe: DatePipe,
protected environmentService: EnvironmentService,
private configService: ConfigService,
private accountService: AccountService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private generatorService: CredentialGeneratorService,
private sendApiService: SendApiService,
private dialogService: DialogService,
private toastService: ToastService,
) {
this.sendDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
return Object.assign(send, {
name: value.name,
deletionDate: new Date(this.formattedDeletionDate),
expirationDate: new Date(this.formattedDeletionDate),
} as SendView);
this.sendDetailsForm.valueChanges
.pipe(
tap((value) => {
if (Utils.isNullOrWhitespace(value.password)) {
value.password = null;
}
}),
takeUntilDestroyed(),
)
.subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
return Object.assign(send, {
name: value.name,
deletionDate: new Date(this.formattedDeletionDate),
expirationDate: new Date(this.formattedDeletionDate),
password: value.password,
emails: value.emails
? value.emails
.split(",")
.map((e) => e.trim())
.filter((e) => e.length > 0)
: null,
} as unknown as SendView);
});
});
this.sendDetailsForm
.get("authType")
.valueChanges.pipe(takeUntilDestroyed())
.subscribe((type) => {
const emailsControl = this.sendDetailsForm.get("emails");
const passwordControl = this.sendDetailsForm.get("password");
if (type === AuthType.Password) {
emailsControl.setValue(null);
emailsControl.clearValidators();
passwordControl.setValidators([Validators.required]);
} else if (type === AuthType.Email) {
passwordControl.setValue(null);
passwordControl.clearValidators();
emailsControl.setValidators([Validators.required, this.emailListValidator()]);
} else {
emailsControl.setValue(null);
emailsControl.clearValidators();
passwordControl.setValue(null);
passwordControl.clearValidators();
}
emailsControl.updateValueAndValidity();
passwordControl.updateValueAndValidity();
});
});
this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm);
}
@@ -141,8 +244,15 @@ export class SendDetailsComponent implements OnInit {
this.sendDetailsForm.patchValue({
name: this.originalSendView.name,
selectedDeletionDatePreset: this.originalSendView.deletionDate.toString(),
password: this.hasPassword ? "************" : null,
authType: this.originalSendView.authType,
emails: this.originalSendView.emails?.join(", ") ?? null,
});
if (this.hasPassword) {
this.sendDetailsForm.get("password")?.disable();
}
if (this.originalSendView.deletionDate) {
this.customDeletionDateOption = {
name: this.datePipe.transform(this.originalSendView.deletionDate, "short"),
@@ -193,4 +303,61 @@ export class SendDetailsComponent implements OnInit {
const milliseconds = now.setTime(now.getTime() + preset * 60 * 60 * 1000);
return new Date(milliseconds).toString();
}
emailListValidator(): ValidatorFn {
return (control: FormControl): ValidationErrors | null => {
if (!control.value) {
return null;
}
const emails = control.value.split(",").map((e: string) => e.trim());
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e));
return invalidEmails.length > 0 ? { email: true } : null;
};
}
generatePassword = async () => {
const on$ = new BehaviorSubject<GenerateRequest>({ source: "send", type: Type.password });
const account$ = this.accountService.activeAccount$.pipe(
pin({ name: () => "send-details.component", distinct: (p, c) => p.id === c.id }),
);
const generatedCredential = await firstValueFrom(
this.generatorService.generate$({ on$, account$ }),
);
this.sendDetailsForm.patchValue({
password: generatedCredential.credential,
});
};
removePassword = async () => {
if (!this.originalSendView?.password) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "removePassword" },
content: { key: "removePasswordConfirmation" },
type: "warning",
});
if (!confirmed) {
return false;
}
this.passwordRemoved = true;
await this.sendApiService.removePassword(this.originalSendView.id);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("removedPassword"),
});
this.originalSendView.password = null;
this.sendDetailsForm.patchValue({
password: null,
});
this.sendDetailsForm.get("password")?.enable();
};
}

8
package-lock.json generated
View File

@@ -106,7 +106,7 @@
"@types/koa__multer": "2.0.7",
"@types/koa__router": "12.0.4",
"@types/koa-bodyparser": "4.3.7",
"@types/koa-json": "2.0.23",
"@types/koa-json": "2.0.24",
"@types/lowdb": "1.0.15",
"@types/lunr": "2.3.7",
"@types/node": "22.19.7",
@@ -15768,9 +15768,9 @@
}
},
"node_modules/@types/koa-json": {
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.23.tgz",
"integrity": "sha512-LJKLFouztosawgU5xrtanK4neLCQKXl+vuVN96YMeVdKTYObLq2Qybggm9V426Jwam8Gi/zOrPw1g+QH0VaEHw==",
"version": "2.0.24",
"resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.24.tgz",
"integrity": "sha512-FF+nQil6YO8vXMuLnOgGHYspSZVVpi+W79m9/s7LBSOQhlX7QY02X3Evk/g1GgWNLbO674AQaziX6OCCKzQ6Aw==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -73,7 +73,7 @@
"@types/koa__multer": "2.0.7",
"@types/koa__router": "12.0.4",
"@types/koa-bodyparser": "4.3.7",
"@types/koa-json": "2.0.23",
"@types/koa-json": "2.0.24",
"@types/lowdb": "1.0.15",
"@types/lunr": "2.3.7",
"@types/node": "22.19.7",