1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 22:13:32 +00:00

Merge branch 'main' into billing/pm-29602/update-cart-summary

This commit is contained in:
Stephon Brown
2026-01-28 15:20:43 -05:00
214 changed files with 2128 additions and 1384 deletions

3
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { WebauthnUtils } from "../utils/webauthn-utils";
import { MessageTypes } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
(function (globalContext) {
if (globalContext.document.currentScript) {
if (globalContext.document.currentScript?.parentNode) {
globalContext.document.currentScript.parentNode.removeChild(
globalContext.document.currentScript,
);
@@ -86,7 +84,7 @@ import { Messenger } from "./messaging/messenger";
*/
async function createWebAuthnCredential(
options?: CredentialCreationOptions,
): Promise<Credential> {
): Promise<Credential | null> {
if (!isWebauthnCall(options)) {
return await browserCredentials.create(options);
}
@@ -106,13 +104,18 @@ import { Messenger } from "./messaging/messenger";
options?.signal,
);
if (response.type !== MessageTypes.CredentialCreationResponse) {
if (response.type !== MessageTypes.CredentialCreationResponse || !response.result) {
throw new Error("Something went wrong.");
}
return WebauthnUtils.mapCredentialRegistrationResult(response.result);
} catch (error) {
if (error && error.fallbackRequested && fallbackSupported) {
if (
fallbackSupported &&
error instanceof Object &&
"fallbackRequested" in error &&
error.fallbackRequested
) {
await waitForFocus();
return await browserCredentials.create(options);
}
@@ -127,7 +130,9 @@ import { Messenger } from "./messaging/messenger";
* @param options Options for creating new credentials.
* @returns Promise that resolves to the new credential object.
*/
async function getWebAuthnCredential(options?: CredentialRequestOptions): Promise<Credential> {
async function getWebAuthnCredential(
options?: CredentialRequestOptions,
): Promise<Credential | null> {
if (!isWebauthnCall(options)) {
return await browserCredentials.get(options);
}
@@ -153,7 +158,7 @@ import { Messenger } from "./messaging/messenger";
internalAbortController.signal,
);
internalAbortController.signal.removeEventListener("abort", abortListener);
if (response.type !== MessageTypes.CredentialGetResponse) {
if (response.type !== MessageTypes.CredentialGetResponse || !response.result) {
throw new Error("Something went wrong.");
}
@@ -176,7 +181,7 @@ import { Messenger } from "./messaging/messenger";
abortSignal.removeEventListener("abort", abortListener);
internalAbortControllers.forEach((controller) => controller.abort());
return response;
return response ?? null;
}
try {
@@ -188,13 +193,18 @@ import { Messenger } from "./messaging/messenger";
options?.signal,
);
if (response.type !== MessageTypes.CredentialGetResponse) {
if (response.type !== MessageTypes.CredentialGetResponse || !response.result) {
throw new Error("Something went wrong.");
}
return WebauthnUtils.mapCredentialAssertResult(response.result);
} catch (error) {
if (error && error.fallbackRequested && fallbackSupported) {
if (
fallbackSupported &&
error instanceof Object &&
"fallbackRequested" in error &&
error.fallbackRequested
) {
await waitForFocus();
return await browserCredentials.get(options);
}
@@ -203,8 +213,10 @@ import { Messenger } from "./messaging/messenger";
}
}
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
return options && "publicKey" in options;
function isWebauthnCall(
options?: CredentialCreationOptions | CredentialRequestOptions,
): options is CredentialCreationOptions | CredentialRequestOptions {
return options != null && "publicKey" in options;
}
/**
@@ -217,7 +229,7 @@ import { Messenger } from "./messaging/messenger";
*/
async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
try {
if (globalContext.top.document.hasFocus()) {
if (globalContext.top?.document.hasFocus()) {
return;
}
} catch {
@@ -225,9 +237,14 @@ import { Messenger } from "./messaging/messenger";
return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait));
}
if (!globalContext.top) {
return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait));
}
const topWindow = globalContext.top;
const focusPromise = new Promise<void>((resolve) => {
focusListenerHandler = () => resolve();
globalContext.top.addEventListener("focus", focusListenerHandler);
topWindow.addEventListener("focus", focusListenerHandler);
});
const timeoutPromise = new Promise<void>((_, reject) => {
@@ -248,7 +265,7 @@ import { Messenger } from "./messaging/messenger";
}
function clearWaitForFocus() {
globalContext.top.removeEventListener("focus", focusListenerHandler);
globalContext.top?.removeEventListener("focus", focusListenerHandler);
if (waitForFocusTimeout) {
globalContext.clearTimeout(waitForFocusTimeout);
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Message, MessageTypes } from "./message";
const SENDER = "bitwarden-webauthn";
@@ -25,7 +23,9 @@ type Handler = (
* handling aborts and exceptions across separate execution contexts.
*/
export class Messenger {
private messageEventListener: (event: MessageEvent<MessageWithMetadata>) => void | null = null;
private messageEventListener:
| ((event: MessageEvent<MessageWithMetadata>) => void | Promise<void>)
| null = null;
private onDestroy = new EventTarget();
/**

View File

@@ -10,7 +10,7 @@ import {
ButtonModule,
CheckboxModule,
FormFieldModule,
IconModule,
SvgModule,
IconTileComponent,
LinkModule,
CalloutComponent,
@@ -31,7 +31,7 @@ import {
templateUrl: "phishing-warning.component.html",
imports: [
CommonModule,
IconModule,
SvgModule,
JslibModule,
LinkModule,
FormFieldModule,

View File

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

View File

@@ -18,11 +18,11 @@
type="button"
role="link"
>
<bit-icon
[icon]="rla.isActive ? button.iconActive : button.icon"
<bit-svg
[content]="rla.isActive ? button.iconActive : button.icon"
aria-hidden="true"
class="tw-leading-3"
></bit-icon>
></bit-svg>
<span class="tw-text-sm tw-truncate tw-max-w-full">
{{ button.label | i18n }}
</span>

View File

@@ -3,15 +3,15 @@ import { Component, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Icon } from "@bitwarden/assets/svg";
import { BitSvg } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { IconModule, LinkModule } from "@bitwarden/components";
import { SvgModule, LinkModule } from "@bitwarden/components";
export type NavButton = {
label: string;
page: string;
icon: Icon;
iconActive: Icon;
icon: BitSvg;
iconActive: BitSvg;
showBerry?: boolean;
};
@@ -20,7 +20,7 @@ export type NavButton = {
@Component({
selector: "popup-tab-navigation",
templateUrl: "popup-tab-navigation.component.html",
imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule],
imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule],
host: {
class: "tw-block tw-size-full tw-flex tw-flex-col",
},

View File

@@ -6,7 +6,7 @@
[pageTitle]="''"
>
<div class="tw-w-32">
<bit-icon *ngIf="showLogo" [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
<bit-svg *ngIf="showLogo" [content]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-svg>
</div>
<ng-container slot="end">

View File

@@ -5,10 +5,10 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
import { BitwardenLogo, Icon } from "@bitwarden/assets/svg";
import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
IconModule,
SvgModule,
Translation,
AnonLayoutComponent,
AnonLayoutWrapperData,
@@ -38,7 +38,7 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData {
CommonModule,
CurrentAccountComponent,
I18nPipe,
IconModule,
SvgModule,
PopOutComponent,
PopupPageComponent,
PopupHeaderComponent,
@@ -54,7 +54,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
protected pageTitle: string;
protected pageSubtitle: string;
protected pageIcon: Icon;
protected pageIcon: BitSvg;
protected showReadonlyHostname: boolean;
protected maxWidth: "md" | "3xl";
protected hasLoggedInAccount: boolean = false;

View File

@@ -14,7 +14,7 @@
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5"
>
<div class="tw-size-[95px] tw-content-center">
<bit-icon [icon]="sendCreatedIcon"></bit-icon>
<bit-svg [content]="sendCreatedIcon"></bit-svg>
</div>
<h3 tabindex="0" appAutofocus class="tw-font-medium">
{{ "createdSendSuccessfully" | i18n }}

View File

@@ -14,7 +14,7 @@ import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/defau
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components";
import { ButtonModule, I18nMockService, SvgModule, ToastService } from "@bitwarden/components";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
@@ -76,7 +76,7 @@ describe("SendCreatedComponent", () => {
RouterTestingModule,
JslibModule,
ButtonModule,
IconModule,
SvgModule,
PopOutComponent,
PopupHeaderComponent,
PopupPageComponent,

View File

@@ -13,7 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { ButtonModule, IconModule, ToastService } from "@bitwarden/components";
import { ButtonModule, SvgModule, ToastService } from "@bitwarden/components";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
@@ -34,7 +34,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page
PopupPageComponent,
RouterModule,
PopupFooterComponent,
IconModule,
SvgModule,
],
})
export class SendCreatedComponent {

View File

@@ -2,7 +2,7 @@
<vault-carousel-slide [label]="'securityPrioritized' | i18n" [disablePadding]="true">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-around">
<div class="tw-size-32 tw-content-center tw-my-4">
<bit-icon [icon]="itemTypes"></bit-icon>
<bit-svg [content]="itemTypes"></bit-svg>
</div>
<h2 bitTypography="h2" class="tw-text-center">{{ "securityPrioritized" | i18n }}</h2>
<p bitTypography="body1" class="tw-text-center">{{ "securityPrioritizedBody" | i18n }}</p>
@@ -11,7 +11,7 @@
<vault-carousel-slide [label]="'quickLogin' | i18n" [disablePadding]="true">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-around">
<div class="tw-size-32 tw-content-center tw-my-4">
<bit-icon [icon]="loginCards"></bit-icon>
<bit-svg [content]="loginCards"></bit-svg>
</div>
<h2 bitTypography="h2" class="tw-text-center">{{ "quickLogin" | i18n }}</h2>
<p bitTypography="body1" class="tw-text-center">{{ "quickLoginBody" | i18n }}</p>
@@ -20,7 +20,7 @@
<vault-carousel-slide [label]="'secureUser' | i18n" [disablePadding]="true">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-around">
<div class="tw-size-32 tw-content-center tw-my-4">
<bit-icon [icon]="noCredentials"></bit-icon>
<bit-svg [content]="noCredentials"></bit-svg>
</div>
<h2 bitTypography="h2" class="tw-text-center">{{ "secureUser" | i18n }}</h2>
<p bitTypography="body1" class="tw-text-center">{{ "secureUserBody" | i18n }}</p>
@@ -29,7 +29,7 @@
<vault-carousel-slide [label]="'secureDevices' | i18n" [disablePadding]="true">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-around">
<div class="tw-size-32 tw-content-center tw-my-4">
<bit-icon [icon]="secureDevices"></bit-icon>
<bit-svg [content]="secureDevices"></bit-svg>
</div>
<h2 bitTypography="h2" class="tw-text-center">{{ "secureDevices" | i18n }}</h2>
<p bitTypography="body1" class="tw-text-center">{{ "secureDevicesBody" | i18n }}</p>

View File

@@ -3,7 +3,7 @@ import { Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ItemTypes, LoginCards, NoCredentialsIcon, DevicesIcon } from "@bitwarden/assets/svg";
import { ButtonModule, DialogModule, IconModule, TypographyModule } from "@bitwarden/components";
import { ButtonModule, DialogModule, SvgModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultCarouselModule } from "@bitwarden/vault";
@@ -17,7 +17,7 @@ import { IntroCarouselService } from "../../../services/intro-carousel.service";
imports: [
VaultCarouselModule,
ButtonModule,
IconModule,
SvgModule,
DialogModule,
TypographyModule,
JslibModule,

View File

@@ -5,7 +5,7 @@
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<bit-svg [content]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-svg>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
{{ "savePasskeyQuestion" | i18n }}
@@ -28,7 +28,7 @@
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col">
<div *ngIf="(ciphers$ | async)?.length === 0; else hasCiphers">
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
<bit-svg [content]="Icons.NoResults" class="tw-text-main"></bit-svg>
<div class="tw-flex tw-flex-col tw-gap-2">
{{ "noMatchingLoginsForSite" | i18n }}
</div>

View File

@@ -16,7 +16,7 @@ import {
BadgeModule,
ButtonModule,
DialogModule,
IconModule,
SvgModule,
ItemModule,
SectionComponent,
TableModule,
@@ -42,7 +42,7 @@ import {
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
SvgModule,
ButtonModule,
DialogModule,
SectionComponent,

View File

@@ -5,7 +5,7 @@
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<bit-svg [content]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-svg>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
{{ "savePasskeyQuestion" | i18n }}
@@ -30,7 +30,7 @@
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-start tw-items-center tw-gap-2 tw-h-full tw-px-5"
>
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
<bit-svg [content]="Icons.NoResults" class="tw-text-main"></bit-svg>
<div class="tw-flex tw-flex-col tw-gap-2">
<b>{{ "passkeyAlreadyExists" | i18n }}</b>
{{ "applicationDoesNotSupportDuplicates" | i18n }}

View File

@@ -9,7 +9,7 @@ import {
BadgeModule,
ButtonModule,
DialogModule,
IconModule,
SvgModule,
ItemModule,
SectionComponent,
TableModule,
@@ -32,7 +32,7 @@ import {
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
SvgModule,
ButtonModule,
DialogModule,
SectionComponent,

View File

@@ -5,7 +5,7 @@
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<bit-svg [content]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-svg>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">{{ "passkeyLogin" | i18n }}</h2>
</div>

View File

@@ -24,7 +24,7 @@ import {
ButtonModule,
DialogModule,
DialogService,
IconModule,
SvgModule,
ItemModule,
SectionComponent,
TableModule,
@@ -48,7 +48,7 @@ import {
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
SvgModule,
ButtonModule,
DialogModule,
SectionComponent,

View File

@@ -11,61 +11,66 @@
>
{{ submitButtonText() }}
</button>
<button
type="button"
class="primary"
(click)="edit()"
appA11yTitle="{{ 'edit' | i18n }}"
*ngIf="!cipher.isDeleted && action === 'view'"
>
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
*ngIf="action === 'edit' || action === 'clone' || action === 'add'"
type="button"
(click)="cancel()"
>
{{ "cancel" | i18n }}
</button>
<button
type="button"
class="primary"
(click)="restore()"
appA11yTitle="{{ 'restore' | i18n }}"
*ngIf="cipher.isDeleted && cipher.permissions.restore"
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
@if (!cipher.isDeleted && action === "view") {
<button type="button" class="primary" (click)="edit()" appA11yTitle="{{ 'edit' | i18n }}">
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true" style="pointer-events: none">
</i>
</button>
}
@if (action === "edit" || action === "clone" || action === "add") {
<button type="button" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
}
@if (cipher.isDeleted && cipher.permissions.restore) {
<button
type="button"
class="primary"
(click)="restore()"
appA11yTitle="{{ 'restore' | i18n }}"
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true" style="pointer-events: none"></i>
</button>
}
@if (showCloneOption) {
<button type="button" class="primary" (click)="clone()" appA11yTitle="{{ 'clone' | i18n }}">
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true" style="pointer-events: none"></i>
</button>
}
</ng-container>
<div class="right" *ngIf="hasFooterAction">
<button
type="button"
*ngIf="showArchiveButton"
(click)="archive()"
appA11yTitle="{{ 'archiveVerb' | i18n }}"
>
<i class="bwi bwi-archive bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
<button
type="button"
*ngIf="showUnarchiveButton"
(click)="unarchive()"
appA11yTitle="{{ 'unArchive' | i18n }}"
>
<i class="bwi bwi-unarchive bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
<button
type="button"
(click)="delete()"
class="danger"
appA11yTitle="{{ (cipher.isDeleted ? 'permanentlyDelete' : 'delete') | i18n }}"
>
<i class="bwi bwi-trash bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
</div>
@if (hasFooterAction) {
<div class="right">
@if (showArchiveButton) {
<button type="button" (click)="archive()" appA11yTitle="{{ 'archiveVerb' | i18n }}">
<i
class="bwi bwi-archive bwi-lg bwi-fw"
aria-hidden="true"
style="pointer-events: none"
></i>
</button>
}
@if (showUnarchiveButton) {
<button type="button" (click)="unarchive()" appA11yTitle="{{ 'unArchive' | i18n }}">
<i
class="bwi bwi-unarchive bwi-lg bwi-fw"
aria-hidden="true"
style="pointer-events: none"
></i>
</button>
}
<button
type="button"
(click)="delete()"
class="danger"
appA11yTitle="{{ (cipher.isDeleted ? 'permanentlyDelete' : 'delete') | i18n }}"
>
<i class="bwi bwi-trash bwi-lg bwi-fw" aria-hidden="true" style="pointer-events: none"></i>
</button>
</div>
}
</div>

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { getById } from "@bitwarden/common/platform/misc";
import { BannerModule, IconModule } from "@bitwarden/components";
import { BannerModule, SvgModule } from "@bitwarden/components";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types";
@@ -47,7 +47,7 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module";
RouterModule,
JslibModule,
WebLayoutModule,
IconModule,
SvgModule,
OrgSwitcherComponent,
BannerModule,
TaxIdWarningComponent,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@
<ng-template #step1>
<div class="tw-flex tw-justify-center tw-mb-6">
<bit-icon class="tw-w-[233px]" [icon]="autoConfirmSvg"></bit-icon>
<bit-svg class="tw-w-[233px]" [content]="autoConfirmSvg"></bit-svg>
</div>
<ol>
<li>1. {{ "autoConfirmExtension1" | i18n }}</li>

View File

@@ -1,7 +1,7 @@
<div class="tw-mt-10 tw-flex tw-justify-center" *ngIf="loading">
<div>
<bit-icon class="tw-w-72 tw-block tw-mb-4" [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n">
</bit-icon>
<bit-svg class="tw-w-72 tw-block tw-mb-4" [content]="logo" [ariaLabel]="'appLogoLabel' | i18n">
</bit-svg>
<div class="tw-flex tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"

View File

@@ -8,7 +8,7 @@ import { BitwardenLogo } from "@bitwarden/assets/svg";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { OrganizationSponsorshipResponse } from "@bitwarden/common/admin-console/models/response/organization-sponsorship.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { IconModule, ToastService } from "@bitwarden/components";
import { SvgModule, ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { BaseAcceptComponent } from "../../../common/base.accept.component";
@@ -22,7 +22,7 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component";
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "accept-family-sponsorship.component.html",
imports: [CommonModule, I18nPipe, IconModule],
imports: [CommonModule, I18nPipe, SvgModule],
})
export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent {
protected logo = BitwardenLogo;

View File

@@ -29,7 +29,7 @@ import {
DialogRef,
DialogService,
FormFieldModule,
IconModule,
SvgModule,
InputModule,
LinkModule,
ToastService,
@@ -68,7 +68,7 @@ declare global {
TypographyModule,
CalloutModule,
ButtonModule,
IconModule,
SvgModule,
I18nPipe,
AsyncActionsModule,
JslibModule,

View File

@@ -21,7 +21,7 @@ import {
DialogRef,
DialogService,
FormFieldModule,
IconModule,
SvgModule,
InputModule,
ToastService,
TypographyModule,
@@ -42,7 +42,7 @@ import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-bas
InputModule,
TypographyModule,
ButtonModule,
IconModule,
SvgModule,
I18nPipe,
ReactiveFormsModule,
AsyncActionsModule,

View File

@@ -24,7 +24,7 @@ import {
DialogRef,
DialogService,
FormFieldModule,
IconModule,
SvgModule,
InputModule,
ToastService,
TypographyModule,
@@ -45,7 +45,7 @@ import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-bas
CommonModule,
DialogModule,
FormFieldModule,
IconModule,
SvgModule,
I18nPipe,
InputModule,
ReactiveFormsModule,

View File

@@ -16,7 +16,7 @@
<div *ngIf="currentStep === 'credentialCreation'" class="tw-flex tw-flex-col tw-items-center">
<div class="tw-size-24 tw-content-center tw-mb-6">
<bit-icon [icon]="Icons.TwoFactorAuthSecurityKeyIcon"></bit-icon>
<bit-svg [content]="Icons.TwoFactorAuthSecurityKeyIcon"></bit-svg>
</div>
<h3 bitTypography="h3">{{ "creatingPasskeyLoading" | i18n }}</h3>
<p bitTypography="body1">{{ "creatingPasskeyLoadingInfo" | i18n }}</p>
@@ -27,7 +27,7 @@
class="tw-flex tw-flex-col tw-items-center"
>
<div class="tw-size-24 tw-content-center tw-mb-6">
<bit-icon [icon]="Icons.TwoFactorAuthSecurityKeyFailedIcon"></bit-icon>
<bit-svg [content]="Icons.TwoFactorAuthSecurityKeyFailedIcon"></bit-svg>
</div>
<h3 bitTypography="h3">{{ "errorCreatingPasskey" | i18n }}</h3>
<p bitTypography="body1">{{ "errorCreatingPasskeyInfo" | i18n }}</p>

View File

@@ -242,7 +242,7 @@
<ng-template #organizationIsNotManagedByConsolidatedBillingMSP>
<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
<div class="tw-size-56 tw-content-center">
<bit-icon [icon]="gearIcon" aria-hidden="true"></bit-icon>
<bit-svg [content]="gearIcon" aria-hidden="true"></bit-svg>
</div>
<p class="tw-font-medium">{{ "billingManagedByProvider" | i18n: userOrg.providerName }}</p>
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>

View File

@@ -10,7 +10,7 @@ import { GearIcon } from "@bitwarden/assets/svg";
selector: "app-org-subscription-hidden",
template: `<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
<div class="tw-size-56 tw-content-center">
<bit-icon [icon]="gearIcon" aria-hidden="true"></bit-icon>
<bit-svg [content]="gearIcon" aria-hidden="true"></bit-svg>
</div>
<p class="tw-font-medium">{{ "billingManagedByProvider" | i18n: providerName }}</p>
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>

View File

@@ -2,7 +2,7 @@
<h3 bitTypography="h3">{{ "moreFromBitwarden" | i18n }}</h3>
<div class="tw-rounded-t tw-bg-background-alt3 tw-p-5">
<div class="tw-w-72">
<bit-icon [icon]="logo"></bit-icon>
<bit-svg [content]="logo"></bit-svg>
</div>
</div>
<div

View File

@@ -1,4 +1,4 @@
import { Icon } from "@bitwarden/assets/svg";
import { BitSvg } from "@bitwarden/assets/svg";
import { ReportVariant } from "./report-variant";
@@ -6,6 +6,6 @@ export type ReportEntry = {
title: string;
description: string;
route: string;
icon: Icon;
icon: BitSvg;
variant: ReportVariant;
};

View File

@@ -8,7 +8,7 @@
[ngClass]="{ 'tw-grayscale': disabled }"
>
<div class="tw-m-auto tw-size-20 tw-content-center">
<bit-icon [icon]="icon" aria-hidden="true"></bit-icon>
<bit-svg [content]="icon" aria-hidden="true"></bit-svg>
</div>
</div>
<bit-card-content [ngClass]="{ 'tw-grayscale': disabled }">

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { Component, Input } from "@angular/core";
import { Icon } from "@bitwarden/assets/svg";
import { BitSvg } from "@bitwarden/assets/svg";
import { ReportVariant } from "../models/report-variant";
@@ -25,7 +25,7 @@ export class ReportCardComponent {
@Input() route: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() icon: Icon;
@Input() icon: BitSvg;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() variant: ReportVariant;

View File

@@ -14,7 +14,7 @@ import {
BaseCardComponent,
CardContentComponent,
I18nMockService,
IconModule,
SvgModule,
} from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../../core/tests";
@@ -31,7 +31,7 @@ export default {
JslibModule,
BadgeModule,
CardContentComponent,
IconModule,
SvgModule,
RouterTestingModule,
PremiumBadgeComponent,
BaseCardComponent,

View File

@@ -12,7 +12,7 @@ import {
BadgeModule,
BaseCardComponent,
CardContentComponent,
IconModule,
SvgModule,
} from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../../core/tests";
@@ -31,7 +31,7 @@ export default {
JslibModule,
BadgeModule,
RouterTestingModule,
IconModule,
SvgModule,
PremiumBadgeComponent,
CardContentComponent,
BaseCardComponent,

View File

@@ -24,7 +24,7 @@ import {
BreadcrumbsModule,
ButtonModule,
IconButtonModule,
IconModule,
SvgModule,
InputModule,
MenuModule,
NavigationModule,
@@ -94,7 +94,7 @@ export default {
BreadcrumbsModule,
ButtonModule,
IconButtonModule,
IconModule,
SvgModule,
InputModule,
MenuModule,
TabsModule,

View File

@@ -16,7 +16,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { IconModule } from "@bitwarden/components";
import { SvgModule } from "@bitwarden/components";
import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component";
@@ -32,7 +32,7 @@ import { WebLayoutModule } from "./web-layout.module";
RouterModule,
JslibModule,
WebLayoutModule,
IconModule,
SvgModule,
BillingFreeFamiliesNavItemComponent,
],
})

View File

@@ -4,7 +4,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an
import { delay, of, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components";
import { LinkModule, SvgModule, ProgressModule } from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../core/tests";
@@ -16,7 +16,7 @@ export default {
component: OnboardingComponent,
decorators: [
moduleMetadata({
imports: [JslibModule, RouterModule, LinkModule, IconModule, ProgressModule],
imports: [JslibModule, RouterModule, LinkModule, SvgModule, ProgressModule],
declarations: [OnboardingTaskComponent],
}),
applicationConfig({

View File

@@ -18,7 +18,7 @@ import {
DialogModule,
FormFieldModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
MenuModule,
MultiSelectModule,
@@ -63,7 +63,7 @@ import {
DialogModule,
FormFieldModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
MenuModule,
MultiSelectModule,
@@ -99,7 +99,7 @@ import {
DialogModule,
FormFieldModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
MenuModule,
MultiSelectModule,

View File

@@ -1,4 +1,4 @@
@switch (viewState) {
@switch (viewState()) {
@case ("auth") {
<app-send-auth [id]="id" [key]="key" (accessGranted)="onAccessGranted($event)"></app-send-auth>
}
@@ -6,6 +6,7 @@
<app-send-view
[id]="id"
[key]="key"
[accessToken]="sendAccessToken"
[sendResponse]="sendAccessResponse"
[accessRequest]="sendAccessRequest"
(authRequired)="onAuthRequired()"

View File

@@ -1,8 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
@@ -17,44 +19,45 @@ const SendViewState = Object.freeze({
} as const);
type SendViewState = (typeof SendViewState)[keyof typeof SendViewState];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-send-access",
templateUrl: "access.component.html",
imports: [SendAuthComponent, SendViewComponent, SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccessComponent implements OnInit {
viewState: SendViewState = SendViewState.View;
readonly viewState = signal<SendViewState>(SendViewState.Auth);
id: string;
key: string;
sendAccessToken: SendAccessToken | null = null;
sendAccessResponse: SendAccessResponse | null = null;
sendAccessRequest: SendAccessRequest = new SendAccessRequest();
constructor(private route: ActivatedRoute) {}
constructor(
private route: ActivatedRoute,
private destroyRef: DestroyRef,
) {}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.params.subscribe(async (params) => {
ngOnInit() {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
this.id = params.sendId;
this.key = params.key;
if (this.id && this.key) {
this.viewState = SendViewState.View;
this.sendAccessResponse = null;
this.sendAccessRequest = new SendAccessRequest();
}
});
}
onAuthRequired() {
this.viewState = SendViewState.Auth;
this.viewState.set(SendViewState.Auth);
}
onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) {
onAccessGranted(event: {
response?: SendAccessResponse;
request?: SendAccessRequest;
accessToken?: SendAccessToken;
}) {
this.sendAccessResponse = event.response;
this.sendAccessRequest = event.request;
this.viewState = SendViewState.View;
this.sendAccessToken = event.accessToken;
this.viewState.set(SendViewState.View);
}
}

View File

@@ -0,0 +1,35 @@
@if (!enterOtp()) {
<bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label>
<input bitInput type="text" [formControl]="email" required appInputVerbatim appAutofocus />
</bit-form-field>
<div class="tw-flex">
<button
bitButton
bitFormButton
type="submit"
buttonType="primary"
[loading]="loading()"
[block]="true"
>
<span>{{ "sendCode" | i18n }} </span>
</button>
</div>
} @else {
<bit-form-field>
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input bitInput type="text" [formControl]="otp" required appInputVerbatim appAutofocus />
</bit-form-field>
<div class="tw-flex">
<button
bitButton
bitFormButton
type="submit"
buttonType="primary"
[loading]="loading()"
[block]="true"
>
<span>{{ "viewSend" | i18n }} </span>
</button>
</div>
}

View File

@@ -0,0 +1,35 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { SharedModule } from "../../../shared";
@Component({
selector: "app-send-access-email",
templateUrl: "send-access-email.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendAccessEmailComponent implements OnInit, OnDestroy {
protected readonly formGroup = input.required<FormGroup>();
protected readonly enterOtp = input.required<boolean>();
protected email: FormControl;
protected otp: FormControl;
readonly loading = input.required<boolean>();
constructor() {}
ngOnInit() {
this.email = new FormControl("", Validators.required);
this.otp = new FormControl("", Validators.required);
this.formGroup().addControl("email", this.email);
this.formGroup().addControl("otp", this.otp);
}
ngOnDestroy() {
this.formGroup().removeControl("email");
this.formGroup().removeControl("otp");
}
}

View File

@@ -1,5 +1,5 @@
<p class="tw-text-wrap tw-break-all">{{ send.file.fileName }}</p>
<p class="tw-text-wrap tw-break-all">{{ send().file.fileName }}</p>
<button bitButton type="button" buttonType="primary" [bitAction]="download" [block]="true">
<i class="bwi bwi-download" aria-hidden="true"></i>
{{ "downloadAttachments" | i18n }} ({{ send.file.sizeName }})
{{ "downloadAttachments" | i18n }} ({{ send().file.sizeName }})
</button>

View File

@@ -1,8 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input } from "@angular/core";
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -15,40 +18,39 @@ import { ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-send-access-file",
templateUrl: "send-access-file.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendAccessFileComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() send: SendAccessView;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() decKey: SymmetricCryptoKey;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() accessRequest: SendAccessRequest;
readonly send = input<SendAccessView | null>(null);
readonly decKey = input<SymmetricCryptoKey | null>(null);
readonly accessRequest = input<SendAccessRequest | null>(null);
readonly accessToken = input<SendAccessToken | null>(null);
constructor(
private i18nService: I18nService,
private toastService: ToastService,
private encryptService: EncryptService,
private fileDownloadService: FileDownloadService,
private sendApiService: SendApiService,
private configService: ConfigService,
) {}
protected download = async () => {
if (this.send == null || this.decKey == null) {
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
const accessToken = this.accessToken();
const accessRequest = this.accessRequest();
const authMissing = (sendEmailOtp && !accessToken) || (!sendEmailOtp && !accessRequest);
if (this.send() == null || this.decKey() == null || authMissing) {
return;
}
const downloadData = await this.sendApiService.getSendFileDownloadData(
this.send,
this.accessRequest,
);
const downloadData = sendEmailOtp
? await this.sendApiService.getSendFileDownloadDataV2(this.send(), accessToken)
: await this.sendApiService.getSendFileDownloadData(this.send(), accessRequest);
if (Utils.isNullOrWhitespace(downloadData.url)) {
this.toastService.showToast({
@@ -71,9 +73,9 @@ export class SendAccessFileComponent {
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey);
const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey());
this.fileDownloadService.download({
fileName: this.send.file.fileName,
fileName: this.send().file.fileName,
blobData: decBuf,
downloadMethod: "save",
});

View File

@@ -1,28 +1,19 @@
<p bitTypography="body1">{{ "sendProtectedPassword" | i18n }}</p>
<p bitTypography="body1">{{ "sendProtectedPasswordDontKnow" | i18n }}</p>
<div class="tw-mb-3" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "password" | i18n }}</bit-label>
<input
bitInput
type="password"
formControlName="password"
required
appInputVerbatim
appAutofocus
/>
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
<div class="tw-flex">
<button
bitButton
bitFormButton
type="submit"
buttonType="primary"
[loading]="loading"
[block]="true"
>
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }} </span>
</button>
</div>
<bit-form-field>
<bit-label>{{ "password" | i18n }}</bit-label>
<input bitInput type="password" [formControl]="password" required appInputVerbatim appAutofocus />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
<div class="tw-flex">
<button
bitButton
bitFormButton
type="submit"
buttonType="primary"
[loading]="loading()"
[block]="true"
>
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }} </span>
</button>
</div>

View File

@@ -1,43 +1,30 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-send-access-password",
templateUrl: "send-access-password.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendAccessPasswordComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected formGroup = this.formBuilder.group({
password: ["", [Validators.required]],
});
protected readonly formGroup = input.required<FormGroup>();
protected password: FormControl;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loading: boolean;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() setPasswordEvent = new EventEmitter<string>();
readonly loading = input.required<boolean>();
constructor(private formBuilder: FormBuilder) {}
constructor() {}
async ngOnInit() {
this.formGroup.controls.password.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((val) => {
this.setPasswordEvent.emit(val);
});
ngOnInit() {
this.password = new FormControl("", Validators.required);
this.formGroup().addControl("password", this.password);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
this.formGroup().removeControl("password");
}
}

View File

@@ -1,14 +1,38 @@
<form (ngSubmit)="onSubmit(password)">
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
@if (loading()) {
<div class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="tw-text-main tw-text-center" *ngIf="error">
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
</div>
<app-send-access-password
*ngIf="!unavailable"
(setPasswordEvent)="password = $event"
[loading]="loading"
></app-send-access-password>
}
<form [formGroup]="sendAccessForm" (ngSubmit)="onSubmit()">
@if (error()) {
<div class="tw-text-main tw-text-center">
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
</div>
}
@if (unavailable()) {
<div class="tw-text-main tw-text-center">
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
</div>
} @else {
@switch (sendAuthType()) {
@case (authType.Password) {
<app-send-access-password
[loading]="loading()"
[formGroup]="sendAccessForm"
></app-send-access-password>
}
@case (authType.Email) {
<app-send-access-email
[formGroup]="sendAccessForm"
[enterOtp]="enterOtp()"
[loading]="loading()"
></app-send-access-email>
}
}
}
</form>

View File

@@ -1,86 +1,211 @@
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import { ChangeDetectionStrategy, Component, input, OnInit, output, signal } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import {
emailAndOtpRequiredEmailSent,
emailInvalid,
emailRequired,
otpInvalid,
passwordHashB64Invalid,
passwordHashB64Required,
SendAccessDomainCredentials,
SendAccessToken,
SendHashedPasswordB64,
sendIdInvalid,
SendOtp,
SendTokenService,
} from "@bitwarden/common/auth/send-access";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { SendAccessEmailComponent } from "./send-access-email.component";
import { SendAccessPasswordComponent } from "./send-access-password.component";
@Component({
selector: "app-send-auth",
templateUrl: "send-auth.component.html",
imports: [SendAccessPasswordComponent, SharedModule],
imports: [SendAccessPasswordComponent, SendAccessEmailComponent, SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendAuthComponent {
readonly id = input.required<string>();
readonly key = input.required<string>();
export class SendAuthComponent implements OnInit {
protected readonly id = input.required<string>();
protected readonly key = input.required<string>();
accessGranted = output<{
response: SendAccessResponse;
request: SendAccessRequest;
protected accessGranted = output<{
response?: SendAccessResponse;
request?: SendAccessRequest;
accessToken?: SendAccessToken;
}>();
loading = false;
error = false;
unavailable = false;
password?: string;
authType = AuthType;
private accessRequest!: SendAccessRequest;
private expiredAuthAttempts = 0;
readonly loading = signal<boolean>(false);
readonly error = signal<boolean>(false);
readonly unavailable = signal<boolean>(false);
readonly sendAuthType = signal<AuthType>(AuthType.None);
readonly enterOtp = signal<boolean>(false);
sendAccessForm = this.formBuilder.group<{ password?: string; email?: string; otp?: string }>({});
constructor(
private cryptoFunctionService: CryptoFunctionService,
private sendApiService: SendApiService,
private toastService: ToastService,
private i18nService: I18nService,
private formBuilder: FormBuilder,
private configService: ConfigService,
private sendTokenService: SendTokenService,
) {}
async onSubmit(password: string) {
this.password = password;
this.loading = true;
this.error = false;
this.unavailable = false;
ngOnInit() {
void this.onSubmit();
}
async onSubmit() {
this.loading.set(true);
this.unavailable.set(false);
this.error.set(false);
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
if (sendEmailOtp) {
await this.attemptV2Access();
} else {
await this.attemptV1Access();
}
this.loading.set(false);
}
private async attemptV1Access() {
try {
const keyArray = Utils.fromUrlB64ToArray(this.key());
this.accessRequest = new SendAccessRequest();
const passwordHash = await this.cryptoFunctionService.pbkdf2(
this.password,
keyArray,
"sha256",
SEND_KDF_ITERATIONS,
);
this.accessRequest.password = Utils.fromBufferToB64(passwordHash);
const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest);
this.accessGranted.emit({ response: sendResponse, request: this.accessRequest });
const accessRequest = new SendAccessRequest();
if (this.sendAuthType() === AuthType.Password) {
const password = this.sendAccessForm.value.password;
if (password == null) {
return;
}
accessRequest.password = await this.getPasswordHashB64(password, this.key());
}
const sendResponse = await this.sendApiService.postSendAccess(this.id(), accessRequest);
this.accessGranted.emit({ request: accessRequest, response: sendResponse });
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 404) {
this.unavailable = true;
} else if (e.statusCode === 400) {
if (e.statusCode === 401) {
this.sendAuthType.set(AuthType.Password);
} else if (e.statusCode === 404) {
this.unavailable.set(true);
} else {
this.error.set(true);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
} else {
this.error = true;
}
} else {
this.error = true;
this.error.set(true);
}
} finally {
this.loading = false;
}
}
private async attemptV2Access(): Promise<void> {
let sendAccessCreds: SendAccessDomainCredentials | null = null;
if (this.sendAuthType() === AuthType.Email) {
const email = this.sendAccessForm.value.email;
if (email == null) {
return;
}
if (!this.enterOtp()) {
sendAccessCreds = { kind: "email", email };
} else {
const otp = this.sendAccessForm.value.otp as SendOtp;
if (otp == null) {
return;
}
sendAccessCreds = { kind: "email_otp", email, otp };
}
} else if (this.sendAuthType() === AuthType.Password) {
const password = this.sendAccessForm.value.password;
if (password == null) {
return;
}
const passwordHashB64 = await this.getPasswordHashB64(password, this.key());
sendAccessCreds = { kind: "password", passwordHashB64 };
}
const response = !sendAccessCreds
? await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(this.id()))
: await firstValueFrom(this.sendTokenService.getSendAccessToken$(this.id(), sendAccessCreds));
if (response instanceof SendAccessToken) {
this.expiredAuthAttempts = 0;
this.accessGranted.emit({ accessToken: response });
} else if (response.kind === "expired") {
if (this.expiredAuthAttempts > 2) {
return;
}
this.expiredAuthAttempts++;
await this.attemptV2Access();
} else if (response.kind === "expected_server") {
this.expiredAuthAttempts = 0;
if (emailRequired(response.error)) {
this.sendAuthType.set(AuthType.Email);
} else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) {
this.enterOtp.set(true);
} else if (otpInvalid(response.error)) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidVerificationCode"),
});
} else if (passwordHashB64Required(response.error)) {
this.sendAuthType.set(AuthType.Password);
} else if (passwordHashB64Invalid(response.error)) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidSendPassword"),
});
} else if (sendIdInvalid(response.error)) {
this.unavailable.set(true);
} else {
this.error.set(true);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: response.error.error_description ?? "",
});
}
} else {
this.expiredAuthAttempts = 0;
this.error.set(true);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: response.error,
});
}
}
private async getPasswordHashB64(password: string, key: string) {
const keyArray = Utils.fromUrlB64ToArray(key);
const passwordHash = await this.cryptoFunctionService.pbkdf2(
password,
keyArray,
"sha256",
SEND_KDF_ITERATIONS,
);
return Utils.fromBufferToB64(passwordHash) as SendHashedPasswordB64;
}
}

View File

@@ -1,41 +1,13 @@
<bit-callout *ngIf="hideEmail" type="warning" title="{{ 'warning' | i18n }}">
{{ "viewSendHiddenEmailWarning" | i18n }}
<a bitLink href="https://bitwarden.com/help/receive-send/" target="_blank" rel="noreferrer">{{
"learnMore" | i18n
}}</a
>.
</bit-callout>
@if (hideEmail()) {
<bit-callout type="warning" title="{{ 'warning' | i18n }}">
{{ "viewSendHiddenEmailWarning" | i18n }}
<a bitLink href="https://bitwarden.com/help/receive-send/" target="_blank" rel="noreferrer">{{
"learnMore" | i18n
}}</a>
</bit-callout>
}
<ng-container *ngIf="!loading; else spinner">
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
</div>
<div class="tw-text-main tw-text-center" *ngIf="error">
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
</div>
<div *ngIf="send && !error && !unavailable">
<p class="tw-text-center">
<b>{{ send.name }}</b>
</p>
<hr />
<!-- Text -->
<ng-container *ngIf="send.type === sendType.Text">
<app-send-access-text [send]="send"></app-send-access-text>
</ng-container>
<!-- File -->
<ng-container *ngIf="send.type === sendType.File">
<app-send-access-file
[send]="send"
[decKey]="decKey"
[accessRequest]="accessRequest()"
></app-send-access-file>
</ng-container>
<p *ngIf="expirationDate" class="tw-text-center tw-text-muted">
Expires: {{ expirationDate | date: "medium" }}
</p>
</div>
</ng-container>
<ng-template #spinner>
@if (loading()) {
<div class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
@@ -44,4 +16,39 @@
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
</ng-template>
} @else {
@if (unavailable()) {
<div class="tw-text-main tw-text-center">
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
</div>
}
@if (error()) {
<div class="tw-text-main tw-text-center">
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
</div>
}
@if (send()) {
<div>
<p class="tw-text-center">
<b>{{ send().name }}</b>
</p>
<hr />
@switch (send().type) {
@case (sendType.Text) {
<app-send-access-text [send]="send()"></app-send-access-text>
}
@case (sendType.File) {
<app-send-access-file
[send]="send()"
[decKey]="decKey"
[accessRequest]="accessRequest()"
[accessToken]="accessToken()"
></app-send-access-file>
}
}
@if (expirationDate()) {
<p class="tw-text-center tw-text-muted">Expires: {{ expirationDate() | date: "medium" }}</p>
}
</div>
}
}

View File

@@ -1,13 +1,17 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
input,
OnInit,
output,
signal,
} from "@angular/core";
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
@@ -34,17 +38,25 @@ import { SendAccessTextComponent } from "./send-access-text.component";
export class SendViewComponent implements OnInit {
readonly id = input.required<string>();
readonly key = input.required<string>();
readonly accessToken = input<SendAccessToken | null>(null);
readonly sendResponse = input<SendAccessResponse | null>(null);
readonly accessRequest = input<SendAccessRequest>(new SendAccessRequest());
authRequired = output<void>();
send: SendAccessView | null = null;
readonly send = signal<SendAccessView | null>(null);
readonly expirationDate = computed<Date | null>(() => this.send()?.expirationDate ?? null);
readonly creatorIdentifier = computed<string | null>(
() => this.send()?.creatorIdentifier ?? null,
);
readonly hideEmail = computed<boolean>(
() => this.send() != null && this.creatorIdentifier() == null,
);
readonly loading = signal<boolean>(false);
readonly unavailable = signal<boolean>(false);
readonly error = signal<boolean>(false);
sendType = SendType;
loading = true;
unavailable = false;
error = false;
hideEmail = false;
decKey!: SymmetricCryptoKey;
constructor(
@@ -53,50 +65,48 @@ export class SendViewComponent implements OnInit {
private toastService: ToastService,
private i18nService: I18nService,
private layoutWrapperDataService: AnonLayoutWrapperDataService,
private cdRef: ChangeDetectorRef,
private configService: ConfigService,
) {}
get expirationDate() {
if (this.send == null || this.send.expirationDate == null) {
return null;
}
return this.send.expirationDate;
}
get creatorIdentifier() {
if (this.send == null || this.send.creatorIdentifier == null) {
return null;
}
return this.send.creatorIdentifier;
}
async ngOnInit() {
await this.load();
ngOnInit() {
void this.load();
}
private async load() {
this.unavailable = false;
this.error = false;
this.hideEmail = false;
this.loading = true;
let response = this.sendResponse();
this.loading.set(true);
this.unavailable.set(false);
this.error.set(false);
try {
if (!response) {
response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest());
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
let response: SendAccessResponse;
if (sendEmailOtp) {
const accessToken = this.accessToken();
if (!accessToken) {
this.authRequired.emit();
return;
}
response = await this.sendApiService.postSendAccessV2(accessToken);
} else {
const sendResponse = this.sendResponse();
if (!sendResponse) {
this.authRequired.emit();
return;
}
response = sendResponse;
}
const keyArray = Utils.fromUrlB64ToArray(this.key());
const sendAccess = new SendAccess(response);
this.decKey = await this.keyService.makeSendKey(keyArray);
this.send = await sendAccess.decrypt(this.decKey);
const decSend = await sendAccess.decrypt(this.decKey);
this.send.set(decSend);
} catch (e) {
this.send.set(null);
if (e instanceof ErrorResponse) {
if (e.statusCode === 401) {
this.authRequired.emit();
} else if (e.statusCode === 404) {
this.unavailable = true;
this.unavailable.set(true);
} else if (e.statusCode === 400) {
this.toastService.showToast({
variant: "error",
@@ -104,28 +114,23 @@ export class SendViewComponent implements OnInit {
message: e.message,
});
} else {
this.error = true;
this.error.set(true);
}
} else {
this.error = true;
this.error.set(true);
}
} finally {
this.loading.set(false);
}
this.loading = false;
this.hideEmail =
this.creatorIdentifier == null && !this.loading && !this.unavailable && !response;
this.hideEmail = this.send != null && this.creatorIdentifier == null;
if (this.creatorIdentifier != null) {
const creatorIdentifier = this.creatorIdentifier();
if (creatorIdentifier != null) {
this.layoutWrapperDataService.setAnonLayoutWrapperData({
pageSubtitle: {
key: "sendAccessCreatorIdentifier",
placeholders: [this.creatorIdentifier],
placeholders: [creatorIdentifier],
},
});
}
this.cdRef.markForCheck();
}
}

View File

@@ -8,7 +8,7 @@
>
<div class="tw-mb-6 tw-mt-8">
<div class="tw-size-[95px] tw-content-center">
<bit-icon [icon]="activeSendIcon"></bit-icon>
<bit-svg [content]="activeSendIcon"></bit-svg>
</div>
</div>

View File

@@ -12,7 +12,7 @@ import { ActivatedRoute } from "@angular/router";
import { map, Observable, of, tap } from "rxjs";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { ButtonComponent, IconModule } from "@bitwarden/components";
import { ButtonComponent, SvgModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
@@ -24,7 +24,7 @@ import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manua
@Component({
selector: "vault-browser-extension-prompt",
templateUrl: "./browser-extension-prompt.component.html",
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent],
imports: [CommonModule, I18nPipe, ButtonComponent, SvgModule, ManuallyOpenExtensionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {

View File

@@ -1,8 +1,8 @@
<p bitTypography="body1" class="tw-mb-0">
{{ "openExtensionFromToolbarPart1" | i18n }}
<bit-icon
[icon]="BitwardenIcon"
<bit-svg
[content]="BitwardenIcon"
class="!tw-inline-block [&>svg]:tw-align-baseline [&>svg]:-tw-mb-[0.25rem]"
></bit-icon>
></bit-svg>
{{ "openExtensionFromToolbarPart2" | i18n }}
</p>

View File

@@ -1,14 +1,14 @@
import { Component, ChangeDetectionStrategy } from "@angular/core";
import { BitwardenIcon } from "@bitwarden/assets/svg";
import { IconModule } from "@bitwarden/components";
import { SvgModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "vault-manually-open-extension",
templateUrl: "./manually-open-extension.component.html",
imports: [I18nPipe, IconModule],
imports: [I18nPipe, SvgModule],
})
export class ManuallyOpenExtensionComponent {
protected BitwardenIcon = BitwardenIcon;

View File

@@ -31,7 +31,7 @@
<section *ngIf="showSuccessUI" class="tw-flex tw-flex-col tw-items-center">
<div class="tw-size-[90px]">
<bit-icon [icon]="PartyIcon"></bit-icon>
<bit-svg [content]="PartyIcon"></bit-svg>
</div>
<h1 bitTypography="h2" class="tw-mb-6 tw-mt-4 tw-text-center">
{{

View File

@@ -18,7 +18,7 @@ import {
CenterPositionStrategy,
DialogRef,
DialogService,
IconModule,
SvgModule,
LinkModule,
} from "@bitwarden/components";
@@ -52,7 +52,7 @@ type SetupExtensionState = UnionOfValues<typeof SetupExtensionState>;
JslibModule,
ButtonComponent,
LinkModule,
IconModule,
SvgModule,
RouterModule,
AddExtensionVideosComponent,
ManuallyOpenExtensionComponent,

View File

@@ -33,7 +33,7 @@ import {
EmptyTrash,
FavoritesIcon,
ItemTypes,
Icon,
BitSvg,
} from "@bitwarden/assets/svg";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -160,7 +160,7 @@ type EmptyStateType = "trash" | "favorites" | "archive";
type EmptyStateItem = {
title: string;
description: string;
icon: Icon;
icon: BitSvg;
};
type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;

View File

@@ -586,6 +586,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@@ -1365,6 +1368,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@@ -6928,17 +6937,17 @@
"personalVaultExportPolicyInEffect": {
"message": "One or more organization policies prevents you from exporting your individual vault."
},
"activateAutofill": {
"message": "Activate auto-fill"
"activateAutofillPolicy": {
"message": "Activate autofill"
},
"activateAutofillPolicyDescription": {
"message": "Activate the autofill on page load setting on the browser extension for all existing and new members."
},
"experimentalFeature": {
"message": "Compromised or untrusted websites can exploit auto-fill on page load."
"autofillOnPageLoadExploitWarning": {
"message": "Compromised or untrusted websites can exploit autofill on page load."
},
"learnMoreAboutAutofill": {
"message": "Learn more about auto-fill"
"learnMoreAboutAutofillPolicy": {
"message": "Learn more about autofill"
},
"selectType": {
"message": "Select SSO type"
@@ -12691,6 +12700,21 @@
"storageFullDescription": {
"message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage."
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
},
"whenYouRemoveStorage": {
"message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill."
},
@@ -12699,5 +12723,8 @@
},
"emailProtected": {
"message": "Email protected"
},
"invalidSendPassword": {
"message": "Invalid Send password"
}
}

View File

@@ -1,11 +1,11 @@
<bit-callout type="warning">
{{ "experimentalFeature" | i18n }}
{{ "autofillOnPageLoadExploitWarning" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/auto-fill-browser/"
target="_blank"
rel="noreferrer"
>{{ "learnMoreAboutAutofill" | i18n }}</a
>{{ "learnMoreAboutAutofillPolicy" | i18n }}</a
>
</bit-callout>

View File

@@ -11,7 +11,7 @@ import {
import { SharedModule } from "@bitwarden/web-vault/app/shared";
export class ActivateAutofillPolicy extends BasePolicyEditDefinition {
name = "activateAutofill";
name = "activateAutofillPolicy";
description = "activateAutofillPolicyDescription";
type = PolicyType.ActivateAutofill;
component = ActivateAutofillPolicyComponent;

View File

@@ -1,10 +1,10 @@
<div class="tw-mt-5 tw-flex tw-justify-center" *ngIf="loading">
<div>
<bit-icon
<bit-svg
class="tw-w-72 tw-block tw-mb-4"
[icon]="logo"
[content]="logo"
[ariaLabel]="'appLogoLabel' | i18n"
></bit-icon>
></bit-svg>
<p class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"

View File

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

View File

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

View File

@@ -7,13 +7,13 @@ import { combineLatest, map, Observable, Subject, switchMap } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BusinessUnitPortalLogo, Icon, ProviderPortalLogo } from "@bitwarden/assets/svg";
import { BusinessUnitPortalLogo, BitSvg, ProviderPortalLogo } from "@bitwarden/assets/svg";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderType } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { IconModule } from "@bitwarden/components";
import { SvgModule } from "@bitwarden/components";
import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components";
import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types";
@@ -31,7 +31,7 @@ import { ProviderWarningsService } from "../../billing/providers/warnings/servic
RouterModule,
JslibModule,
WebLayoutModule,
IconModule,
SvgModule,
TaxIdWarningComponent,
],
})
@@ -41,7 +41,7 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected provider$: Observable<Provider>;
protected logo$: Observable<Icon>;
protected logo$: Observable<BitSvg>;
protected canAccessBilling$: Observable<boolean>;

View File

@@ -1,10 +1,10 @@
<div class="tw-mt-12 tw-flex tw-justify-center" *ngIf="loading">
<div>
<bit-icon
<bit-svg
class="tw-w-72 tw-block tw-mb-4"
[icon]="logo"
[content]="logo"
[ariaLabel]="'appLogoLabel' | i18n"
></bit-icon>
></bit-svg>
<p class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"

View File

@@ -1,10 +1,10 @@
<div class="tw-mt-12 tw-flex tw-justify-center" *ngIf="loading">
<div>
<bit-icon
<bit-svg
class="tw-w-72 tw-block tw-mb-4"
[icon]="bitwardenLogo"
[content]="bitwardenLogo"
[ariaLabel]="'appLogoLabel' | i18n"
></bit-icon>
></bit-svg>
<p class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"

View File

@@ -68,11 +68,11 @@
<div
class="tw-size-full tw-flex tw-items-center tw-justify-center tw-bg-secondary-100 tw-rounded-lg"
>
<bit-icon
[icon]="icon()"
<bit-svg
[content]="icon()"
class="tw-size-16 xl:tw-size-24 tw-text-muted"
aria-hidden="true"
></bit-icon>
></bit-svg>
</div>
}
</div>
@@ -94,11 +94,11 @@
<div
class="tw-size-full tw-flex tw-items-center tw-justify-center tw-bg-secondary-100 tw-rounded-lg"
>
<bit-icon
[icon]="icon()"
<bit-svg
[content]="icon()"
class="tw-size-12 sm:tw-size-16 tw-text-muted"
aria-hidden="true"
></bit-icon>
></bit-svg>
</div>
}
</div>

View File

@@ -1,17 +1,17 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core";
import { Icon } from "@bitwarden/assets/svg";
import { ButtonModule, IconModule } from "@bitwarden/components";
import { BitSvg } from "@bitwarden/assets/svg";
import { ButtonModule, SvgModule } from "@bitwarden/components";
@Component({
selector: "empty-state-card",
templateUrl: "./empty-state-card.component.html",
imports: [CommonModule, IconModule, ButtonModule],
imports: [CommonModule, SvgModule, ButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmptyStateCardComponent implements OnInit {
readonly icon = input<Icon | null>(null);
readonly icon = input<BitSvg | null>(null);
readonly videoSrc = input<string | null>(null);
readonly title = input<string>("");
readonly description = input<string>("");

View File

@@ -2,7 +2,7 @@ import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { map, concatMap, firstValueFrom } from "rxjs";
import { Icon, DeactivatedOrg } from "@bitwarden/assets/svg";
import { BitSvg, DeactivatedOrg } from "@bitwarden/assets/svg";
import {
getOrganizationById,
OrganizationService,
@@ -23,7 +23,7 @@ export class OrgSuspendedComponent {
private route: ActivatedRoute,
) {}
protected DeactivatedOrg: Icon = DeactivatedOrg;
protected DeactivatedOrg: BitSvg = DeactivatedOrg;
protected organizationName$ = this.route.params.pipe(
concatMap(async (params) => {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));

View File

@@ -207,6 +207,7 @@ export default tseslint.config(
"error",
{ ignoreIfHas: ["bitPasswordInputToggle"] },
],
"@bitwarden/components/no-bwi-class-usage": "warn",
},
},

View File

@@ -1,6 +1,6 @@
<ng-container>
<div class="tw-size-[70px] tw-content-center" *ngIf="!!IconProviderMap[provider]">
<bit-icon [icon]="IconProviderMap[provider]"></bit-icon>
<bit-svg [content]="IconProviderMap[provider]"></bit-svg>
</div>
<!-- Other 2FA Types (Duo, Yubico, U2F as PNG) -->
<img

View File

@@ -3,7 +3,7 @@
import { Component, Input } from "@angular/core";
import {
Icon,
BitSvg,
TwoFactorAuthAuthenticatorIcon,
TwoFactorAuthEmailIcon,
TwoFactorAuthWebAuthnIcon,
@@ -24,7 +24,7 @@ export class TwoFactorIconComponent {
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() name: string;
protected readonly IconProviderMap: { [key: number | string]: Icon } = {
protected readonly IconProviderMap: { [key: number | string]: BitSvg } = {
0: TwoFactorAuthAuthenticatorIcon,
1: TwoFactorAuthEmailIcon,
7: TwoFactorAuthWebAuthnIcon,

View File

@@ -25,7 +25,7 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid
import {
AnonLayoutWrapperDataService,
ButtonModule,
IconModule,
SvgModule,
LinkModule,
TypographyModule,
} from "@bitwarden/components";
@@ -43,7 +43,7 @@ export type State = "assert" | "assertFailed";
RouterModule,
JslibModule,
ButtonModule,
IconModule,
SvgModule,
LinkModule,
TypographyModule,
],

View File

@@ -11,7 +11,7 @@ import {
DialogModule,
FormFieldModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
MenuModule,
RadioButtonModule,
@@ -73,9 +73,9 @@ import { IconComponent } from "./vault/components/icon.component";
MenuModule,
NoItemsModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
IconModule,
SvgModule,
TextDragDirective,
CopyClickDirective,
A11yTitleDirective,

View File

@@ -8,9 +8,9 @@ This lib contains assets used by the Bitwarden clients. Unused assets are tree-s
### SVGs
SVGs intended to be used with the `bit-icon` component live in `src/svgs`. These SVGs are built with the `icon-service` for security reasons. These SVGs can be viewed in our Component Library [Icon Story](https://components.bitwarden.com/?path=/story/component-library-icon--default).
SVGs intended to be used with the `bit-svg` component live in `src/svgs`. These SVGs are built with the `svg` function for security reasons. These SVGs can be viewed in our Component Library [SVG Story](https://components.bitwarden.com/?path=/story/component-library-svg--default).
When adding a new SVG, follow the instructions in our Component Library: [SVG Icon Docs](https://components.bitwarden.com/?path=/docs/component-library-icon--docs)
When adding a new SVG, follow the instructions in our Component Library: [SVG Docs](https://components.bitwarden.com/?path=/docs/component-library-svg--docs)
When importing an SVG in one of the clients:
`import { ExampleSvg } from "@bitwarden/assets/svg";`

View File

@@ -1,25 +0,0 @@
class Icon {
constructor(readonly svg: string) {}
}
// We only export the type to prohibit the creation of Icons without using
// the `svgIcon` template literal tag.
export type { Icon };
export function isIcon(icon: unknown): icon is Icon {
return icon instanceof Icon;
}
export class DynamicContentNotAllowedError extends Error {
constructor() {
super("Dynamic content in icons is not allowed due to risk of user-injected XSS.");
}
}
export function svgIcon(strings: TemplateStringsArray, ...values: unknown[]): Icon {
if (values.length > 0) {
throw new DynamicContentNotAllowedError();
}
return new Icon(strings[0]);
}

View File

@@ -1,2 +1,2 @@
export * from "./svgs";
export * from "./icon-service";
export * from "./svg";

Some files were not shown because too many files have changed in this diff Show More