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