diff --git a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.spec.ts
index d1cf89eb08..7b73ad8305 100644
--- a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.spec.ts
+++ b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.spec.ts
@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
+import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -17,9 +18,12 @@ describe("ExposedPasswordsReportComponent", () => {
let component: ExposedPasswordsReportComponent;
let fixture: ComponentFixture;
let auditService: MockProxy;
+ let organizationService: MockProxy;
beforeEach(() => {
auditService = mock();
+ organizationService = mock();
+ organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -35,7 +39,7 @@ describe("ExposedPasswordsReportComponent", () => {
},
{
provide: OrganizationService,
- useValue: mock(),
+ useValue: organizationService,
},
{
provide: ModalService,
diff --git a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.ts
index 631e9ef8a8..39414487d7 100644
--- a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.ts
+++ b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.ts
@@ -3,6 +3,7 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -24,8 +25,9 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
protected organizationService: OrganizationService,
modalService: ModalService,
passwordRepromptService: PasswordRepromptService,
+ i18nService: I18nService,
) {
- super(modalService, passwordRepromptService, organizationService);
+ super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -36,7 +38,9 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
const allCiphers = await this.getAllCiphers();
const exposedPasswordCiphers: CipherView[] = [];
const promises: Promise[] = [];
- allCiphers.forEach((ciph) => {
+ this.filterStatus = [0];
+
+ allCiphers.forEach((ciph: any) => {
const { type, login, isDeleted, edit, viewPassword, id } = ciph;
if (
type !== CipherType.Login ||
@@ -48,6 +52,7 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
) {
return;
}
+
const promise = this.auditService.passwordLeaked(login.password).then((exposedCount) => {
if (exposedCount > 0) {
exposedPasswordCiphers.push(ciph);
@@ -57,11 +62,8 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
promises.push(promise);
});
await Promise.all(promises);
- this.ciphers = [...exposedPasswordCiphers];
- }
- protected getAllCiphers(): Promise {
- return this.cipherService.getAllDecrypted();
+ this.filterCiphersByOrg(exposedPasswordCiphers);
}
protected canManageCipher(c: CipherView): boolean {
diff --git a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html
index d81fc2d413..ae03a3bcb8 100644
--- a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html
+++ b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html
@@ -16,9 +16,32 @@
- {{ "inactive2faFoundDesc" | i18n: (ciphers.length | number) }}
+ {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
+
+
+
+ {{ getName(status) }}
+ {{ getCount(status) }}
+
+
+
+
+
+ |
+ {{ "name" | i18n }} |
+ {{ "owner" | i18n }} |
+
+
diff --git a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.spec.ts
index 97321480fa..528f6306e0 100644
--- a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.spec.ts
+++ b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.spec.ts
@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
-import { mock } from "jest-mock-extended";
+import { MockProxy, mock } from "jest-mock-extended";
+import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -16,8 +17,11 @@ import { cipherData } from "./reports-ciphers.mock";
describe("InactiveTwoFactorReportComponent", () => {
let component: InactiveTwoFactorReportComponent;
let fixture: ComponentFixture;
+ let organizationService: MockProxy;
beforeEach(() => {
+ organizationService = mock();
+ organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -29,7 +33,7 @@ describe("InactiveTwoFactorReportComponent", () => {
},
{
provide: OrganizationService,
- useValue: mock(),
+ useValue: organizationService,
},
{
provide: ModalService,
diff --git a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.ts b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.ts
index 15b79981b6..956607c8fb 100644
--- a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.ts
+++ b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.ts
@@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -26,8 +27,9 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
modalService: ModalService,
private logService: LogService,
passwordRepromptService: PasswordRepromptService,
+ i18nService: I18nService,
) {
- super(modalService, passwordRepromptService, organizationService);
+ super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -45,6 +47,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
const allCiphers = await this.getAllCiphers();
const inactive2faCiphers: CipherView[] = [];
const docs = new Map();
+ this.filterStatus = [0];
allCiphers.forEach((ciph) => {
const { type, login, isDeleted, edit, id, viewPassword } = ciph;
@@ -58,6 +61,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
) {
return;
}
+
for (let i = 0; i < login.uris.length; i++) {
const u = login.uris[i];
if (u.uri != null && u.uri !== "") {
@@ -75,15 +79,12 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
}
}
});
- this.ciphers = [...inactive2faCiphers];
+
+ this.filterCiphersByOrg(inactive2faCiphers);
this.cipherDocs = docs;
}
}
- protected getAllCiphers(): Promise {
- return this.cipherService.getAllDecrypted();
- }
-
private async load2fa() {
if (this.services.size > 0) {
return;
diff --git a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html
index cde2e59ea8..549773ba8c 100644
--- a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html
+++ b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html
@@ -16,9 +16,34 @@
- {{ "reusedPasswordsFoundDesc" | i18n: (ciphers.length | number) }}
+ {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
+
+
+
+
+ {{ getName(status) }}
+ {{ getCount(status) }}
+
+
+
+
+
+
+ |
+ {{ "name" | i18n }} |
+ {{ "owner" | i18n }} |
+
+
diff --git a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.spec.ts
index 450e42805a..29e20c11af 100644
--- a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.spec.ts
+++ b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.spec.ts
@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
-import { mock } from "jest-mock-extended";
+import { MockProxy, mock } from "jest-mock-extended";
+import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -15,8 +16,11 @@ import { ReusedPasswordsReportComponent } from "./reused-passwords-report.compon
describe("ReusedPasswordsReportComponent", () => {
let component: ReusedPasswordsReportComponent;
let fixture: ComponentFixture;
+ let organizationService: MockProxy;
beforeEach(() => {
+ organizationService = mock();
+ organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -28,7 +32,7 @@ describe("ReusedPasswordsReportComponent", () => {
},
{
provide: OrganizationService,
- useValue: mock(),
+ useValue: organizationService,
},
{
provide: ModalService,
diff --git a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.ts
index f785186c15..cbc2ea11b5 100644
--- a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.ts
+++ b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.ts
@@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -22,8 +23,9 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
protected organizationService: OrganizationService,
modalService: ModalService,
passwordRepromptService: PasswordRepromptService,
+ i18nService: I18nService,
) {
- super(modalService, passwordRepromptService, organizationService);
+ super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -34,6 +36,8 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
const allCiphers = await this.getAllCiphers();
const ciphersWithPasswords: CipherView[] = [];
this.passwordUseMap = new Map();
+ this.filterStatus = [0];
+
allCiphers.forEach((ciph) => {
const { type, login, isDeleted, edit, viewPassword } = ciph;
if (
@@ -46,6 +50,7 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
) {
return;
}
+
ciphersWithPasswords.push(ciph);
if (this.passwordUseMap.has(login.password)) {
this.passwordUseMap.set(login.password, this.passwordUseMap.get(login.password) + 1);
@@ -57,11 +62,8 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
(c) =>
this.passwordUseMap.has(c.login.password) && this.passwordUseMap.get(c.login.password) > 1,
);
- this.ciphers = reusedPasswordCiphers;
- }
- protected getAllCiphers(): Promise {
- return this.cipherService.getAllDecrypted();
+ this.filterCiphersByOrg(reusedPasswordCiphers);
}
protected canManageCipher(c: CipherView): boolean {
diff --git a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html
index 616bdbba0b..ced0ff9731 100644
--- a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html
+++ b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html
@@ -16,9 +16,33 @@
- {{ "unsecuredWebsitesFoundDesc" | i18n: (ciphers.length | number) }}
+ {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
+
+
+
+
+ {{ getName(status) }}
+ {{ getCount(status) }}
+
+
+
+
+
+ |
+ {{ "name" | i18n }} |
+ {{ "owner" | i18n }} |
+
+
diff --git a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.spec.ts
index 5cdf640c55..3b7c6d350f 100644
--- a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.spec.ts
+++ b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.spec.ts
@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
-import { mock } from "jest-mock-extended";
+import { MockProxy, mock } from "jest-mock-extended";
+import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -15,8 +16,11 @@ import { UnsecuredWebsitesReportComponent } from "./unsecured-websites-report.co
describe("UnsecuredWebsitesReportComponent", () => {
let component: UnsecuredWebsitesReportComponent;
let fixture: ComponentFixture;
+ let organizationService: MockProxy;
beforeEach(() => {
+ organizationService = mock();
+ organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -28,7 +32,7 @@ describe("UnsecuredWebsitesReportComponent", () => {
},
{
provide: OrganizationService,
- useValue: mock(),
+ useValue: organizationService,
},
{
provide: ModalService,
diff --git a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.ts b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.ts
index 2de70e928b..769eb058cd 100644
--- a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.ts
+++ b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.ts
@@ -2,9 +2,9 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
-import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
import { CipherReportComponent } from "./cipher-report.component";
@@ -21,8 +21,9 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl
protected organizationService: OrganizationService,
modalService: ModalService,
passwordRepromptService: PasswordRepromptService,
+ i18nService: I18nService,
) {
- super(modalService, passwordRepromptService, organizationService);
+ super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -31,18 +32,15 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl
async setCiphers() {
const allCiphers = await this.getAllCiphers();
+ this.filterStatus = [0];
const unsecuredCiphers = allCiphers.filter((c) => {
if (c.type !== CipherType.Login || !c.login.hasUris || c.isDeleted) {
return false;
}
- return c.login.uris.some((u) => u.uri != null && u.uri.indexOf("http://") === 0);
- });
- this.ciphers = unsecuredCiphers.filter(
- (c) => (!this.organization && c.edit) || (this.organization && !c.edit),
- );
- }
- protected getAllCiphers(): Promise {
- return this.cipherService.getAllDecrypted();
+ return c.login.uris.some((u: any) => u.uri != null && u.uri.indexOf("http://") === 0);
+ });
+
+ this.filterCiphersByOrg(unsecuredCiphers);
}
}
diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html
index b4c77b2fa1..a943c8c29e 100644
--- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html
+++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html
@@ -16,9 +16,32 @@
- {{ "weakPasswordsFoundDesc" | i18n: (ciphers.length | number) }}
+ {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
+
+
+
+ {{ getName(status) }}
+ {{ getCount(status) }}
+
+
+
+
+
+ |
+ {{ "name" | i18n }} |
+ {{ "owner" | i18n }} |
+
+
diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.spec.ts
index f1446c4209..dbc367b108 100644
--- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.spec.ts
+++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.spec.ts
@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
+import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -17,9 +18,12 @@ describe("WeakPasswordsReportComponent", () => {
let component: WeakPasswordsReportComponent;
let fixture: ComponentFixture;
let passwordStrengthService: MockProxy;
+ let organizationService: MockProxy;
beforeEach(() => {
passwordStrengthService = mock();
+ organizationService = mock();
+ organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -35,7 +39,7 @@ describe("WeakPasswordsReportComponent", () => {
},
{
provide: OrganizationService,
- useValue: mock(),
+ useValue: organizationService,
},
{
provide: ModalService,
diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts
index a7ed119e19..4d179b58f3 100644
--- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts
+++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts
@@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -29,8 +30,9 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
protected organizationService: OrganizationService,
modalService: ModalService,
passwordRepromptService: PasswordRepromptService,
+ i18nService: I18nService,
) {
- super(modalService, passwordRepromptService, organizationService);
+ super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -38,7 +40,10 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
}
async setCiphers() {
- const allCiphers = await this.getAllCiphers();
+ const allCiphers: any = await this.getAllCiphers();
+ this.passwordStrengthCache = new Map();
+ this.weakPasswordCiphers = [];
+ this.filterStatus = [0];
this.findWeakPasswords(allCiphers);
}
@@ -55,6 +60,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
) {
return;
}
+
const hasUserName = this.isUserNameNotEmpty(ciph);
const cacheKey = this.getCacheKey(ciph);
if (!this.passwordStrengthCache.has(cacheKey)) {
@@ -87,6 +93,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
this.passwordStrengthCache.set(cacheKey, result.score);
}
const score = this.passwordStrengthCache.get(cacheKey);
+
if (score != null && score <= 2) {
this.passwordStrengthMap.set(id, this.scoreKey(score));
this.weakPasswordCiphers.push(ciph);
@@ -98,11 +105,8 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
this.passwordStrengthCache.get(this.getCacheKey(b))
);
});
- this.ciphers = [...this.weakPasswordCiphers];
- }
- protected getAllCiphers(): Promise {
- return this.cipherService.getAllDecrypted();
+ this.filterCiphersByOrg(this.weakPasswordCiphers);
}
protected canManageCipher(c: CipherView): boolean {
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 2ba3c2e903..de948caa2f 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -1809,12 +1809,16 @@
"unsecuredWebsitesFound": {
"message": "Unsecured websites found"
},
- "unsecuredWebsitesFoundDesc": {
- "message": "We found $COUNT$ items in your vault with unsecured URIs. You should change their URI scheme to https:// if the website allows it.",
+ "unsecuredWebsitesFoundReportDesc": {
+ "message": "We found $COUNT$ items in your $VAULT$ with unsecured URIs. You should change their URI scheme to https:// if the website allows it.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
+ },
+ "vault": {
+ "content": "$2",
+ "example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -1830,12 +1834,16 @@
"inactive2faFound": {
"message": "Logins without two-step login found"
},
- "inactive2faFoundDesc": {
- "message": "We found $COUNT$ website(s) in your vault that may not be configured with two-step login (according to 2fa.directory). To further protect these accounts, you should set up two-step login.",
+ "inactive2faFoundReportDesc": {
+ "message": "We found $COUNT$ website(s) in your $VAULT$ that may not be configured with two-step login (according to 2fa.directory). To further protect these accounts, you should set up two-step login.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
+ },
+ "vault": {
+ "content": "$2",
+ "example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -1854,12 +1862,16 @@
"exposedPasswordsFound": {
"message": "Exposed passwords found"
},
- "exposedPasswordsFoundDesc": {
- "message": "We found $COUNT$ items in your vault that have passwords that were exposed in known data breaches. You should change them to use a new password.",
+ "exposedPasswordsFoundReportDesc": {
+ "message": "We found $COUNT$ items in your $VAULT$ that have passwords that were exposed in known data breaches. You should change them to use a new password.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
+ },
+ "vault": {
+ "content": "$2",
+ "example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -1887,12 +1899,16 @@
"weakPasswordsFound": {
"message": "Weak passwords found"
},
- "weakPasswordsFoundDesc": {
- "message": "We found $COUNT$ items in your vault with passwords that are not strong. You should update them to use stronger passwords.",
+ "weakPasswordsFoundReportDesc": {
+ "message": "We found $COUNT$ items in your $VAULT$ with passwords that are not strong. You should update them to use stronger passwords.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
+ },
+ "vault": {
+ "content": "$2",
+ "example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -1908,12 +1924,16 @@
"reusedPasswordsFound": {
"message": "Reused passwords found"
},
- "reusedPasswordsFoundDesc": {
- "message": "We found $COUNT$ passwords that are being reused in your vault. You should change them to a unique value.",
+ "reusedPasswordsFoundReportDesc": {
+ "message": "We found $COUNT$ passwords that are being reused in your $VAULT$. You should change them to a unique value.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
+ },
+ "vault": {
+ "content": "$2",
+ "example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -8055,5 +8075,8 @@
},
"collectionItemSelect": {
"message": "Select collection item"
+ },
+ "manageBillingFromProviderPortalMessage": {
+ "message": "Manage billing from the Provider Portal"
}
}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts
index 554e7fa37d..0547c4fcba 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts
@@ -17,7 +17,7 @@ import { ServiceAccountEventLogApiService } from "./service-account-event-log-ap
templateUrl: "./service-accounts-events.component.html",
})
export class ServiceAccountEventsComponent extends BaseEventsComponent implements OnDestroy {
- exportFileName = "service-account-events";
+ exportFileName = "machine-account-events";
private destroy$ = new Subject();
private serviceAccountId: string;
diff --git a/libs/angular/src/platform/services/logging-error-handler.ts b/libs/angular/src/platform/services/logging-error-handler.ts
index 522412dd28..5644272d35 100644
--- a/libs/angular/src/platform/services/logging-error-handler.ts
+++ b/libs/angular/src/platform/services/logging-error-handler.ts
@@ -14,7 +14,7 @@ export class LoggingErrorHandler extends ErrorHandler {
override handleError(error: any): void {
try {
const logService = this.injector.get(LogService, null);
- logService.error(error);
+ logService.error("Unhandled error in angular", error);
} catch {
super.handleError(error);
}
diff --git a/libs/common/spec/intercept-console.ts b/libs/common/spec/intercept-console.ts
index 01c4063e7a..565d475cae 100644
--- a/libs/common/spec/intercept-console.ts
+++ b/libs/common/spec/intercept-console.ts
@@ -2,22 +2,17 @@ const originalConsole = console;
declare let console: any;
-export function interceptConsole(interceptions: any): object {
+export function interceptConsole(): {
+ log: jest.Mock;
+ warn: jest.Mock;
+ error: jest.Mock;
+} {
console = {
- log: function () {
- // eslint-disable-next-line
- interceptions.log = arguments;
- },
- warn: function () {
- // eslint-disable-next-line
- interceptions.warn = arguments;
- },
- error: function () {
- // eslint-disable-next-line
- interceptions.error = arguments;
- },
+ log: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
};
- return interceptions;
+ return console;
}
export function restoreConsole() {
diff --git a/libs/common/src/billing/models/view/self-hosted-organization-subscription.view.ts b/libs/common/src/billing/models/view/self-hosted-organization-subscription.view.ts
index c1f5640207..7b49688294 100644
--- a/libs/common/src/billing/models/view/self-hosted-organization-subscription.view.ts
+++ b/libs/common/src/billing/models/view/self-hosted-organization-subscription.view.ts
@@ -58,4 +58,16 @@ export class SelfHostedOrganizationSubscriptionView implements View {
get isExpiredAndOutsideGracePeriod() {
return this.hasExpiration && this.expirationWithGracePeriod < new Date();
}
+
+ /**
+ * In the case of a trial, where there is no grace period, the expirationWithGracePeriod and expirationWithoutGracePeriod will
+ * be exactly the same. This can be used to hide the grace period note.
+ */
+ get isInTrial() {
+ return (
+ this.expirationWithGracePeriod &&
+ this.expirationWithoutGracePeriod &&
+ this.expirationWithGracePeriod.getTime() === this.expirationWithoutGracePeriod.getTime()
+ );
+ }
}
diff --git a/libs/common/src/platform/abstractions/log.service.ts b/libs/common/src/platform/abstractions/log.service.ts
index dffa3ca8d3..d77a4f6990 100644
--- a/libs/common/src/platform/abstractions/log.service.ts
+++ b/libs/common/src/platform/abstractions/log.service.ts
@@ -1,9 +1,9 @@
import { LogLevelType } from "../enums/log-level-type.enum";
export abstract class LogService {
- abstract debug(message: string): void;
- abstract info(message: string): void;
- abstract warning(message: string): void;
- abstract error(message: string): void;
- abstract write(level: LogLevelType, message: string): void;
+ abstract debug(message?: any, ...optionalParams: any[]): void;
+ abstract info(message?: any, ...optionalParams: any[]): void;
+ abstract warning(message?: any, ...optionalParams: any[]): void;
+ abstract error(message?: any, ...optionalParams: any[]): void;
+ abstract write(level: LogLevelType, message?: any, ...optionalParams: any[]): void;
}
diff --git a/libs/common/src/platform/services/console-log.service.spec.ts b/libs/common/src/platform/services/console-log.service.spec.ts
index 129969bbc4..508ca4eb32 100644
--- a/libs/common/src/platform/services/console-log.service.spec.ts
+++ b/libs/common/src/platform/services/console-log.service.spec.ts
@@ -2,13 +2,18 @@ import { interceptConsole, restoreConsole } from "../../../spec";
import { ConsoleLogService } from "./console-log.service";
-let caughtMessage: any;
-
describe("ConsoleLogService", () => {
+ const error = new Error("this is an error");
+ const obj = { a: 1, b: 2 };
+ let consoleSpy: {
+ log: jest.Mock;
+ warn: jest.Mock;
+ error: jest.Mock;
+ };
let logService: ConsoleLogService;
+
beforeEach(() => {
- caughtMessage = {};
- interceptConsole(caughtMessage);
+ consoleSpy = interceptConsole();
logService = new ConsoleLogService(true);
});
@@ -18,41 +23,41 @@ describe("ConsoleLogService", () => {
it("filters messages below the set threshold", () => {
logService = new ConsoleLogService(true, () => true);
- logService.debug("debug");
- logService.info("info");
- logService.warning("warning");
- logService.error("error");
+ logService.debug("debug", error, obj);
+ logService.info("info", error, obj);
+ logService.warning("warning", error, obj);
+ logService.error("error", error, obj);
- expect(caughtMessage).toEqual({});
+ expect(consoleSpy.log).not.toHaveBeenCalled();
+ expect(consoleSpy.warn).not.toHaveBeenCalled();
+ expect(consoleSpy.error).not.toHaveBeenCalled();
});
+
it("only writes debug messages in dev mode", () => {
logService = new ConsoleLogService(false);
logService.debug("debug message");
- expect(caughtMessage.log).toBeUndefined();
+ expect(consoleSpy.log).not.toHaveBeenCalled();
});
it("writes debug/info messages to console.log", () => {
- logService.debug("this is a debug message");
- expect(caughtMessage).toMatchObject({
- log: { "0": "this is a debug message" },
- });
+ logService.debug("this is a debug message", error, obj);
+ logService.info("this is an info message", error, obj);
- logService.info("this is an info message");
- expect(caughtMessage).toMatchObject({
- log: { "0": "this is an info message" },
- });
+ expect(consoleSpy.log).toHaveBeenCalledTimes(2);
+ expect(consoleSpy.log).toHaveBeenCalledWith("this is a debug message", error, obj);
+ expect(consoleSpy.log).toHaveBeenCalledWith("this is an info message", error, obj);
});
+
it("writes warning messages to console.warn", () => {
- logService.warning("this is a warning message");
- expect(caughtMessage).toMatchObject({
- warn: { 0: "this is a warning message" },
- });
+ logService.warning("this is a warning message", error, obj);
+
+ expect(consoleSpy.warn).toHaveBeenCalledWith("this is a warning message", error, obj);
});
+
it("writes error messages to console.error", () => {
- logService.error("this is an error message");
- expect(caughtMessage).toMatchObject({
- error: { 0: "this is an error message" },
- });
+ logService.error("this is an error message", error, obj);
+
+ expect(consoleSpy.error).toHaveBeenCalledWith("this is an error message", error, obj);
});
});
diff --git a/libs/common/src/platform/services/console-log.service.ts b/libs/common/src/platform/services/console-log.service.ts
index 3eb3ad1881..a1480a0c26 100644
--- a/libs/common/src/platform/services/console-log.service.ts
+++ b/libs/common/src/platform/services/console-log.service.ts
@@ -9,26 +9,26 @@ export class ConsoleLogService implements LogServiceAbstraction {
protected filter: (level: LogLevelType) => boolean = null,
) {}
- debug(message: string) {
+ debug(message?: any, ...optionalParams: any[]) {
if (!this.isDev) {
return;
}
- this.write(LogLevelType.Debug, message);
+ this.write(LogLevelType.Debug, message, ...optionalParams);
}
- info(message: string) {
- this.write(LogLevelType.Info, message);
+ info(message?: any, ...optionalParams: any[]) {
+ this.write(LogLevelType.Info, message, ...optionalParams);
}
- warning(message: string) {
- this.write(LogLevelType.Warning, message);
+ warning(message?: any, ...optionalParams: any[]) {
+ this.write(LogLevelType.Warning, message, ...optionalParams);
}
- error(message: string) {
- this.write(LogLevelType.Error, message);
+ error(message?: any, ...optionalParams: any[]) {
+ this.write(LogLevelType.Error, message, ...optionalParams);
}
- write(level: LogLevelType, message: string) {
+ write(level: LogLevelType, message?: any, ...optionalParams: any[]) {
if (this.filter != null && this.filter(level)) {
return;
}
@@ -36,19 +36,19 @@ export class ConsoleLogService implements LogServiceAbstraction {
switch (level) {
case LogLevelType.Debug:
// eslint-disable-next-line
- console.log(message);
+ console.log(message, ...optionalParams);
break;
case LogLevelType.Info:
// eslint-disable-next-line
- console.log(message);
+ console.log(message, ...optionalParams);
break;
case LogLevelType.Warning:
// eslint-disable-next-line
- console.warn(message);
+ console.warn(message, ...optionalParams);
break;
case LogLevelType.Error:
// eslint-disable-next-line
- console.error(message);
+ console.error(message, ...optionalParams);
break;
default:
break;
diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/common/src/state-migrations/migration-helper.spec.ts
index 162fac2fab..21c5c72a18 100644
--- a/libs/common/src/state-migrations/migration-helper.spec.ts
+++ b/libs/common/src/state-migrations/migration-helper.spec.ts
@@ -235,6 +235,11 @@ export function mockMigrationHelper(
helper.setToUser(userId, keyDefinition, value),
);
mockHelper.getAccounts.mockImplementation(() => helper.getAccounts());
+ mockHelper.getKnownUserIds.mockImplementation(() => helper.getKnownUserIds());
+ mockHelper.removeFromGlobal.mockImplementation((keyDefinition) =>
+ helper.removeFromGlobal(keyDefinition),
+ );
+ mockHelper.remove.mockImplementation((key) => helper.remove(key));
mockHelper.type = helper.type;
diff --git a/libs/common/src/state-migrations/migration-helper.ts b/libs/common/src/state-migrations/migration-helper.ts
index 5d1de8dd49..b377df8ef9 100644
--- a/libs/common/src/state-migrations/migration-helper.ts
+++ b/libs/common/src/state-migrations/migration-helper.ts
@@ -175,8 +175,8 @@ export class MigrationHelper {
* Helper method to read known users ids.
*/
async getKnownUserIds(): Promise {
- if (this.currentVersion < 61) {
- return knownAccountUserIdsBuilderPre61(this.storageService);
+ if (this.currentVersion < 60) {
+ return knownAccountUserIdsBuilderPre60(this.storageService);
} else {
return knownAccountUserIdsBuilder(this.storageService);
}
@@ -245,7 +245,7 @@ function globalKeyBuilderPre9(): string {
throw Error("No key builder should be used for versions prior to 9.");
}
-async function knownAccountUserIdsBuilderPre61(
+async function knownAccountUserIdsBuilderPre60(
storageService: AbstractStorageService,
): Promise {
return (await storageService.get("authenticatedAccounts")) ?? [];
diff --git a/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts b/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts
index 28dedb3c39..01be4adb6a 100644
--- a/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts
+++ b/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts
@@ -51,17 +51,13 @@ const rollbackJson = () => {
},
global_account_accounts: {
user1: {
- profile: {
- email: "user1",
- name: "User 1",
- emailVerified: true,
- },
+ email: "user1",
+ name: "User 1",
+ emailVerified: true,
},
user2: {
- profile: {
- email: "",
- emailVerified: false,
- },
+ email: "",
+ emailVerified: false,
},
},
global_account_activeAccountId: "user1",
diff --git a/libs/common/src/state-migrations/migrations/60-known-accounts.ts b/libs/common/src/state-migrations/migrations/60-known-accounts.ts
index 75117da5b4..3b02a5acc4 100644
--- a/libs/common/src/state-migrations/migrations/60-known-accounts.ts
+++ b/libs/common/src/state-migrations/migrations/60-known-accounts.ts
@@ -38,8 +38,8 @@ export class KnownAccountsMigrator extends Migrator<59, 60> {
}
async rollback(helper: MigrationHelper): Promise {
// authenticated account are removed, but the accounts record also contains logged out accounts. Best we can do is to add them all back
- const accounts = (await helper.getFromGlobal>(ACCOUNT_ACCOUNTS)) ?? {};
- await helper.set("authenticatedAccounts", Object.keys(accounts));
+ const userIds = (await helper.getKnownUserIds()) ?? [];
+ await helper.set("authenticatedAccounts", userIds);
await helper.removeFromGlobal(ACCOUNT_ACCOUNTS);
// Active Account Id
diff --git a/libs/components/src/a11y/a11y-cell.directive.ts b/libs/components/src/a11y/a11y-cell.directive.ts
new file mode 100644
index 0000000000..fdd75c076f
--- /dev/null
+++ b/libs/components/src/a11y/a11y-cell.directive.ts
@@ -0,0 +1,33 @@
+import { ContentChild, Directive, ElementRef, HostBinding } from "@angular/core";
+
+import { FocusableElement } from "../shared/focusable-element";
+
+@Directive({
+ selector: "bitA11yCell",
+ standalone: true,
+ providers: [{ provide: FocusableElement, useExisting: A11yCellDirective }],
+})
+export class A11yCellDirective implements FocusableElement {
+ @HostBinding("attr.role")
+ role: "gridcell" | null;
+
+ @ContentChild(FocusableElement)
+ private focusableChild: FocusableElement;
+
+ getFocusTarget() {
+ let focusTarget: HTMLElement;
+ if (this.focusableChild) {
+ focusTarget = this.focusableChild.getFocusTarget();
+ } else {
+ focusTarget = this.elementRef.nativeElement.querySelector("button, a");
+ }
+
+ if (!focusTarget) {
+ return this.elementRef.nativeElement;
+ }
+
+ return focusTarget;
+ }
+
+ constructor(private elementRef: ElementRef) {}
+}
diff --git a/libs/components/src/a11y/a11y-grid.directive.ts b/libs/components/src/a11y/a11y-grid.directive.ts
new file mode 100644
index 0000000000..c632376f4f
--- /dev/null
+++ b/libs/components/src/a11y/a11y-grid.directive.ts
@@ -0,0 +1,145 @@
+import {
+ AfterViewInit,
+ ContentChildren,
+ Directive,
+ HostBinding,
+ HostListener,
+ Input,
+ QueryList,
+} from "@angular/core";
+
+import type { A11yCellDirective } from "./a11y-cell.directive";
+import { A11yRowDirective } from "./a11y-row.directive";
+
+@Directive({
+ selector: "bitA11yGrid",
+ standalone: true,
+})
+export class A11yGridDirective implements AfterViewInit {
+ @HostBinding("attr.role")
+ role = "grid";
+
+ @ContentChildren(A11yRowDirective)
+ rows: QueryList;
+
+ /** The number of pages to navigate on `PageUp` and `PageDown` */
+ @Input() pageSize = 5;
+
+ private grid: A11yCellDirective[][];
+
+ /** The row that currently has focus */
+ private activeRow = 0;
+
+ /** The cell that currently has focus */
+ private activeCol = 0;
+
+ @HostListener("keydown", ["$event"])
+ onKeyDown(event: KeyboardEvent) {
+ switch (event.code) {
+ case "ArrowUp":
+ this.updateCellFocusByDelta(-1, 0);
+ break;
+ case "ArrowRight":
+ this.updateCellFocusByDelta(0, 1);
+ break;
+ case "ArrowDown":
+ this.updateCellFocusByDelta(1, 0);
+ break;
+ case "ArrowLeft":
+ this.updateCellFocusByDelta(0, -1);
+ break;
+ case "Home":
+ this.updateCellFocusByDelta(-this.activeRow, -this.activeCol);
+ break;
+ case "End":
+ this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length);
+ break;
+ case "PageUp":
+ this.updateCellFocusByDelta(-this.pageSize, 0);
+ break;
+ case "PageDown":
+ this.updateCellFocusByDelta(this.pageSize, 0);
+ break;
+ default:
+ return;
+ }
+
+ /** Prevent default scrolling behavior */
+ event.preventDefault();
+ }
+
+ ngAfterViewInit(): void {
+ this.initializeGrid();
+ }
+
+ private initializeGrid(): void {
+ try {
+ this.grid = this.rows.map((listItem) => {
+ listItem.role = "row";
+ return [...listItem.cells];
+ });
+ this.grid.flat().forEach((cell) => {
+ cell.role = "gridcell";
+ cell.getFocusTarget().tabIndex = -1;
+ });
+
+ this.getActiveCellContent().tabIndex = 0;
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Unable to initialize grid");
+ }
+ }
+
+ /** Get the focusable content of the active cell */
+ private getActiveCellContent(): HTMLElement {
+ return this.grid[this.activeRow][this.activeCol].getFocusTarget();
+ }
+
+ /** Move focus via a delta against the currently active gridcell */
+ private updateCellFocusByDelta(rowDelta: number, colDelta: number) {
+ const prevActive = this.getActiveCellContent();
+
+ this.activeCol += colDelta;
+ this.activeRow += rowDelta;
+
+ // Row upper bound
+ if (this.activeRow >= this.grid.length) {
+ this.activeRow = this.grid.length - 1;
+ }
+
+ // Row lower bound
+ if (this.activeRow < 0) {
+ this.activeRow = 0;
+ }
+
+ // Column upper bound
+ if (this.activeCol >= this.grid[this.activeRow].length) {
+ if (this.activeRow < this.grid.length - 1) {
+ // Wrap to next row on right arrow
+ this.activeCol = 0;
+ this.activeRow += 1;
+ } else {
+ this.activeCol = this.grid[this.activeRow].length - 1;
+ }
+ }
+
+ // Column lower bound
+ if (this.activeCol < 0) {
+ if (this.activeRow > 0) {
+ // Wrap to prev row on left arrow
+ this.activeRow -= 1;
+ this.activeCol = this.grid[this.activeRow].length - 1;
+ } else {
+ this.activeCol = 0;
+ }
+ }
+
+ const nextActive = this.getActiveCellContent();
+ nextActive.tabIndex = 0;
+ nextActive.focus();
+
+ if (nextActive !== prevActive) {
+ prevActive.tabIndex = -1;
+ }
+ }
+}
diff --git a/libs/components/src/a11y/a11y-row.directive.ts b/libs/components/src/a11y/a11y-row.directive.ts
new file mode 100644
index 0000000000..e062eb2b5a
--- /dev/null
+++ b/libs/components/src/a11y/a11y-row.directive.ts
@@ -0,0 +1,31 @@
+import {
+ AfterViewInit,
+ ContentChildren,
+ Directive,
+ HostBinding,
+ QueryList,
+ ViewChildren,
+} from "@angular/core";
+
+import { A11yCellDirective } from "./a11y-cell.directive";
+
+@Directive({
+ selector: "bitA11yRow",
+ standalone: true,
+})
+export class A11yRowDirective implements AfterViewInit {
+ @HostBinding("attr.role")
+ role: "row" | null;
+
+ cells: A11yCellDirective[];
+
+ @ViewChildren(A11yCellDirective)
+ private viewCells: QueryList;
+
+ @ContentChildren(A11yCellDirective)
+ private contentCells: QueryList;
+
+ ngAfterViewInit(): void {
+ this.cells = [...this.viewCells, ...this.contentCells];
+ }
+}
diff --git a/libs/components/src/badge/badge.directive.ts b/libs/components/src/badge/badge.directive.ts
index b81b9f80e2..acce4a18aa 100644
--- a/libs/components/src/badge/badge.directive.ts
+++ b/libs/components/src/badge/badge.directive.ts
@@ -1,5 +1,7 @@
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
+import { FocusableElement } from "../shared/focusable-element";
+
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
const styles: Record = {
@@ -22,8 +24,9 @@ const hoverStyles: Record = {
@Directive({
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
+ providers: [{ provide: FocusableElement, useExisting: BadgeDirective }],
})
-export class BadgeDirective {
+export class BadgeDirective implements FocusableElement {
@HostBinding("class") get classList() {
return [
"tw-inline-block",
@@ -62,6 +65,10 @@ export class BadgeDirective {
*/
@Input() truncate = true;
+ getFocusTarget() {
+ return this.el.nativeElement;
+ }
+
private hasHoverEffects = false;
constructor(private el: ElementRef) {
diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts
index 53e8032795..54f6dfda96 100644
--- a/libs/components/src/icon-button/icon-button.component.ts
+++ b/libs/components/src/icon-button/icon-button.component.ts
@@ -1,6 +1,7 @@
-import { Component, HostBinding, Input } from "@angular/core";
+import { Component, ElementRef, HostBinding, Input } from "@angular/core";
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
+import { FocusableElement } from "../shared/focusable-element";
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
@@ -123,9 +124,12 @@ const sizes: Record = {
@Component({
selector: "button[bitIconButton]:not(button[bitButton])",
templateUrl: "icon-button.component.html",
- providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
+ providers: [
+ { provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent },
+ { provide: FocusableElement, useExisting: BitIconButtonComponent },
+ ],
})
-export class BitIconButtonComponent implements ButtonLikeAbstraction {
+export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
@Input("bitIconButton") icon: string;
@Input() buttonType: IconButtonType;
@@ -162,4 +166,10 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction {
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
this.buttonType = value;
}
+
+ getFocusTarget() {
+ return this.elementRef.nativeElement;
+ }
+
+ constructor(private elementRef: ElementRef) {}
}
diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts
index 36185911a6..1e4a3a86ff 100644
--- a/libs/components/src/index.ts
+++ b/libs/components/src/index.ts
@@ -16,6 +16,7 @@ export * from "./form-field";
export * from "./icon-button";
export * from "./icon";
export * from "./input";
+export * from "./item";
export * from "./layout";
export * from "./link";
export * from "./menu";
diff --git a/libs/components/src/input/autofocus.directive.ts b/libs/components/src/input/autofocus.directive.ts
index f8161ee6e0..625e7fbc92 100644
--- a/libs/components/src/input/autofocus.directive.ts
+++ b/libs/components/src/input/autofocus.directive.ts
@@ -3,12 +3,7 @@ import { take } from "rxjs/operators";
import { Utils } from "@bitwarden/common/platform/misc/utils";
-/**
- * Interface for implementing focusable components. Used by the AutofocusDirective.
- */
-export abstract class FocusableElement {
- focus: () => void;
-}
+import { FocusableElement } from "../shared/focusable-element";
/**
* Directive to focus an element.
@@ -46,7 +41,7 @@ export class AutofocusDirective {
private focus() {
if (this.focusableElement) {
- this.focusableElement.focus();
+ this.focusableElement.getFocusTarget().focus();
} else {
this.el.nativeElement.focus();
}
diff --git a/libs/components/src/item/index.ts b/libs/components/src/item/index.ts
new file mode 100644
index 0000000000..56896cdc3c
--- /dev/null
+++ b/libs/components/src/item/index.ts
@@ -0,0 +1 @@
+export * from "./item.module";
diff --git a/libs/components/src/item/item-action.component.ts b/libs/components/src/item/item-action.component.ts
new file mode 100644
index 0000000000..8cabf5c5c2
--- /dev/null
+++ b/libs/components/src/item/item-action.component.ts
@@ -0,0 +1,12 @@
+import { Component } from "@angular/core";
+
+import { A11yCellDirective } from "../a11y/a11y-cell.directive";
+
+@Component({
+ selector: "bit-item-action",
+ standalone: true,
+ imports: [],
+ template: ``,
+ providers: [{ provide: A11yCellDirective, useExisting: ItemActionComponent }],
+})
+export class ItemActionComponent extends A11yCellDirective {}
diff --git a/libs/components/src/item/item-content.component.html b/libs/components/src/item/item-content.component.html
new file mode 100644
index 0000000000..d034a4a001
--- /dev/null
+++ b/libs/components/src/item/item-content.component.html
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/libs/components/src/item/item-content.component.ts b/libs/components/src/item/item-content.component.ts
new file mode 100644
index 0000000000..58a1198512
--- /dev/null
+++ b/libs/components/src/item/item-content.component.ts
@@ -0,0 +1,15 @@
+import { CommonModule } from "@angular/common";
+import { ChangeDetectionStrategy, Component } from "@angular/core";
+
+@Component({
+ selector: "bit-item-content, [bit-item-content]",
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: `item-content.component.html`,
+ host: {
+ class:
+ "fvw-target tw-outline-none tw-text-main hover:tw-text-main hover:tw-no-underline tw-text-base tw-py-2 tw-px-4 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between",
+ },
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ItemContentComponent {}
diff --git a/libs/components/src/item/item-group.component.ts b/libs/components/src/item/item-group.component.ts
new file mode 100644
index 0000000000..2a9a8275cc
--- /dev/null
+++ b/libs/components/src/item/item-group.component.ts
@@ -0,0 +1,13 @@
+import { ChangeDetectionStrategy, Component } from "@angular/core";
+
+@Component({
+ selector: "bit-item-group",
+ standalone: true,
+ imports: [],
+ template: ``,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ class: "tw-block",
+ },
+})
+export class ItemGroupComponent {}
diff --git a/libs/components/src/item/item.component.html b/libs/components/src/item/item.component.html
new file mode 100644
index 0000000000..0c91c6848e
--- /dev/null
+++ b/libs/components/src/item/item.component.html
@@ -0,0 +1,21 @@
+
+
diff --git a/libs/components/src/item/item.component.ts b/libs/components/src/item/item.component.ts
new file mode 100644
index 0000000000..4b7b57fa9f
--- /dev/null
+++ b/libs/components/src/item/item.component.ts
@@ -0,0 +1,29 @@
+import { CommonModule } from "@angular/common";
+import { ChangeDetectionStrategy, Component, HostListener, signal } from "@angular/core";
+
+import { A11yRowDirective } from "../a11y/a11y-row.directive";
+
+import { ItemActionComponent } from "./item-action.component";
+
+@Component({
+ selector: "bit-item",
+ standalone: true,
+ imports: [CommonModule, ItemActionComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ templateUrl: "item.component.html",
+ providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }],
+})
+export class ItemComponent extends A11yRowDirective {
+ /**
+ * We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
+ */
+ protected focusVisibleWithin = signal(false);
+ @HostListener("focusin", ["$event.target"])
+ onFocusIn(target: HTMLElement) {
+ this.focusVisibleWithin.set(target.matches(".fvw-target:focus-visible"));
+ }
+ @HostListener("focusout")
+ onFocusOut() {
+ this.focusVisibleWithin.set(false);
+ }
+}
diff --git a/libs/components/src/item/item.mdx b/libs/components/src/item/item.mdx
new file mode 100644
index 0000000000..8506de72bb
--- /dev/null
+++ b/libs/components/src/item/item.mdx
@@ -0,0 +1,141 @@
+import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs";
+
+import * as stories from "./item.stories";
+
+
+
+```ts
+import { ItemModule } from "@bitwarden/components";
+```
+
+# Item
+
+`` is a horizontal card that contains one or more interactive actions.
+
+It is a generic container that can be used for either standalone content, an alternative to tables,
+or to list nav links.
+
+
+
+## Primary Content
+
+The primary content of an item is supplied by `bit-item-content`.
+
+### Content Types
+
+The content can be a button, anchor, or static container.
+
+```html
+
+ Hi, I am a link.
+
+
+
+
+
+
+
+ I'm just static :(
+
+```
+
+
+
+### Content Slots
+
+`bit-item-content` contains the following slots to help position the content:
+
+| Slot | Description |
+| ------------------ | --------------------------------------------------- |
+| default | primary text or arbitrary content; fan favorite |
+| `slot="secondary"` | supporting text; under the default slot |
+| `slot="start"` | commonly an icon or avatar; before the default slot |
+| `slot="end"` | commonly an icon; after the default slot |
+
+- Note: There is also an `end` slot within `bit-item` itself. Place
+ [interactive secondary actions](#secondary-actions) there, and place non-interactive content (such
+ as icons) in `bit-item-content`
+
+```html
+
+
+
+```
+
+
+
+## Secondary Actions
+
+Secondary interactive actions can be placed in the item through the `"end"` slot, outside of
+`bit-item-content`.
+
+Each action must be wrapped by ``.
+
+Actions are commonly icon buttons or badge buttons.
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Item Groups
+
+Groups of items can be associated by wrapping them in the ``.
+
+
+
+
+
+### A11y
+
+Keyboard nav is currently disabled due to a bug when used within a virtual scroll viewport.
+
+Item groups utilize arrow-based keyboard navigation
+([further reading here](https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/#kbd_label)).
+
+Use `aria-label` or `aria-labelledby` to give groups an accessible name.
+
+```html
+
+ ...
+ ...
+ ...
+
+```
+
+### Virtual Scrolling
+
+
diff --git a/libs/components/src/item/item.module.ts b/libs/components/src/item/item.module.ts
new file mode 100644
index 0000000000..226fed11d8
--- /dev/null
+++ b/libs/components/src/item/item.module.ts
@@ -0,0 +1,12 @@
+import { NgModule } from "@angular/core";
+
+import { ItemActionComponent } from "./item-action.component";
+import { ItemContentComponent } from "./item-content.component";
+import { ItemGroupComponent } from "./item-group.component";
+import { ItemComponent } from "./item.component";
+
+@NgModule({
+ imports: [ItemComponent, ItemContentComponent, ItemActionComponent, ItemGroupComponent],
+ exports: [ItemComponent, ItemContentComponent, ItemActionComponent, ItemGroupComponent],
+})
+export class ItemModule {}
diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts
new file mode 100644
index 0000000000..b9d8d6cc2e
--- /dev/null
+++ b/libs/components/src/item/item.stories.ts
@@ -0,0 +1,326 @@
+import { ScrollingModule } from "@angular/cdk/scrolling";
+import { CommonModule } from "@angular/common";
+import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
+
+import { A11yGridDirective } from "../a11y/a11y-grid.directive";
+import { AvatarModule } from "../avatar";
+import { BadgeModule } from "../badge";
+import { IconButtonModule } from "../icon-button";
+import { TypographyModule } from "../typography";
+
+import { ItemActionComponent } from "./item-action.component";
+import { ItemContentComponent } from "./item-content.component";
+import { ItemGroupComponent } from "./item-group.component";
+import { ItemComponent } from "./item.component";
+
+export default {
+ title: "Component Library/Item",
+ component: ItemComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [
+ CommonModule,
+ ItemGroupComponent,
+ AvatarModule,
+ IconButtonModule,
+ BadgeModule,
+ TypographyModule,
+ ItemActionComponent,
+ ItemContentComponent,
+ A11yGridDirective,
+ ScrollingModule,
+ ],
+ }),
+ componentWrapperDecorator((story) => `${story} `),
+ ],
+} as Meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const ContentSlots: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+};
+
+export const ContentTypes: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+ Hi, I am a link.
+
+
+
+
+
+
+
+ I'm just static :(
+
+
+ `,
+ }),
+};
+
+export const TextOverflow: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+ TODO: Fix truncation
+
+
+ Helloooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo
+
+
+ `,
+ }),
+};
+
+export const MultipleActionList: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const SingleActionList: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+
+ Foobar
+
+
+
+
+
+ Foobar
+
+
+
+
+
+ Foobar
+
+
+
+
+
+ Foobar
+
+
+
+
+
+ Foobar
+
+
+
+
+
+ Foobar
+
+
+
+
+ `,
+ }),
+};
+
+export const VirtualScrolling: Story = {
+ render: (_args) => ({
+ props: {
+ data: Array.from(Array(100000).keys()),
+ },
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
diff --git a/libs/components/src/search/search.component.ts b/libs/components/src/search/search.component.ts
index a0f3eb363f..27170d5d7b 100644
--- a/libs/components/src/search/search.component.ts
+++ b/libs/components/src/search/search.component.ts
@@ -1,7 +1,7 @@
import { Component, ElementRef, Input, ViewChild } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
-import { FocusableElement } from "../input/autofocus.directive";
+import { FocusableElement } from "../shared/focusable-element";
let nextId = 0;
@@ -32,8 +32,8 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
@Input() disabled: boolean;
@Input() placeholder: string;
- focus() {
- this.input.nativeElement.focus();
+ getFocusTarget() {
+ return this.input.nativeElement;
}
onChange(searchText: string) {
diff --git a/libs/components/src/shared/focusable-element.ts b/libs/components/src/shared/focusable-element.ts
new file mode 100644
index 0000000000..1ea422aa6f
--- /dev/null
+++ b/libs/components/src/shared/focusable-element.ts
@@ -0,0 +1,8 @@
+/**
+ * Interface for implementing focusable components.
+ *
+ * Used by the `AutofocusDirective` and `A11yGridDirective`.
+ */
+export abstract class FocusableElement {
+ getFocusTarget: () => HTMLElement;
+}
diff --git a/libs/components/src/styles.scss b/libs/components/src/styles.scss
index ae97838e09..7ddcb1b64b 100644
--- a/libs/components/src/styles.scss
+++ b/libs/components/src/styles.scss
@@ -49,6 +49,6 @@ $card-icons-base: "../../src/billing/images/cards/";
@import "multi-select/scss/bw.theme.scss";
// Workaround for https://bitwarden.atlassian.net/browse/CL-110
-#storybook-docs pre.prismjs {
+.sbdocs-preview pre.prismjs {
color: white;
}
| | | | |