1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-28 02:23:25 +00:00

Merge remote-tracking branch 'origin' into auth/pm-26578/http-redirect-cloud

This commit is contained in:
Patrick Pimentel
2026-01-08 12:25:23 -05:00
111 changed files with 2928 additions and 1145 deletions

View File

@@ -4811,6 +4811,24 @@
"adminConsole": {
"message": "Admin Console"
},
"admin" :{
"message": "Admin"
},
"automaticUserConfirmation": {
"message": "Automatic user confirmation"
},
"automaticUserConfirmationHint": {
"message": "Automatically confirm pending users while this device is unlocked"
},
"autoConfirmOnboardingCallout":{
"message": "Save time with automatic user confirmation"
},
"autoConfirmWarning": {
"message": "This could impact your organizations data security. "
},
"autoConfirmWarningLink": {
"message": "Learn about the risks"
},
"accountSecurity": {
"message": "Account security"
},

View File

@@ -8,6 +8,7 @@ import { firstValueFrom, of, BehaviorSubject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { NudgesService } from "@bitwarden/angular/vault";
import { LockService } from "@bitwarden/auth/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -124,6 +125,12 @@ describe("AccountSecurityComponent", () => {
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: ValidationService, useValue: validationService },
{ provide: LockService, useValue: lockService },
{
provide: AutomaticUserConfirmationService,
useValue: mock<AutomaticUserConfirmationService>(),
},
{ provide: ConfigService, useValue: configService },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
],
})

View File

@@ -506,7 +506,6 @@ export default class MainBackground {
// DIRT
private phishingDataService: PhishingDataService;
private phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction;
private phishingDetectionCleanup: (() => void) | null = null;
constructor() {
const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) =>
@@ -1516,12 +1515,7 @@ export default class MainBackground {
this.stateProvider,
);
// Call cleanup from previous initialization if it exists (service worker restart scenario)
if (this.phishingDetectionCleanup) {
this.phishingDetectionCleanup();
}
this.phishingDetectionCleanup = PhishingDetectionService.initialize(
PhishingDetectionService.initialize(
this.logService,
this.phishingDataService,
this.phishingDetectionSettingsService,
@@ -1680,32 +1674,6 @@ export default class MainBackground {
}
}
/**
* Triggers a phishing cache update in the background.
* Called on extension install/update to pre-populate the cache
* so it's ready when a premium user logs in.
*
* Creates a temporary subscription to ensure the update executes even if
* there are no other subscribers (install/update scenario). The subscription
* is automatically cleaned up after the update completes or errors.
*/
triggerPhishingCacheUpdate(): void {
// Create a temporary subscription to ensure the update executes
// since update$ uses shareReplay with refCount: true, which requires at least one subscriber
const tempSub = this.phishingDataService.update$.subscribe({
next: () => {
this.logService.debug("[MainBackground] Phishing cache pre-population completed");
tempSub.unsubscribe();
},
error: (err: unknown) => {
this.logService.error("[MainBackground] Phishing cache pre-population failed", err);
tempSub.unsubscribe();
},
});
// Trigger the update after subscription is created
this.phishingDataService.triggerUpdateIfNeeded();
}
/**
* Switch accounts to indicated userId -- null is no active user
*/

View File

@@ -433,15 +433,6 @@ export default class RuntimeBackground {
void this.autofillService.loadAutofillScriptsOnInstall();
if (this.onInstalledReason != null) {
// Pre-populate phishing cache on install/update so it's ready when premium user logs in
// This runs in background and doesn't block the user
if (this.onInstalledReason === "install" || this.onInstalledReason === "update") {
this.logService.debug(
`[RuntimeBackground] Extension ${this.onInstalledReason}: triggering phishing cache pre-population`,
);
this.main.triggerPhishingCacheUpdate();
}
if (
this.onInstalledReason === "install" &&
!(await firstValueFrom(this.browserInitialInstallService.extensionInstalled$))

View File

@@ -18,7 +18,8 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
[PhishingResourceType.Domains]: [
{
name: "Phishing.Database Domains",
remoteUrl: "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
remoteUrl:
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt",
checksumUrl:
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5",
todayUrl:
@@ -45,7 +46,8 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
[PhishingResourceType.Links]: [
{
name: "Phishing.Database Links",
remoteUrl: "https://phish.co.za/latest/phishing-links-ACTIVE.txt",
remoteUrl:
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-links-ACTIVE.txt",
checksumUrl:
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-links-ACTIVE.txt.md5",
todayUrl:

View File

@@ -113,7 +113,7 @@ describe("PhishingDataService", () => {
expect(result!.applicationVersion).toBe("2.0.0");
});
it("returns null if checksum matches (no update needed)", async () => {
it("only updates timestamp if checksum matches", async () => {
const prev: PhishingData = {
webAddresses: ["a.com"],
timestamp: Date.now() - 60000,
@@ -122,8 +122,9 @@ describe("PhishingDataService", () => {
};
fetchChecksumSpy.mockResolvedValue("abc");
const result = await service.getNextWebAddresses(prev);
// When checksum matches, return null to signal "skip state update"
expect(result).toBeNull();
expect(result!.webAddresses).toEqual(prev.webAddresses);
expect(result!.checksum).toBe("abc");
expect(result!.timestamp).not.toBe(prev.timestamp);
});
it("patches daily domains if cache is fresh", async () => {

View File

@@ -1,17 +1,13 @@
import {
catchError,
distinctUntilChanged,
EMPTY,
filter,
finalize,
first,
firstValueFrom,
from,
of,
map,
retry,
shareReplay,
share,
startWith,
Subject,
Subscription,
switchMap,
tap,
timer,
@@ -22,12 +18,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ScheduledTaskNames, TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
import { LogService } from "@bitwarden/logging";
import {
GlobalState,
GlobalStateProvider,
KeyDefinition,
PHISHING_DETECTION_DISK,
} from "@bitwarden/state";
import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bitwarden/state";
import { getPhishingResources, PhishingResourceType } from "../phishing-resources";
@@ -47,31 +38,70 @@ export const PHISHING_DOMAINS_KEY = new KeyDefinition<PhishingData>(
PHISHING_DETECTION_DISK,
"phishingDomains",
{
deserializer: (value: PhishingData) => {
return value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" };
},
deserializer: (value: PhishingData) =>
value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" },
},
);
/** Coordinates fetching, caching, and patching of known phishing web addresses */
export class PhishingDataService {
// Static tracking to prevent interval accumulation across service instances (reload scenario)
private static _intervalSubscription: Subscription | null = null;
private _testWebAddresses = this.getTestWebAddresses();
private _cachedPhishingDataStateInstance: GlobalState<PhishingData> | null = null;
private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY);
private _webAddresses$ = this._cachedState.state$.pipe(
map(
(state) =>
new Set(
(state?.webAddresses?.filter((line) => line.trim().length > 0) ?? []).concat(
this._testWebAddresses,
"phishing.testcategory.com", // Included for QA to test in prod
),
),
),
);
/**
* Lazy getter for cached phishing data state. Only accesses storage when phishing detection is actually used.
* This prevents blocking service worker initialization on extension reload for non-premium users.
*/
private get _cachedPhishingDataState() {
if (this._cachedPhishingDataStateInstance === null) {
this.logService.debug("[PhishingDataService] Lazy-loading state from storage (first access)");
this._cachedPhishingDataStateInstance = this.globalStateProvider.get(PHISHING_DOMAINS_KEY);
}
return this._cachedPhishingDataStateInstance;
}
// How often are new web addresses added to the remote?
readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours
private _triggerUpdate$ = new Subject<void>();
update$ = this._triggerUpdate$.pipe(
startWith(undefined), // Always emit once
tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)),
switchMap(() =>
this._cachedState.state$.pipe(
first(), // Only take the first value to avoid an infinite loop when updating the cache below
switchMap(async (cachedState) => {
const next = await this.getNextWebAddresses(cachedState);
if (next) {
await this._cachedState.update(() => next);
this.logService.info(`[PhishingDataService] cache updated`);
}
}),
retry({
count: 3,
delay: (err, count) => {
this.logService.error(
`[PhishingDataService] Unable to update web addresses. Attempt ${count}.`,
err,
);
return timer(5 * 60 * 1000); // 5 minutes
},
resetOnSuccess: true,
}),
catchError(
(
err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */,
) => {
this.logService.error(
"[PhishingDataService] Retries unsuccessful. Unable to update web addresses.",
err,
);
return EMPTY;
},
),
),
),
share(),
);
constructor(
private apiService: ApiService,
@@ -84,182 +114,12 @@ export class PhishingDataService {
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => {
this._triggerUpdate$.next();
});
// Clean up previous interval if it exists (prevents accumulation on service recreation)
if (PhishingDataService._intervalSubscription) {
PhishingDataService._intervalSubscription.unsubscribe();
PhishingDataService._intervalSubscription = null;
}
// Store interval subscription statically to prevent accumulation on reload
PhishingDataService._intervalSubscription = this.taskSchedulerService.setInterval(
this.taskSchedulerService.setInterval(
ScheduledTaskNames.phishingDomainUpdate,
this.UPDATE_INTERVAL_DURATION,
);
}
// In-memory cache to avoid expensive Set rebuilds and state rewrites
private _cachedWebAddressesSet: Set<string> | null = null;
private _cachedSetChecksum: string = "";
private _lastCheckTime: number = 0; // Track check time in memory, not state
// Lazy observable: only subscribes to state$ when actually needed (first URL check)
// This prevents blocking service worker initialization on extension reload
// Using a getter with caching to defer access to _cachedPhishingDataState until actually subscribed
private _webAddresses$Instance: ReturnType<typeof this.createWebAddresses$> | null = null;
private get _webAddresses$() {
if (this._webAddresses$Instance === null) {
this._webAddresses$Instance = this.createWebAddresses$();
}
return this._webAddresses$Instance;
}
private createWebAddresses$() {
return this._cachedPhishingDataState.state$.pipe(
// Only rebuild Set when checksum changes (actual data change)
distinctUntilChanged((prev, curr) => prev?.checksum === curr?.checksum),
switchMap((state) => {
// Return cached Set if checksum matches
if (this._cachedWebAddressesSet && state?.checksum === this._cachedSetChecksum) {
this.logService.debug(
`[PhishingDataService] Using cached Set (${this._cachedWebAddressesSet.size} entries, checksum: ${state?.checksum.substring(0, 8)}...)`,
);
return of(this._cachedWebAddressesSet);
}
// Build Set in chunks to avoid blocking UI
this.logService.debug(
`[PhishingDataService] Building Set from ${state?.webAddresses?.length ?? 0} entries`,
);
return from(this.buildSetInChunks(state?.webAddresses ?? [], state?.checksum ?? ""));
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
// How often are new web addresses added to the remote?
readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours
// Minimum time between updates when triggered by account switch (5 minutes)
private readonly MIN_UPDATE_INTERVAL = 5 * 60 * 1000;
private _triggerUpdate$ = new Subject<void>();
private _updateInProgress = false;
/**
* Observable that handles phishing data updates.
*
* Updates are triggered explicitly via triggerUpdateIfNeeded() or the 24-hour scheduler.
* The observable includes safeguards to prevent redundant updates:
* - Skips if an update is already in progress
* - Skips if cache was updated within MIN_UPDATE_INTERVAL (5 min)
*
* Lazy getter with caching: Only accesses _cachedPhishingDataState when actually subscribed to prevent storage read on reload.
*/
private _update$Instance: ReturnType<typeof this.createUpdate$> | null = null;
get update$() {
if (this._update$Instance === null) {
this._update$Instance = this.createUpdate$();
}
return this._update$Instance;
}
private createUpdate$() {
return this._triggerUpdate$.pipe(
// Don't use startWith - initial update is handled by triggerUpdateIfNeeded()
filter(() => {
if (this._updateInProgress) {
this.logService.debug("[PhishingDataService] Update already in progress, skipping");
return false;
}
return true;
}),
tap(() => {
this._updateInProgress = true;
}),
switchMap(async () => {
// Get current state directly without subscribing to state$ observable
// This avoids creating a subscription that stays active
const cachedState = await firstValueFrom(
this._cachedPhishingDataState.state$.pipe(first()),
);
// Early exit if we checked recently (using in-memory tracking)
const timeSinceLastCheck = Date.now() - this._lastCheckTime;
if (timeSinceLastCheck < this.MIN_UPDATE_INTERVAL) {
this.logService.debug(
`[PhishingDataService] Checked ${Math.round(timeSinceLastCheck / 1000)}s ago, skipping`,
);
return;
}
// Update last check time in memory (not state - avoids expensive write)
this._lastCheckTime = Date.now();
try {
const result = await this.getNextWebAddresses(cachedState);
// result is null when checksum matched - skip state update entirely
if (result === null) {
this.logService.debug("[PhishingDataService] Checksum matched, skipping state update");
return;
}
if (result) {
// Yield to event loop before state update
await new Promise((resolve) => setTimeout(resolve, 0));
await this._cachedPhishingDataState.update(() => result);
this.logService.info(
`[PhishingDataService] State updated with ${result.webAddresses?.length ?? 0} entries`,
);
}
} catch (err) {
this.logService.error("[PhishingDataService] Unable to update web addresses.", err);
// Retry logic removed - let the 24-hour scheduler handle retries
throw err;
}
}),
retry({
count: 3,
delay: (err, count) => {
this.logService.error(
`[PhishingDataService] Unable to update web addresses. Attempt ${count}.`,
err,
);
return timer(5 * 60 * 1000); // 5 minutes
},
resetOnSuccess: true,
}),
catchError(
(
err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */,
) => {
this.logService.error(
"[PhishingDataService] Retries unsuccessful. Unable to update web addresses.",
err,
);
return EMPTY;
},
),
// Use finalize() to ensure _updateInProgress is reset on success, error, OR completion
// Per ADR: "Use finalize() operator to ensure cleanup code always runs"
finalize(() => {
this._updateInProgress = false;
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
/**
* Triggers an update if the cache is stale or empty.
* Should be called when phishing detection is enabled for an account or on install/update.
*
* The lazy loading of _cachedPhishingDataState ensures that storage is only accessed
* when the update$ observable chain actually executes (i.e., when there are subscribers).
* If there are no subscribers, the chain doesn't execute and no storage access occurs.
*/
triggerUpdateIfNeeded(): void {
this._triggerUpdate$.next();
}
/**
* Checks if the given URL is a known phishing web address
*
@@ -267,16 +127,13 @@ export class PhishingDataService {
* @returns True if the URL is a known phishing web address, false otherwise
*/
async isPhishingWebAddress(url: URL): Promise<boolean> {
// Lazy load: Only now do we subscribe to _webAddresses$ and trigger storage read + Set build
// This ensures we don't block service worker initialization on extension reload
this.logService.debug(`[PhishingDataService] Checking URL: ${url.href}`);
// Use domain (hostname) matching for domain resources, and link matching for links resources
const entries = await firstValueFrom(this._webAddresses$);
const resource = getPhishingResources(this.resourceType);
if (resource && resource.match) {
for (const entry of entries) {
if (resource.match(url, entry)) {
this.logService.info(`[PhishingDataService] Match: ${url.href} matched entry: ${entry}`);
return true;
}
}
@@ -287,72 +144,44 @@ export class PhishingDataService {
return entries.has(url.hostname);
}
/**
* Determines if the phishing data needs to be updated and fetches new data if necessary.
*
* The CHECKSUM is an MD5 hash of the phishing list file, hosted at:
* For full url see: clients/apps/browser/src/dirt/phishing-detection/phishing-resources.ts
* - Links: https://raw.githubusercontent.com/Phishing-Database/checksums/.../phishing-links-ACTIVE.txt.md5
* - Domains: https://raw.githubusercontent.com/Phishing-Database/checksums/.../phishing-domains-ACTIVE.txt.md5
*
* PURPOSE: The checksum allows us to quickly check if the list has changed without
* downloading the entire file (~63MB uncompressed). If checksums match, data is identical.
*
* FLOW:
* 1. Fetch remote checksum (~62 bytes) - fast
* 2. Compare to local cached checksum
* 3. If match: return null (skip expensive state update)
* 4. If different: fetch new data and update state
*
* @returns PhishingData if data changed, null if checksum matched (no update needed)
*/
async getNextWebAddresses(prev: PhishingData | null): Promise<PhishingData | null> {
prev = prev ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" };
const timestamp = Date.now();
const prevAge = timestamp - prev.timestamp;
this.logService.debug(
`[PhishingDataService] Cache: ${prev.webAddresses?.length ?? 0} entries, age ${Math.round(prevAge / 1000 / 60)}min`,
);
this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`);
const applicationVersion = await this.platformUtilsService.getApplicationVersion();
// STEP 1: Fetch the remote checksum (tiny file, ~32 bytes)
// If checksum matches, return existing data with new timestamp & version
const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType);
// STEP 2: Compare checksums
if (remoteChecksum && prev.checksum === remoteChecksum) {
this.logService.debug("[PhishingDataService] Checksum match, no update needed");
return null; // Signal to skip state update - no UI blocking!
}
// STEP 3: Checksum different - data needs to be updated
this.logService.info("[PhishingDataService] Checksum mismatch, fetching new data");
// Approach 1: Fetch only today's new entries (if cache is less than 24h old)
const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION;
if (
isOneDayOldMax &&
applicationVersion === prev.applicationVersion &&
(prev.webAddresses?.length ?? 0) > 0
) {
const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl;
const dailyWebAddresses = await this.fetchPhishingWebAddresses(webAddressesTodayUrl);
this.logService.info(
`[PhishingDataService] Daily update: +${dailyWebAddresses.length} entries`,
`[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`,
);
return { ...prev, timestamp, applicationVersion };
}
// Checksum is different, data needs to be updated.
// Approach 1: Fetch only new web addresses and append
const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION;
if (isOneDayOldMax && applicationVersion === prev.applicationVersion) {
const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl;
const dailyWebAddresses: string[] =
await this.fetchPhishingWebAddresses(webAddressesTodayUrl);
this.logService.info(
`[PhishingDataService] ${dailyWebAddresses.length} new phishing web addresses added`,
);
return {
webAddresses: (prev.webAddresses ?? []).concat(dailyWebAddresses),
webAddresses: prev.webAddresses.concat(dailyWebAddresses),
checksum: remoteChecksum,
timestamp,
applicationVersion,
};
}
// Approach 2: Fetch entire list (cache is stale or empty)
// Approach 2: Fetch all web addresses
const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl;
const remoteWebAddresses = await this.fetchPhishingWebAddresses(remoteUrl);
this.logService.info(`[PhishingDataService] Full update: ${remoteWebAddresses.length} entries`);
return {
webAddresses: remoteWebAddresses,
timestamp,
@@ -361,136 +190,23 @@ export class PhishingDataService {
};
}
/**
* Fetches the MD5 checksum of the phishing list from GitHub.
* The checksum file is tiny (~32 bytes) and fast to fetch.
* Used to detect if the phishing list has changed without downloading the full list.
*/
private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) {
const checksumUrl = getPhishingResources(type)!.checksumUrl;
this.logService.debug(`[PhishingDataService] Fetching checksum from: ${checksumUrl}`);
const response = await this.apiService.nativeFetch(new Request(checksumUrl));
if (!response.ok) {
throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`);
}
const checksum = await response.text();
return checksum.trim(); // MD5 checksums are 32 hex characters
return response.text();
}
/**
* Fetches phishing web addresses from the given URL.
* Uses streaming to avoid loading the entire file into memory at once,
* which can cause Firefox to freeze due to memory pressure.
*/
private async fetchPhishingWebAddresses(url: string): Promise<string[]> {
private async fetchPhishingWebAddresses(url: string) {
const response = await this.apiService.nativeFetch(new Request(url));
if (!response.ok) {
throw new Error(`[PhishingDataService] Failed to fetch web addresses: ${response.status}`);
}
// Stream the response to avoid loading entire file into memory at once
// This prevents Firefox from freezing on large phishing lists (~63MB uncompressed)
const reader = response.body?.getReader();
if (!reader) {
// Fallback for environments without streaming support
this.logService.warning(
"[PhishingDataService] Streaming not available, falling back to full load",
);
const text = await response.text();
return text
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
}
const decoder = new TextDecoder();
const addresses: string[] = [];
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// Process complete lines from buffer
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || ""; // Keep incomplete last line in buffer
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.length > 0) {
addresses.push(trimmed);
}
}
// Yield after processing each network chunk to keep service worker responsive
// This allows popup messages to be handled between chunks
await new Promise((resolve) => setTimeout(resolve, 0));
}
// Process any remaining buffer content
const remaining = buffer.trim();
if (remaining.length > 0) {
addresses.push(remaining);
}
} finally {
// Ensure reader is released even if an error occurs
reader.releaseLock();
}
this.logService.debug(`[PhishingDataService] Streamed ${addresses.length} addresses`);
return addresses;
}
/**
* Builds a Set from an array of web addresses in chunks to avoid blocking the UI.
* Yields to the event loop every CHUNK_SIZE entries, keeping the UI responsive
* even when processing 700K+ entries.
*
* @param addresses Array of web addresses to add to the Set
* @param checksum The checksum to associate with this cached Set
* @returns Promise that resolves to the built Set
*/
private async buildSetInChunks(addresses: string[], checksum: string): Promise<Set<string>> {
const CHUNK_SIZE = 50000; // Process 50K entries per chunk (fast, fewer iterations)
const startTime = Date.now();
const set = new Set<string>();
this.logService.debug(`[PhishingDataService] Building Set (${addresses.length} entries)`);
for (let i = 0; i < addresses.length; i += CHUNK_SIZE) {
const chunk = addresses.slice(i, Math.min(i + CHUNK_SIZE, addresses.length));
for (const addr of chunk) {
if (addr) {
// Skip empty strings
set.add(addr);
}
}
// Yield to event loop after each chunk
if (i + CHUNK_SIZE < addresses.length) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
// Add test addresses
this._testWebAddresses.forEach((addr) => set.add(addr));
set.add("phishing.testcategory.com"); // For QA testing
// Cache for future use
this._cachedWebAddressesSet = set;
this._cachedSetChecksum = checksum;
const buildTime = Date.now() - startTime;
this.logService.debug(
`[PhishingDataService] Set built: ${set.size} entries in ${buildTime}ms (checksum: ${checksum.substring(0, 8)}...)`,
);
return set;
return response.text().then((text) => text.split("\n"));
}
private getTestWebAddresses() {
@@ -502,7 +218,7 @@ export class PhishingDataService {
const webAddresses = devFlagValue("testPhishingUrls") as unknown[];
if (webAddresses && webAddresses instanceof Array) {
this.logService.debug(
"[PhishingDataService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:",
"[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:",
webAddresses,
);
return webAddresses as string[];

View File

@@ -1,5 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { EMPTY, Observable, of } from "rxjs";
import { Observable, of } from "rxjs";
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -16,9 +16,7 @@ describe("PhishingDetectionService", () => {
beforeEach(() => {
logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any;
phishingDataService = mock<PhishingDataService>({
update$: EMPTY,
});
phishingDataService = mock();
messageListener = mock<MessageListener>({
messages$(_commandDefinition) {
return new Observable();

View File

@@ -1,14 +1,11 @@
import {
concatMap,
delay,
distinctUntilChanged,
EMPTY,
filter,
map,
merge,
of,
Subject,
Subscription,
switchMap,
tap,
} from "rxjs";
@@ -46,8 +43,6 @@ export class PhishingDetectionService {
private static _tabUpdated$ = new Subject<PhishingDetectionNavigationEvent>();
private static _ignoredHostnames = new Set<string>();
private static _didInit = false;
private static _triggerUpdateSub: Subscription | null = null;
private static _boundTabHandler: ((...args: readonly unknown[]) => unknown) | null = null;
static initialize(
logService: LogService,
@@ -55,34 +50,18 @@ export class PhishingDetectionService {
phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction,
messageListener: MessageListener,
) {
// If already initialized, clean up first to prevent memory leaks on service worker restart
if (this._didInit) {
logService.debug(
"[PhishingDetectionService] Initialize already called. Cleaning up previous instance first.",
);
// Clean up previous state
if (this._triggerUpdateSub) {
this._triggerUpdateSub.unsubscribe();
this._triggerUpdateSub = null;
}
if (this._boundTabHandler) {
BrowserApi.removeListener(chrome.tabs.onUpdated, this._boundTabHandler);
this._boundTabHandler = null;
}
// Clear accumulated state
this._ignoredHostnames.clear();
// Reset flag to allow re-initialization
this._didInit = false;
logService.debug("[PhishingDetectionService] Initialize already called. Aborting.");
return;
}
this._boundTabHandler = this._handleTabUpdated.bind(this) as (
...args: readonly unknown[]
) => unknown;
BrowserApi.addListener(chrome.tabs.onUpdated, this._boundTabHandler);
logService.debug("[PhishingDetectionService] Initialize called. Checking prerequisites...");
BrowserApi.addListener(chrome.tabs.onUpdated, this._handleTabUpdated.bind(this));
const onContinueCommand$ = messageListener.messages$(PHISHING_DETECTION_CONTINUE_COMMAND).pipe(
tap((message) =>
logService.debug(`[PhishingDetectionService] User selected continue for ${message.url}`),
logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`),
),
concatMap(async (message) => {
const url = new URL(message.url);
@@ -108,9 +87,7 @@ export class PhishingDetectionService {
prev.tabId === curr.tabId &&
prev.ignored === curr.ignored,
),
tap((event) =>
logService.debug(`[PhishingDetectionService] Processing navigation event:`, event),
),
tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)),
concatMap(async ({ tabId, url, ignored }) => {
if (ignored) {
// The next time this host is visited, block again
@@ -136,58 +113,23 @@ export class PhishingDetectionService {
const phishingDetectionActive$ = phishingDetectionSettingsService.on$;
// CRITICAL: Only subscribe to update$ if phishing detection is available
// This prevents storage access for non-premium users on extension reload
// The subscription is created lazily when phishing detection becomes active
let updateSub: Subscription | null = null;
const initSub = phishingDetectionActive$
.pipe(
distinctUntilChanged(),
switchMap((activeUserHasAccess) => {
// Clean up previous trigger subscription if it exists
// This prevents memory leaks when account access changes (switch, lock/unlock)
if (this._triggerUpdateSub) {
this._triggerUpdateSub.unsubscribe();
this._triggerUpdateSub = null;
}
if (!activeUserHasAccess) {
logService.debug(
"[PhishingDetectionService] User does not have access to phishing detection service.",
);
// Unsubscribe from update$ if user loses access (e.g., account switch to non-premium)
if (updateSub) {
updateSub.unsubscribe();
updateSub = null;
}
return EMPTY;
} else {
logService.debug("[PhishingDetectionService] Enabling phishing detection service");
// Lazy subscription: Only subscribe to update$ when phishing detection becomes active
// This prevents storage access for non-premium users on extension reload
if (!updateSub) {
updateSub = phishingDataService.update$.subscribe({
next: () => {
logService.debug("[PhishingDetectionService] Update completed");
},
error: (err: unknown) => {
logService.error("[PhishingDetectionService] Update error", err);
},
complete: () => {
logService.debug("[PhishingDetectionService] Update subscription completed");
},
});
}
// Trigger cache update asynchronously using RxJS delay(0)
// This defers to the next event loop tick, preventing UI blocking during account switch
// CRITICAL: Store subscription to prevent memory leaks on account switches
this._triggerUpdateSub = of(null)
.pipe(delay(0))
.subscribe(() => phishingDataService.triggerUpdateIfNeeded());
// update$ removed from merge - popup no longer blocks waiting for update
// The actual update runs via updateSub above
return merge(onContinueCommand$, onTabUpdated$, onCancelCommand$);
return merge(
phishingDataService.update$,
onContinueCommand$,
onTabUpdated$,
onCancelCommand$,
);
}
}),
)
@@ -195,26 +137,16 @@ export class PhishingDetectionService {
this._didInit = true;
return () => {
logService.debug("[PhishingDetectionService] Cleanup function called");
if (updateSub) {
updateSub.unsubscribe();
updateSub = null;
}
initSub.unsubscribe();
// Clean up trigger subscription to prevent memory leaks
if (this._triggerUpdateSub) {
this._triggerUpdateSub.unsubscribe();
this._triggerUpdateSub = null;
}
this._didInit = false;
if (this._boundTabHandler) {
BrowserApi.removeListener(chrome.tabs.onUpdated, this._boundTabHandler);
this._boundTabHandler = null;
}
// Clear accumulated state to prevent memory leaks
this._ignoredHostnames.clear();
// Manually type cast to satisfy the listener signature due to the mixture
// of static and instance methods in this class. To be fixed when refactoring
// this class to be instance-based while providing a singleton instance in usage
BrowserApi.removeListener(
chrome.tabs.onUpdated,
PhishingDetectionService._handleTabUpdated as (...args: readonly unknown[]) => unknown,
);
};
}

View File

@@ -42,6 +42,7 @@ import {
TwoFactorAuthComponent,
TwoFactorAuthGuard,
} from "@bitwarden/auth/angular";
import { canAccessAutoConfirmSettings } from "@bitwarden/auto-confirm";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import {
LockComponent,
@@ -90,6 +91,7 @@ import {
} from "../vault/popup/guards/at-risk-passwords.guard";
import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard";
import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard";
import { AdminSettingsComponent } from "../vault/popup/settings/admin-settings.component";
import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component";
import { ArchiveComponent } from "../vault/popup/settings/archive.component";
import { DownloadBitwardenComponent } from "../vault/popup/settings/download-bitwarden.component";
@@ -332,6 +334,12 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "admin",
component: AdminSettingsComponent,
canActivate: [authGuard, canAccessAutoConfirmSettings],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "clone-cipher",
component: AddEditV2Component,

View File

@@ -3,7 +3,11 @@
import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
import { merge, of, Subject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionService,
OrganizationUserApiService,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
@@ -40,11 +44,18 @@ import {
LogoutService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service";
import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
InternalOrganizationServiceAbstraction,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
AccountService,
@@ -745,6 +756,19 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionNewDeviceVerificationComponentService,
deps: [],
}),
safeProvider({
provide: AutomaticUserConfirmationService,
useClass: DefaultAutomaticUserConfirmationService,
deps: [
ConfigService,
ApiService,
OrganizationUserService,
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({
provide: SessionTimeoutTypeService,
useClass: BrowserSessionTimeoutTypeService,

View File

@@ -82,6 +82,24 @@
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
@if (showAdminSettingsLink$ | async) {
<bit-item>
<a bit-item-content routerLink="/admin">
<i slot="start" class="bwi bwi-business" aria-hidden="true"></i>
<div class="tw-flex tw-items-center tw-justify-center">
<p class="tw-pr-2">{{ "admin" | i18n }}</p>
@if (showAdminBadge$ | async) {
<span bitBadge variant="notification" [attr.aria-label]="'nudgeBadgeAria' | i18n"
>1</span
>
}
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
}
<bit-item>
<a bit-item-content routerLink="/about">
<i slot="start" class="bwi bwi-info-circle" aria-hidden="true"></i>

View File

@@ -6,6 +6,7 @@ import { BehaviorSubject, firstValueFrom, of, Subject } from "rxjs";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { AutofillBrowserSettingsService } from "@bitwarden/browser/autofill/services/autofill-browser-settings.service";
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -42,6 +43,9 @@ describe("SettingsV2Component", () => {
defaultBrowserAutofillDisabled$: Subject<boolean>;
isBrowserAutofillSettingOverridden: jest.Mock<Promise<boolean>>;
};
let mockAutoConfirmService: {
canManageAutoConfirm$: jest.Mock;
};
let dialogService: MockProxy<DialogService>;
let openSpy: jest.SpyInstance;
@@ -66,6 +70,10 @@ describe("SettingsV2Component", () => {
isBrowserAutofillSettingOverridden: jest.fn().mockResolvedValue(false),
};
mockAutoConfirmService = {
canManageAutoConfirm$: jest.fn().mockReturnValue(of(false)),
};
jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue("Chrome");
const cfg = TestBed.configureTestingModule({
@@ -75,6 +83,7 @@ describe("SettingsV2Component", () => {
{ provide: BillingAccountProfileStateService, useValue: mockBillingState },
{ provide: NudgesService, useValue: mockNudges },
{ provide: AutofillBrowserSettingsService, useValue: mockAutofillSettings },
{ provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService },
{ provide: DialogService, useValue: dialogService },
{ provide: I18nService, useValue: { t: jest.fn((key: string) => key) } },
{ provide: GlobalStateProvider, useValue: new FakeGlobalStateProvider() },

View File

@@ -7,7 +7,9 @@ import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/compon
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { UserId } from "@bitwarden/common/types/guid";
import {
@@ -65,13 +67,25 @@ export class SettingsV2Component {
),
);
showAdminBadge$: Observable<boolean> = this.authenticatedAccount$.pipe(
switchMap((account) =>
this.nudgesService.showNudgeBadge$(NudgeType.AutoConfirmNudge, account.id),
),
);
showAutofillBadge$: Observable<boolean> = this.authenticatedAccount$.pipe(
switchMap((account) => this.nudgesService.showNudgeBadge$(NudgeType.AutofillNudge, account.id)),
);
showAdminSettingsLink$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.autoConfirmService.canManageAutoConfirm$(userId)),
);
constructor(
private readonly nudgesService: NudgesService,
private readonly accountService: AccountService,
private readonly autoConfirmService: AutomaticUserConfirmationService,
private readonly accountProfileStateService: BillingAccountProfileStateService,
private readonly dialogService: DialogService,
) {}

View File

@@ -0,0 +1,41 @@
<popup-page [loading]="formLoading()">
<popup-header slot="header" [pageTitle]="'admin' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<div class="tw-px-1 tw-pt-1">
@if (showAutoConfirmSpotlight$ | async) {
<bit-spotlight [persistent]="true">
<div class="tw-flex tw-flex-row tw-items-center tw-justify-between">
<span class="tw-text-sm">
{{ "autoConfirmOnboardingCallout" | i18n }}
</span>
<button
type="button"
bitIconButton="bwi-close"
size="small"
(click)="dismissSpotlight()"
class="tw-ml-1 tw-mt-[2px]"
[label]="'close' | i18n"
></button>
</div>
</bit-spotlight>
}
<form [formGroup]="adminForm">
<bit-card>
<bit-switch formControlName="autoConfirm">
<bit-label>
<span class="tw-text-sm">
{{ "automaticUserConfirmation" | i18n }}
</span>
</bit-label>
<bit-hint class="tw-max-w-[18rem]">{{ "automaticUserConfirmationHint" | i18n }}</bit-hint>
</bit-switch>
</bit-card>
</form>
</div>
</popup-page>

View File

@@ -0,0 +1,199 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { AutoConfirmState, AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { AdminSettingsComponent } from "./admin-settings.component";
@Component({
selector: "popup-header",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupHeaderComponent {
readonly pageTitle = input<string>();
readonly backAction = input<() => void>();
}
@Component({
selector: "popup-page",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupPageComponent {
readonly loading = input<boolean>();
}
@Component({
selector: "app-pop-out",
template: ``,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopOutComponent {
readonly show = input<boolean>(true);
}
describe("AdminSettingsComponent", () => {
let component: AdminSettingsComponent;
let fixture: ComponentFixture<AdminSettingsComponent>;
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let nudgesService: MockProxy<NudgesService>;
let mockDialogService: MockProxy<DialogService>;
const userId = "test-user-id" as UserId;
const mockAutoConfirmState: AutoConfirmState = {
enabled: false,
showSetupDialog: true,
showBrowserNotification: false,
};
beforeEach(async () => {
autoConfirmService = mock<AutomaticUserConfirmationService>();
nudgesService = mock<NudgesService>();
mockDialogService = mock<DialogService>();
autoConfirmService.configuration$.mockReturnValue(of(mockAutoConfirmState));
autoConfirmService.upsert.mockResolvedValue(undefined);
nudgesService.showNudgeSpotlight$.mockReturnValue(of(false));
await TestBed.configureTestingModule({
imports: [AdminSettingsComponent],
providers: [
provideNoopAnimations(),
{ provide: AccountService, useValue: mockAccountServiceWith(userId) },
{ provide: AutomaticUserConfirmationService, useValue: autoConfirmService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: NudgesService, useValue: nudgesService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
})
.overrideComponent(AdminSettingsComponent, {
remove: {
imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent],
},
add: {
imports: [MockPopupHeaderComponent, MockPopupPageComponent, MockPopOutComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(AdminSettingsComponent);
component = fixture.componentInstance;
});
describe("initialization", () => {
it("should populate form with current auto-confirm state", async () => {
const mockState: AutoConfirmState = {
enabled: true,
showSetupDialog: false,
showBrowserNotification: true,
};
autoConfirmService.configuration$.mockReturnValue(of(mockState));
await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
expect(component["adminForm"].value).toEqual({
autoConfirm: true,
});
});
it("should populate form with disabled auto-confirm state", async () => {
await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
expect(component["adminForm"].value).toEqual({
autoConfirm: false,
});
});
});
describe("spotlight", () => {
beforeEach(async () => {
await component.ngOnInit();
fixture.detectChanges();
});
it("should expose showAutoConfirmSpotlight$ observable", (done) => {
nudgesService.showNudgeSpotlight$.mockReturnValue(of(true));
const newFixture = TestBed.createComponent(AdminSettingsComponent);
const newComponent = newFixture.componentInstance;
newComponent["showAutoConfirmSpotlight$"].subscribe((show) => {
expect(show).toBe(true);
expect(nudgesService.showNudgeSpotlight$).toHaveBeenCalledWith(
NudgeType.AutoConfirmNudge,
userId,
);
done();
});
});
it("should dismiss spotlight and update state", async () => {
autoConfirmService.upsert.mockResolvedValue();
await component.dismissSpotlight();
expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, {
...mockAutoConfirmState,
showBrowserNotification: false,
});
});
it("should use current userId when dismissing spotlight", async () => {
autoConfirmService.upsert.mockResolvedValue();
await component.dismissSpotlight();
expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, expect.any(Object));
});
it("should preserve existing state when dismissing spotlight", async () => {
const customState: AutoConfirmState = {
enabled: true,
showSetupDialog: false,
showBrowserNotification: true,
};
autoConfirmService.configuration$.mockReturnValue(of(customState));
autoConfirmService.upsert.mockResolvedValue();
await component.dismissSpotlight();
expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, {
...customState,
showBrowserNotification: false,
});
});
});
describe("form validation", () => {
beforeEach(async () => {
await component.ngOnInit();
fixture.detectChanges();
});
it("should have a valid form", () => {
expect(component["adminForm"].valid).toBe(true);
});
it("should have autoConfirm control", () => {
expect(component["adminForm"].controls.autoConfirm).toBeDefined();
});
});
});

View File

@@ -0,0 +1,121 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnInit,
signal,
WritableSignal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom, map, Observable, of, switchMap, tap, withLatestFrom } from "rxjs";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import {
AutoConfirmWarningDialogComponent,
AutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "@bitwarden/browser/platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "@bitwarden/browser/platform/popup/layout/popup-page.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
BitIconButtonComponent,
CardComponent,
DialogService,
FormFieldModule,
SwitchComponent,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { UserId } from "@bitwarden/user-core";
@Component({
templateUrl: "./admin-settings.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
PopupPageComponent,
PopupHeaderComponent,
PopOutComponent,
FormFieldModule,
ReactiveFormsModule,
SwitchComponent,
CardComponent,
SpotlightComponent,
BitIconButtonComponent,
I18nPipe,
],
})
export class AdminSettingsComponent implements OnInit {
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
protected readonly formLoading: WritableSignal<boolean> = signal(true);
protected adminForm = this.formBuilder.group({
autoConfirm: false,
});
protected showAutoConfirmSpotlight$: Observable<boolean> = this.userId$.pipe(
switchMap((userId) =>
this.nudgesService.showNudgeSpotlight$(NudgeType.AutoConfirmNudge, userId),
),
);
constructor(
private formBuilder: FormBuilder,
private accountService: AccountService,
private autoConfirmService: AutomaticUserConfirmationService,
private destroyRef: DestroyRef,
private dialogService: DialogService,
private nudgesService: NudgesService,
) {}
async ngOnInit() {
const userId = await firstValueFrom(this.userId$);
const autoConfirmEnabled = (
await firstValueFrom(this.autoConfirmService.configuration$(userId))
).enabled;
this.adminForm.setValue({ autoConfirm: autoConfirmEnabled });
this.formLoading.set(false);
this.adminForm.controls.autoConfirm.valueChanges
.pipe(
switchMap((newValue) => {
if (newValue) {
return this.confirm();
}
return of(false);
}),
withLatestFrom(this.autoConfirmService.configuration$(userId)),
switchMap(([newValue, existingState]) =>
this.autoConfirmService.upsert(userId, {
...existingState,
enabled: newValue,
showBrowserNotification: false,
}),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
private confirm(): Observable<boolean> {
return AutoConfirmWarningDialogComponent.open(this.dialogService).closed.pipe(
map((result) => result ?? false),
tap((result) => {
if (!result) {
this.adminForm.setValue({ autoConfirm: false }, { emitEvent: false });
}
}),
);
}
async dismissSpotlight() {
const userId = await firstValueFrom(this.userId$);
const state = await firstValueFrom(this.autoConfirmService.configuration$(userId));
await this.autoConfirmService.upsert(userId, { ...state, showBrowserNotification: false });
}
}

View File

@@ -1,5 +1,11 @@
use anyhow::Result;
#[cfg(target_os = "windows")]
mod modifier_keys;
#[cfg(target_os = "windows")]
pub(crate) use modifier_keys::*;
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(target_os = "windows", path = "windows/mod.rs")]

View File

@@ -0,0 +1,45 @@
// Electron modifier keys
// <https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts#cross-platform-modifiers>
pub(crate) const CONTROL_KEY_STR: &str = "Control";
pub(crate) const ALT_KEY_STR: &str = "Alt";
pub(crate) const SUPER_KEY_STR: &str = "Super";
// numeric values for modifier keys
pub(crate) const CONTROL_KEY: u16 = 0x11;
pub(crate) const ALT_KEY: u16 = 0x12;
pub(crate) const SUPER_KEY: u16 = 0x5B;
/// A mapping of <Electron modifier key string> to <numeric representation>
static MODIFIER_KEYS: [(&str, u16); 3] = [
(CONTROL_KEY_STR, CONTROL_KEY),
(ALT_KEY_STR, ALT_KEY),
(SUPER_KEY_STR, SUPER_KEY),
];
/// Provides a mapping of the valid modifier keys' electron
/// string representation to the numeric representation.
pub(crate) fn get_numeric_modifier_key(modifier: &str) -> Option<u16> {
for (modifier_str, modifier_num) in MODIFIER_KEYS {
if modifier_str == modifier {
return Some(modifier_num);
}
}
None
}
#[cfg(test)]
mod test {
use super::get_numeric_modifier_key;
#[test]
fn valid_modifier_keys() {
assert_eq!(get_numeric_modifier_key("Control").unwrap(), 0x11);
assert_eq!(get_numeric_modifier_key("Alt").unwrap(), 0x12);
assert_eq!(get_numeric_modifier_key("Super").unwrap(), 0x5B);
}
#[test]
fn does_not_contain_invalid_modifier_keys() {
assert!(get_numeric_modifier_key("Shift").is_none());
}
}

View File

@@ -44,7 +44,6 @@ pub fn get_foreground_window_title() -> Result<String> {
/// - Control
/// - Alt
/// - Super
/// - Shift
/// - \[a-z\]\[A-Z\]
struct KeyboardShortcutInput(INPUT);

View File

@@ -6,11 +6,7 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
};
use super::{ErrorOperations, KeyboardShortcutInput, Win32ErrorOperations};
const SHIFT_KEY_STR: &str = "Shift";
const CONTROL_KEY_STR: &str = "Control";
const ALT_KEY_STR: &str = "Alt";
const LEFT_WINDOWS_KEY_STR: &str = "Super";
use crate::get_numeric_modifier_key;
const IS_VIRTUAL_KEY: bool = true;
const IS_REAL_KEY: bool = false;
@@ -88,22 +84,19 @@ impl TryFrom<&str> for KeyboardShortcutInput {
type Error = anyhow::Error;
fn try_from(key: &str) -> std::result::Result<Self, Self::Error> {
const SHIFT_KEY: u16 = 0x10;
const CONTROL_KEY: u16 = 0x11;
const ALT_KEY: u16 = 0x12;
const LEFT_WINDOWS_KEY: u16 = 0x5B;
// not modifier key
if key.len() == 1 {
let input = build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?);
return Ok(KeyboardShortcutInput(input));
}
// the modifier keys are using the Up keypress variant because the user has already
// pressed those keys in order to trigger the feature.
let input = match key {
SHIFT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, SHIFT_KEY),
CONTROL_KEY_STR => build_virtual_key_input(InputKeyPress::Up, CONTROL_KEY),
ALT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, ALT_KEY),
LEFT_WINDOWS_KEY_STR => build_virtual_key_input(InputKeyPress::Up, LEFT_WINDOWS_KEY),
_ => build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?),
};
Ok(KeyboardShortcutInput(input))
if let Some(numeric_modifier_key) = get_numeric_modifier_key(key) {
let input = build_virtual_key_input(InputKeyPress::Up, numeric_modifier_key);
Ok(KeyboardShortcutInput(input))
} else {
Err(anyhow!("Unsupported modifier key: {key}"))
}
}
}
@@ -278,7 +271,7 @@ mod tests {
#[test]
#[serial]
fn keyboard_shortcut_conversion_succeeds() {
let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "B"];
let keyboard_shortcut = ["Control", "Alt", "B"];
let _: Vec<KeyboardShortcutInput> = keyboard_shortcut
.iter()
.map(|s| KeyboardShortcutInput::try_from(*s))
@@ -290,7 +283,19 @@ mod tests {
#[serial]
#[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '1'"]
fn keyboard_shortcut_conversion_fails_invalid_key() {
let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "1"];
let keyboard_shortcut = ["Control", "Alt", "1"];
let _: Vec<KeyboardShortcutInput> = keyboard_shortcut
.iter()
.map(|s| KeyboardShortcutInput::try_from(*s))
.try_collect()
.unwrap();
}
#[test]
#[serial]
#[should_panic(expected = "Unsupported modifier key: Shift")]
fn keyboard_shortcut_conversion_fails_with_shift() {
let keyboard_shortcut = ["Control", "Shift", "B"];
let _: Vec<KeyboardShortcutInput> = keyboard_shortcut
.iter()
.map(|s| KeyboardShortcutInput::try_from(*s))

View File

@@ -188,7 +188,7 @@ describe("SettingsComponent", () => {
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
policyService.policiesByType$.mockReturnValue(of([null]));
desktopAutotypeService.autotypeEnabledUserSetting$ = of(false);
desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]);
desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Alt", "B"]);
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
configService.getFeatureFlag$.mockReturnValue(of(false));
});

View File

@@ -5,7 +5,7 @@
</div>
<div bitDialogContent>
<p>
{{ "editAutotypeShortcutDescription" | i18n }}
{{ "editAutotypeKeyboardModifiersDescription" | i18n }}
</p>
<bit-form-field>
<bit-label>{{ "typeShortcut" | i18n }}</bit-label>

View File

@@ -30,11 +30,9 @@ describe("AutotypeShortcutComponent", () => {
const validShortcuts = [
"Control+A",
"Alt+B",
"Shift+C",
"Win+D",
"control+e", // case insensitive
"ALT+F",
"SHIFT+G",
"WIN+H",
];
@@ -46,14 +44,7 @@ describe("AutotypeShortcutComponent", () => {
});
it("should accept two modifiers with letter", () => {
const validShortcuts = [
"Control+Alt+A",
"Control+Shift+B",
"Control+Win+C",
"Alt+Shift+D",
"Alt+Win+E",
"Shift+Win+F",
];
const validShortcuts = ["Control+Alt+A", "Control+Win+C", "Alt+Win+D", "Alt+Win+E"];
validShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
@@ -63,7 +54,7 @@ describe("AutotypeShortcutComponent", () => {
});
it("should accept modifiers in different orders", () => {
const validShortcuts = ["Alt+Control+A", "Shift+Control+B", "Win+Alt+C"];
const validShortcuts = ["Alt+Control+A", "Win+Control+B", "Win+Alt+C"];
validShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
@@ -88,15 +79,14 @@ describe("AutotypeShortcutComponent", () => {
const invalidShortcuts = [
"Control+1",
"Alt+2",
"Shift+3",
"Win+4",
"Control+!",
"Alt+@",
"Shift+#",
"Alt+#",
"Win+$",
"Control+Space",
"Alt+Enter",
"Shift+Tab",
"Control+Tab",
"Win+Escape",
];
@@ -111,12 +101,10 @@ describe("AutotypeShortcutComponent", () => {
const invalidShortcuts = [
"Control",
"Alt",
"Shift",
"Win",
"Control+Alt",
"Control+Shift",
"Alt+Shift",
"Control+Alt+Shift",
"Control+Win",
"Control+Alt+Win",
];
invalidShortcuts.forEach((shortcut) => {
@@ -127,7 +115,7 @@ describe("AutotypeShortcutComponent", () => {
});
it("should reject shortcuts with invalid modifier names", () => {
const invalidShortcuts = ["Ctrl+A", "Command+A", "Super+A", "Meta+A", "Cmd+A", "Invalid+A"];
const invalidShortcuts = ["Ctrl+A", "Command+A", "Meta+A", "Cmd+A", "Invalid+A"];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
@@ -137,7 +125,7 @@ describe("AutotypeShortcutComponent", () => {
});
it("should reject shortcuts with multiple base keys", () => {
const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Shift"];
const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Win"];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
@@ -148,11 +136,10 @@ describe("AutotypeShortcutComponent", () => {
it("should reject shortcuts with more than two modifiers", () => {
const invalidShortcuts = [
"Control+Alt+Shift+A",
"Control+Alt+Win+A",
"Control+Alt+Win+B",
"Control+Shift+Win+C",
"Alt+Shift+Win+D",
"Control+Alt+Shift+Win+E",
"Control+Alt+Win+C",
"Alt+Control+Win+D",
];
invalidShortcuts.forEach((shortcut) => {
@@ -221,7 +208,7 @@ describe("AutotypeShortcutComponent", () => {
});
it("should handle very long strings", () => {
const longString = "Control+Alt+Shift+Win+A".repeat(100);
const longString = "Control+Alt+Win+A".repeat(100);
const control = createControl(longString);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
@@ -230,7 +217,7 @@ describe("AutotypeShortcutComponent", () => {
describe("modifier combinations", () => {
it("should accept all possible single modifier combinations", () => {
const modifiers = ["Control", "Alt", "Shift", "Win"];
const modifiers = ["Control", "Alt", "Win"];
modifiers.forEach((modifier) => {
const control = createControl(`${modifier}+A`);
@@ -240,14 +227,7 @@ describe("AutotypeShortcutComponent", () => {
});
it("should accept all possible two-modifier combinations", () => {
const combinations = [
"Control+Alt+A",
"Control+Shift+A",
"Control+Win+A",
"Alt+Shift+A",
"Alt+Win+A",
"Shift+Win+A",
];
const combinations = ["Control+Alt+A", "Control+Win+A", "Alt+Win+A"];
combinations.forEach((shortcut) => {
const control = createControl(shortcut);
@@ -257,12 +237,7 @@ describe("AutotypeShortcutComponent", () => {
});
it("should reject all three-modifier combinations", () => {
const combinations = [
"Control+Alt+Shift+A",
"Control+Alt+Win+A",
"Control+Shift+Win+A",
"Alt+Shift+Win+A",
];
const combinations = ["Control+Alt+Win+A", "Alt+Control+Win+A", "Win+Alt+Control+A"];
combinations.forEach((shortcut) => {
const control = createControl(shortcut);
@@ -270,12 +245,6 @@ describe("AutotypeShortcutComponent", () => {
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject all four modifiers combination", () => {
const control = createControl("Control+Alt+Shift+Win+A");
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
});
});

View File

@@ -77,25 +77,31 @@ export class AutotypeShortcutComponent {
}
}
// <https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent>
private buildShortcutFromEvent(event: KeyboardEvent): string | null {
const hasCtrl = event.ctrlKey;
const hasAlt = event.altKey;
const hasShift = event.shiftKey;
const hasMeta = event.metaKey; // Windows key on Windows, Command on macOS
const hasSuper = event.metaKey; // Windows key on Windows, Command on macOS
// Require at least one modifier (Control, Alt, Shift, or Super)
if (!hasCtrl && !hasAlt && !hasShift && !hasMeta) {
// Require at least one valid modifier (Control, Alt, Super)
if (!hasCtrl && !hasAlt && !hasSuper) {
return null;
}
const key = event.key;
// Ignore pure modifier keys themselves
if (key === "Control" || key === "Alt" || key === "Shift" || key === "Meta") {
// disallow pure modifier keys themselves
if (key === "Control" || key === "Alt" || key === "Meta") {
return null;
}
// Accept a single alphabetical letter as the base key
// disallow shift modifier
if (hasShift) {
return null;
}
// require a single alphabetical letter as the base key
const isAlphabetical = typeof key === "string" && /^[a-z]$/i.test(key);
if (!isAlphabetical) {
return null;
@@ -108,10 +114,7 @@ export class AutotypeShortcutComponent {
if (hasAlt) {
parts.push("Alt");
}
if (hasShift) {
parts.push("Shift");
}
if (hasMeta) {
if (hasSuper) {
parts.push("Super");
}
parts.push(key.toUpperCase());
@@ -129,10 +132,9 @@ export class AutotypeShortcutComponent {
}
// Must include exactly 1-2 modifiers and end with a single letter
// Valid examples: Ctrl+A, Shift+Z, Ctrl+Shift+X, Alt+Shift+Q
// Valid examples: Ctrl+A, Alt+B, Ctrl+Alt+X, Alt+Control+Q, Win+B, Ctrl+Win+A
// Allow modifiers in any order, but only 1-2 modifiers total
const pattern =
/^(?=.*\b(Control|Alt|Shift|Win)\b)(?:Control\+|Alt\+|Shift\+|Win\+){1,2}[A-Z]$/i;
const pattern = /^(?=.*\b(Control|Alt|Win)\b)(?:Control\+|Alt\+|Win\+){1,2}[A-Z]$/i;
return pattern.test(value)
? null
: { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };

View File

@@ -1,4 +1,14 @@
import { defaultWindowsAutotypeKeyboardShortcut } from "../services/desktop-autotype.service";
/**
Electron's representation of modifier keys
<https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts#cross-platform-modifiers>
*/
export const CONTROL_KEY_STR = "Control";
export const ALT_KEY_STR = "Alt";
export const SUPER_KEY_STR = "Super";
export const VALID_SHORTCUT_MODIFIER_KEYS: string[] = [CONTROL_KEY_STR, ALT_KEY_STR, SUPER_KEY_STR];
export const DEFAULT_KEYBOARD_SHORTCUT: string[] = [CONTROL_KEY_STR, ALT_KEY_STR, "B"];
/*
This class provides the following:
@@ -13,7 +23,7 @@ export class AutotypeKeyboardShortcut {
private autotypeKeyboardShortcut: string[];
constructor() {
this.autotypeKeyboardShortcut = defaultWindowsAutotypeKeyboardShortcut;
this.autotypeKeyboardShortcut = DEFAULT_KEYBOARD_SHORTCUT;
}
/*
@@ -51,14 +61,16 @@ export class AutotypeKeyboardShortcut {
This private function validates the strArray input to make sure the array contains
valid, currently accepted shortcut keys for Windows.
Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z
Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z (not yet supported)
Valid shortcut keys: Control, Alt, Super, letters A - Z
Platform specifics:
- On Windows, Super maps to the Windows key.
- On MacOS, Super maps to the Command key.
- On MacOS, Alt maps to the Option key.
See Electron keyboard shorcut docs for more info:
https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts
*/
#keyboardShortcutIsValid(strArray: string[]) {
const VALID_SHORTCUT_CONTROL_KEYS: string[] = ["Control", "Alt", "Super", "Shift"];
const UNICODE_LOWER_BOUND = 65; // unicode 'A'
const UNICODE_UPPER_BOUND = 90; // unicode 'Z'
const MIN_LENGTH: number = 2;
@@ -77,7 +89,7 @@ export class AutotypeKeyboardShortcut {
// Ensure strArray is all modifier keys, and that the last key is a letter
for (let i = 0; i < strArray.length; i++) {
if (i < strArray.length - 1) {
if (!VALID_SHORTCUT_CONTROL_KEYS.includes(strArray[i])) {
if (!VALID_SHORTCUT_MODIFIER_KEYS.includes(strArray[i])) {
return false;
}
} else {

View File

@@ -33,11 +33,10 @@ import { UserId } from "@bitwarden/user-core";
import { AutotypeConfig } from "../models/autotype-config";
import { AutotypeVaultData } from "../models/autotype-vault-data";
import { DEFAULT_KEYBOARD_SHORTCUT } from "../models/main-autotype-keyboard-shortcut";
import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service";
export const defaultWindowsAutotypeKeyboardShortcut: string[] = ["Control", "Shift", "B"];
export const AUTOTYPE_ENABLED = new KeyDefinition<boolean | null>(
AUTOTYPE_SETTINGS_DISK,
"autotypeEnabled",
@@ -72,10 +71,9 @@ export class DesktopAutotypeService implements OnDestroy {
private readonly isPremiumAccount$: Observable<boolean>;
// The enabled/disabled state from the user settings menu
autotypeEnabledUserSetting$: Observable<boolean>;
autotypeEnabledUserSetting$: Observable<boolean> = of(false);
// The keyboard shortcut from the user settings menu
autotypeKeyboardShortcut$: Observable<string[]> = of(defaultWindowsAutotypeKeyboardShortcut);
autotypeKeyboardShortcut$: Observable<string[]> = of(DEFAULT_KEYBOARD_SHORTCUT);
private destroy$ = new Subject<void>();
@@ -106,7 +104,7 @@ export class DesktopAutotypeService implements OnDestroy {
);
this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$.pipe(
map((shortcut) => shortcut ?? defaultWindowsAutotypeKeyboardShortcut),
map((shortcut) => shortcut ?? DEFAULT_KEYBOARD_SHORTCUT),
takeUntil(this.destroy$),
);
}

View File

@@ -4267,8 +4267,8 @@
"typeShortcut": {
"message": "Type shortcut"
},
"editAutotypeShortcutDescription": {
"message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter."
"editAutotypeKeyboardModifiersDescription": {
"message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter."
},
"invalidShortcut": {
"message": "Invalid shortcut"

View File

@@ -1,70 +0,0 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom, Observable, switchMap, tap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
/**
* This guard is intended to prevent members of an organization from accessing
* routes based on compliance with organization
* policies. e.g Emergency access, which is a non-organization
* feature is restricted by the Auto Confirm policy.
*/
export function organizationPolicyGuard(
featureCallback: (
userId: UserId,
configService: ConfigService,
policyService: PolicyService,
) => Observable<boolean>,
): CanActivateFn {
return async () => {
const router = inject(Router);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
const accountService = inject(AccountService);
const policyService = inject(PolicyService);
const configService = inject(ConfigService);
const syncService = inject(SyncService);
const synced = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => syncService.lastSync$(userId)),
),
);
if (synced == null) {
await syncService.fullSync(false);
}
const compliant = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => featureCallback(userId, configService, policyService)),
tap((compliant) => {
if (typeof compliant !== "boolean") {
throw new Error("Feature callback must return a boolean.");
}
}),
),
);
if (!compliant) {
toastService.showToast({
variant: "error",
message: i18nService.t("noPageAccess"),
});
return router.createUrlTree(["/"]);
}
return compliant;
};
}

View File

@@ -1 +1,2 @@
export * from "./members.module";
export * from "./pipes";

View File

@@ -102,15 +102,25 @@
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
<th bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[bitAction]="exportMembers"
[disabled]="!firstLoaded"
label="{{ 'export' | i18n }}"
></button>
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
</div>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
@@ -352,13 +362,16 @@
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<div class="tw-w-[32px]"></div>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
</div>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">

View File

@@ -35,6 +35,7 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin
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 { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -55,7 +56,11 @@ import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
import { MemberDialogManagerService, OrganizationMembersService } from "./services";
import {
MemberDialogManagerService,
MemberExportService,
OrganizationMembersService,
} from "./services";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import {
MemberActionsService,
@@ -119,6 +124,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private fileDownloadService: FileDownloadService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
@@ -593,4 +600,36 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
exportMembers = async (): Promise<void> => {
try {
const members = this.dataSource.data;
if (!members || members.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noMembersToExport"),
});
return;
}
const csvData = this.memberExportService.getMemberExport(members);
const fileName = this.memberExportService.getFileName("org-members");
this.fileDownloadService.download({
fileName: fileName,
blobData: csvData,
blobOptions: { type: "text/plain" },
});
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("dataExportSuccess"),
});
} catch (e) {
this.validationService.showError(e);
this.logService.error(`Failed to export members: ${e}`);
}
};
}

View File

@@ -19,10 +19,12 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
import { UserStatusPipe } from "./pipes";
import {
OrganizationMembersService,
MemberActionsService,
MemberDialogManagerService,
MemberExportService,
} from "./services";
@NgModule({
@@ -45,12 +47,15 @@ import {
BulkStatusComponent,
MembersComponent,
BulkDeleteDialogComponent,
UserStatusPipe,
],
providers: [
OrganizationMembersService,
MemberActionsService,
BillingConstraintService,
MemberDialogManagerService,
MemberExportService,
UserStatusPipe,
],
})
export class MembersModule {}

View File

@@ -0,0 +1 @@
export * from "./user-status.pipe";

View File

@@ -0,0 +1,47 @@
import { MockProxy, mock } from "jest-mock-extended";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserStatusPipe } from "./user-status.pipe";
describe("UserStatusPipe", () => {
let pipe: UserStatusPipe;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
i18nService.t.mockImplementation((key: string) => key);
pipe = new UserStatusPipe(i18nService);
});
it("transforms OrganizationUserStatusType.Invited to 'invited'", () => {
expect(pipe.transform(OrganizationUserStatusType.Invited)).toBe("invited");
expect(i18nService.t).toHaveBeenCalledWith("invited");
});
it("transforms OrganizationUserStatusType.Accepted to 'accepted'", () => {
expect(pipe.transform(OrganizationUserStatusType.Accepted)).toBe("accepted");
expect(i18nService.t).toHaveBeenCalledWith("accepted");
});
it("transforms OrganizationUserStatusType.Confirmed to 'confirmed'", () => {
expect(pipe.transform(OrganizationUserStatusType.Confirmed)).toBe("confirmed");
expect(i18nService.t).toHaveBeenCalledWith("confirmed");
});
it("transforms OrganizationUserStatusType.Revoked to 'revoked'", () => {
expect(pipe.transform(OrganizationUserStatusType.Revoked)).toBe("revoked");
expect(i18nService.t).toHaveBeenCalledWith("revoked");
});
it("transforms null to 'unknown'", () => {
expect(pipe.transform(null)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
it("transforms undefined to 'unknown'", () => {
expect(pipe.transform(undefined)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
});

View File

@@ -0,0 +1,30 @@
import { Pipe, PipeTransform } from "@angular/core";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@Pipe({
name: "userStatus",
standalone: false,
})
export class UserStatusPipe implements PipeTransform {
constructor(private i18nService: I18nService) {}
transform(value?: OrganizationUserStatusType): string {
if (value == null) {
return this.i18nService.t("unknown");
}
switch (value) {
case OrganizationUserStatusType.Invited:
return this.i18nService.t("invited");
case OrganizationUserStatusType.Accepted:
return this.i18nService.t("accepted");
case OrganizationUserStatusType.Confirmed:
return this.i18nService.t("confirmed");
case OrganizationUserStatusType.Revoked:
return this.i18nService.t("revoked");
default:
return this.i18nService.t("unknown");
}
}
}

View File

@@ -1,4 +1,5 @@
export { OrganizationMembersService } from "./organization-members-service/organization-members.service";
export { MemberActionsService } from "./member-actions/member-actions.service";
export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service";
export { MemberExportService } from "./member-export";
export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service";

View File

@@ -0,0 +1,2 @@
export * from "./member.export";
export * from "./member-export.service";

View File

@@ -0,0 +1,151 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
import { MemberExportService } from "./member-export.service";
describe("MemberExportService", () => {
let service: MemberExportService;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
// Setup common i18n translations
i18nService.t.mockImplementation((key: string) => {
const translations: Record<string, string> = {
// Column headers
email: "Email",
name: "Name",
status: "Status",
role: "Role",
twoStepLogin: "Two-step Login",
accountRecovery: "Account Recovery",
secretsManager: "Secrets Manager",
groups: "Groups",
// Status values
invited: "Invited",
accepted: "Accepted",
confirmed: "Confirmed",
revoked: "Revoked",
// Role values
owner: "Owner",
admin: "Admin",
user: "User",
custom: "Custom",
// Boolean states
enabled: "Enabled",
disabled: "Disabled",
enrolled: "Enrolled",
notEnrolled: "Not Enrolled",
};
return translations[key] || key;
});
TestBed.configureTestingModule({
providers: [
MemberExportService,
{ provide: I18nService, useValue: i18nService },
UserTypePipe,
UserStatusPipe,
],
});
service = TestBed.inject(MemberExportService);
});
describe("getMemberExport", () => {
it("should export members with all fields populated", () => {
const members: OrganizationUserView[] = [
{
email: "user1@example.com",
name: "User One",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Admin,
twoFactorEnabled: true,
resetPasswordEnrolled: true,
accessSecretsManager: true,
groupNames: ["Group A", "Group B"],
} as OrganizationUserView,
{
email: "user2@example.com",
name: "User Two",
status: OrganizationUserStatusType.Invited,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: ["Group C"],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery");
expect(csvData).toContain("user1@example.com");
expect(csvData).toContain("User One");
expect(csvData).toContain("Confirmed");
expect(csvData).toContain("Admin");
expect(csvData).toContain("user2@example.com");
expect(csvData).toContain("User Two");
expect(csvData).toContain("Invited");
});
it("should handle members with null name", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: null,
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: [],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("user@example.com");
// Empty name is represented as an empty field in CSV
expect(csvData).toContain("user@example.com,,Confirmed");
});
it("should handle members with no groups", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: "User",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: null,
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("user@example.com");
expect(csvData).toBeDefined();
});
it("should handle empty members array", () => {
const csvData = service.getMemberExport([]);
// When array is empty, papaparse returns an empty string
expect(csvData).toBe("");
});
});
});

View File

@@ -0,0 +1,49 @@
import { inject, Injectable } from "@angular/core";
import * as papa from "papaparse";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
import { MemberExport } from "./member.export";
@Injectable()
export class MemberExportService {
private i18nService = inject(I18nService);
private userTypePipe = inject(UserTypePipe);
private userStatusPipe = inject(UserStatusPipe);
getMemberExport(members: OrganizationUserView[]): string {
const exportData = members.map((m) =>
MemberExport.fromOrganizationUserView(
this.i18nService,
this.userTypePipe,
this.userStatusPipe,
m,
),
);
const headers: string[] = [
this.i18nService.t("email"),
this.i18nService.t("name"),
this.i18nService.t("status"),
this.i18nService.t("role"),
this.i18nService.t("twoStepLogin"),
this.i18nService.t("accountRecovery"),
this.i18nService.t("secretsManager"),
this.i18nService.t("groups"),
];
return papa.unparse(exportData, {
columns: headers,
header: true,
});
}
getFileName(prefix: string | null = null, extension = "csv"): string {
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

View File

@@ -0,0 +1,43 @@
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
export class MemberExport {
/**
* @param user Organization user to export
* @returns a Record<string, string> of each column header key, value
* All property members must be a string for export purposes. Null and undefined will appear as
* "null" in a .csv export, therefore an empty string is preferable to a nullish type.
*/
static fromOrganizationUserView(
i18nService: I18nService,
userTypePipe: UserTypePipe,
userStatusPipe: UserStatusPipe,
user: OrganizationUserView,
): Record<string, string> {
const result = {
[i18nService.t("email")]: user.email,
[i18nService.t("name")]: user.name ?? "",
[i18nService.t("status")]: userStatusPipe.transform(user.status),
[i18nService.t("role")]: userTypePipe.transform(user.type),
[i18nService.t("twoStepLogin")]: user.twoFactorEnabled
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("accountRecovery")]: user.resetPasswordEnrolled
? i18nService.t("enrolled")
: i18nService.t("notEnrolled"),
[i18nService.t("secretsManager")]: user.accessSecretsManager
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("groups")]: user.groupNames?.join(", ") ?? "",
};
return result;
}
}

View File

@@ -22,7 +22,7 @@ import {
tap,
} from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";

View File

@@ -188,7 +188,7 @@ describe("PoliciesComponent", () => {
});
describe("orgPolicies$", () => {
it("should fetch policies from API for current organization", async () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: newGuid(),
@@ -206,39 +206,63 @@ describe("PoliciesComponent", () => {
},
];
const listResponse = new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
);
beforeEach(async () => {
const listResponse = new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
);
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual(listResponse.data);
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should fetch policies from API for current organization", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies.length).toBe(2);
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
});
});
it("should return empty array when API returns no data", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns no data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
it("should return empty array when API returns null data", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
);
describe("with null data", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns null data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
});
describe("policiesEnabledMap$", () => {
it("should create a map of policy types to their enabled status", async () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: "policy-1",
@@ -263,27 +287,43 @@ describe("PoliciesComponent", () => {
},
];
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
),
);
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
),
);
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(3);
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
expect(map.get(PolicyType.RequireSso)).toBe(false);
expect(map.get(PolicyType.SingleOrg)).toBe(true);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create a map of policy types to their enabled status", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(3);
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
expect(map.get(PolicyType.RequireSso)).toBe(false);
expect(map.get(PolicyType.SingleOrg)).toBe(true);
});
});
it("should create empty map when no policies exist", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(0);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create empty map when no policies exist", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(0);
});
});
});
@@ -292,31 +332,36 @@ describe("PoliciesComponent", () => {
expect(mockPolicyService.policies$).toHaveBeenCalledWith(mockUserId);
});
it("should refresh policies when policyService emits", async () => {
const policiesSubject = new BehaviorSubject<any[]>([]);
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
describe("when policyService emits", () => {
let policiesSubject: BehaviorSubject<any[]>;
let callCount: number;
let callCount = 0;
mockPolicyApiService.getPolicies.mockImplementation(() => {
callCount++;
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
beforeEach(async () => {
policiesSubject = new BehaviorSubject<any[]>([]);
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
callCount = 0;
mockPolicyApiService.getPolicies.mockImplementation(() => {
callCount++;
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
});
fixture = TestBed.createComponent(PoliciesComponent);
fixture.detectChanges();
});
const newFixture = TestBed.createComponent(PoliciesComponent);
newFixture.detectChanges();
it("should refresh policies when policyService emits", () => {
const initialCallCount = callCount;
const initialCallCount = callCount;
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
expect(callCount).toBeGreaterThan(initialCallCount);
newFixture.destroy();
expect(callCount).toBeGreaterThan(initialCallCount);
});
});
});
describe("handleLaunchEvent", () => {
it("should open policy dialog when policyId is in query params", async () => {
describe("when policyId is in query params", () => {
const mockPolicyId = newGuid();
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
@@ -335,54 +380,59 @@ describe("PoliciesComponent", () => {
data: null,
};
queryParamsSubject.next({ policyId: mockPolicyId });
let dialogOpenSpy: jest.SpyInstance;
mockPolicyApiService.getPolicies.mockReturnValue(
of(
new ListResponse(
{ Data: [mockPolicyResponseData], ContinuationToken: null },
PolicyResponse,
beforeEach(async () => {
queryParamsSubject.next({ policyId: mockPolicyId });
mockPolicyApiService.getPolicies.mockReturnValue(
of(
new ListResponse(
{ Data: [mockPolicyResponseData], ContinuationToken: null },
PolicyResponse,
),
),
),
);
);
const dialogOpenSpy = jest
.spyOn(PolicyEditDialogComponent, "open")
.mockReturnValue({ close: jest.fn() } as any);
dialogOpenSpy = jest
.spyOn(PolicyEditDialogComponent, "open")
.mockReturnValue({ close: jest.fn() } as any);
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.compileComponents();
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
const newFixture = TestBed.createComponent(PoliciesComponent);
newFixture.detectChanges();
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
expect(dialogOpenSpy).toHaveBeenCalled();
const callArgs = dialogOpenSpy.mock.calls[0][1];
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
expect(callArgs.data?.organizationId).toBe(mockOrgId);
newFixture.destroy();
it("should open policy dialog when policyId is in query params", () => {
expect(dialogOpenSpy).toHaveBeenCalled();
const callArgs = dialogOpenSpy.mock.calls[0][1];
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
expect(callArgs.data?.organizationId).toBe(mockOrgId);
});
});
it("should not open dialog when policyId is not in query params", async () => {

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { combineLatest, Observable, of, switchMap, first, map } from "rxjs";
import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
@@ -70,6 +70,7 @@ export class PoliciesComponent {
switchMap(() => this.organizationId$),
switchMap((organizationId) => this.policyApiService.getPolicies(organizationId)),
map((response) => (response.data != null && response.data.length > 0 ? response.data : [])),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected policiesEnabledMap$: Observable<Map<PolicyType, boolean>> = this.orgPolicies$.pipe(

View File

@@ -40,21 +40,27 @@
{{ i.amount | currency: "$" }}
</td>
<td bitCell class="tw-text-right">
<ng-container *ngIf="isSecretsManagerTrial(); else calculateElse">
<ng-container
*ngIf="
isSecretsManagerTrial() && i.productName === 'passwordManager';
else calculateElse
"
>
{{ "freeForOneYear" | i18n }}
</ng-container>
<ng-template #calculateElse>
<div class="tw-flex tw-flex-col">
<span>
{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}
{{ i.quantity * i.amount | currency: "$" }} /
{{ i.interval | i18n }}
</span>
<span
*ngIf="customerDiscount?.percentOff"
*ngIf="
customerDiscount?.percentOff && discountAppliesToProduct(i.productId)
"
class="tw-line-through !tw-text-muted"
>{{
calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$"
}}
/ {{ "year" | i18n }}</span
>{{ i.quantity * i.originalAmount | currency: "$" }} /
{{ "year" | i18n }}</span
>
</div>
</ng-template>

View File

@@ -19,11 +19,9 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -82,9 +80,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
private organizationApiService: OrganizationApiServiceAbstraction,
private route: ActivatedRoute,
private dialogService: DialogService,
private configService: ConfigService,
private toastService: ToastService,
private billingApiService: BillingApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
) {}
@@ -218,6 +214,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
get subscriptionLineItems() {
return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({
name: lineItem.name,
originalAmount: lineItem.amount,
amount: this.discountPrice(lineItem.amount, lineItem.productId),
quantity: lineItem.quantity,
interval: lineItem.interval,
@@ -406,12 +403,16 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
const isSmStandalone = this.sub?.customerDiscount?.id === "sm-standalone";
const appliesToProduct =
this.sub?.subscription?.items?.some((item) =>
this.sub?.customerDiscount?.appliesTo?.includes(item.productId),
this.discountAppliesToProduct(item.productId),
) ?? false;
return isSmStandalone && appliesToProduct;
}
discountAppliesToProduct(productId: string): boolean {
return this.sub?.customerDiscount?.appliesTo?.includes(productId) ?? false;
}
closeChangePlan() {
this.showChangePlan = false;
}
@@ -438,10 +439,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
await this.load();
}
calculateTotalAppliedDiscount(total: number) {
return total / (1 - this.customerDiscount?.percentOff / 100);
}
adjustStorage = (add: boolean) => {
return async () => {
const dialogRef = AdjustStorageDialogComponent.open(this.dialogService, {

View File

@@ -9,8 +9,6 @@ import {
DefaultCollectionAdminService,
OrganizationUserApiService,
CollectionService,
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
OrganizationUserService,
DefaultOrganizationUserService,
} from "@bitwarden/admin-console/common";
@@ -46,6 +44,10 @@ import {
InternalUserDecryptionOptionsServiceAbstraction,
LoginEmailService,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
@@ -376,6 +378,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({

View File

@@ -4,12 +4,12 @@ import { CommonModule } from "@angular/common";
import { Component, OnInit, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { RouterModule } from "@angular/router";
import { combineLatest, map, Observable, switchMap } from "rxjs";
import { Observable, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
@@ -58,21 +58,11 @@ export class UserLayoutComponent implements OnInit {
);
this.showEmergencyAccess = toSignal(
combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
canAccessEmergencyAccess(userId, this.configService, this.policyService),
),
]).pipe(
map(([enabled, policyAppliesToUser]) => {
if (!enabled || !policyAppliesToUser) {
return true;
}
return false;
}),
),
);

View File

@@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { Route, RouterModule, Routes } from "@angular/router";
import { organizationPolicyGuard } from "@bitwarden/angular/admin-console/guards";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import { AuthRoute } from "@bitwarden/angular/auth/constants";
import {
@@ -56,7 +57,6 @@ import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/gua
import { flagEnabled, Flags } from "../utils/flags";
import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard";
import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component";
import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component";

View File

@@ -4,6 +4,7 @@ import { Injectable } from "@angular/core";
import * as papa from "papaparse";
import { EventView } from "@bitwarden/common/models/view/event.view";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { EventExport } from "./event.export";
@@ -16,25 +17,6 @@ export class EventExportService {
}
getFileName(prefix: string = null, extension = "csv"): string {
const now = new Date();
const dateString =
now.getFullYear() +
"" +
this.padNumber(now.getMonth() + 1, 2) +
"" +
this.padNumber(now.getDate(), 2) +
this.padNumber(now.getHours(), 2) +
"" +
this.padNumber(now.getMinutes(), 2) +
this.padNumber(now.getSeconds(), 2);
return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + extension;
}
private padNumber(num: number, width: number, padCharacter = "0"): string {
const numString = num.toString();
return numString.length >= width
? numString
: new Array(width - numString.length + 1).join(padCharacter) + numString;
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

View File

@@ -1,52 +1,14 @@
<form [formGroup]="formGroup" [bitSubmit]="load">
<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>
<ng-container *ngIf="!loading; else spinner">
<app-send-access-password
(setPasswordEvent)="setPassword($event)"
*ngIf="passwordRequired && !error"
></app-send-access-password>
<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="!passwordRequired && 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>
<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>
</ng-template>
</form>
@switch (viewState) {
@case ("auth") {
<app-send-auth [id]="id" [key]="key" (accessGranted)="onAccessGranted($event)"></app-send-auth>
}
@case ("view") {
<app-send-view
[id]="id"
[key]="key"
[sendResponse]="sendAccessResponse"
[accessRequest]="sendAccessRequest"
(authRequired)="onAuthRequired()"
></app-send-view>
}
}

View File

@@ -1,161 +1,60 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
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";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendAccess } from "@bitwarden/common/tools/send/models/domain/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";
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { SendAccessFileComponent } from "./send-access-file.component";
import { SendAccessPasswordComponent } from "./send-access-password.component";
import { SendAccessTextComponent } from "./send-access-text.component";
import { SendAuthComponent } from "./send-auth.component";
import { SendViewComponent } from "./send-view.component";
const SendViewState = Object.freeze({
View: "view",
Auth: "auth",
} 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: [
SendAccessFileComponent,
SendAccessTextComponent,
SendAccessPasswordComponent,
SharedModule,
],
imports: [SendAuthComponent, SendViewComponent, SharedModule],
})
export class AccessComponent implements OnInit {
protected send: SendAccessView;
protected sendType = SendType;
protected loading = true;
protected passwordRequired = false;
protected formPromise: Promise<SendAccessResponse>;
protected password: string;
protected unavailable = false;
protected error = false;
protected hideEmail = false;
protected decKey: SymmetricCryptoKey;
protected accessRequest: SendAccessRequest;
viewState: SendViewState = SendViewState.View;
id: string;
key: string;
protected formGroup = this.formBuilder.group({});
sendAccessResponse: SendAccessResponse | null = null;
sendAccessRequest: SendAccessRequest = new SendAccessRequest();
private id: string;
private key: string;
constructor(
private cryptoFunctionService: CryptoFunctionService,
private route: ActivatedRoute,
private keyService: KeyService,
private sendApiService: SendApiService,
private toastService: ToastService,
private i18nService: I18nService,
private layoutWrapperDataService: AnonLayoutWrapperDataService,
protected formBuilder: FormBuilder,
) {}
protected get expirationDate() {
if (this.send == null || this.send.expirationDate == null) {
return null;
}
return this.send.expirationDate;
}
protected get creatorIdentifier() {
if (this.send == null || this.send.creatorIdentifier == null) {
return null;
}
return this.send.creatorIdentifier;
}
constructor(private route: ActivatedRoute) {}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.params.subscribe(async (params) => {
this.id = params.sendId;
this.key = params.key;
if (this.key == null || this.id == null) {
return;
if (this.id && this.key) {
this.viewState = SendViewState.View;
this.sendAccessResponse = null;
this.sendAccessRequest = new SendAccessRequest();
}
await this.load();
});
}
protected load = async () => {
this.unavailable = false;
this.error = false;
this.hideEmail = false;
try {
const keyArray = Utils.fromUrlB64ToArray(this.key);
this.accessRequest = new SendAccessRequest();
if (this.password != null) {
const passwordHash = await this.cryptoFunctionService.pbkdf2(
this.password,
keyArray,
"sha256",
SEND_KDF_ITERATIONS,
);
this.accessRequest.password = Utils.fromBufferToB64(passwordHash);
}
let sendResponse: SendAccessResponse = null;
if (this.loading) {
sendResponse = await this.sendApiService.postSendAccess(this.id, this.accessRequest);
} else {
this.formPromise = this.sendApiService.postSendAccess(this.id, this.accessRequest);
sendResponse = await this.formPromise;
}
this.passwordRequired = false;
const sendAccess = new SendAccess(sendResponse);
this.decKey = await this.keyService.makeSendKey(keyArray);
this.send = await sendAccess.decrypt(this.decKey);
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 401) {
this.passwordRequired = true;
} else if (e.statusCode === 404) {
this.unavailable = true;
} else if (e.statusCode === 400) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
} else {
this.error = true;
}
} else {
this.error = true;
}
}
this.loading = false;
this.hideEmail =
this.creatorIdentifier == null &&
!this.passwordRequired &&
!this.loading &&
!this.unavailable;
onAuthRequired() {
this.viewState = SendViewState.Auth;
}
if (this.creatorIdentifier != null) {
this.layoutWrapperDataService.setAnonLayoutWrapperData({
pageSubtitle: {
key: "sendAccessCreatorIdentifier",
placeholders: [this.creatorIdentifier],
},
});
}
};
protected setPassword(password: string) {
this.password = password;
onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) {
this.sendAccessResponse = event.response;
this.sendAccessRequest = event.request;
this.viewState = SendViewState.View;
}
}

View File

@@ -0,0 +1,14 @@
<form (ngSubmit)="onSubmit(password)">
<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>
<app-send-access-password
*ngIf="!unavailable"
(setPasswordEvent)="password = $event"
[loading]="loading"
></app-send-access-password>
</form>

View File

@@ -0,0 +1,86 @@
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
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 { ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { SendAccessPasswordComponent } from "./send-access-password.component";
@Component({
selector: "app-send-auth",
templateUrl: "send-auth.component.html",
imports: [SendAccessPasswordComponent, SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendAuthComponent {
readonly id = input.required<string>();
readonly key = input.required<string>();
accessGranted = output<{
response: SendAccessResponse;
request: SendAccessRequest;
}>();
loading = false;
error = false;
unavailable = false;
password?: string;
private accessRequest!: SendAccessRequest;
constructor(
private cryptoFunctionService: CryptoFunctionService,
private sendApiService: SendApiService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
async onSubmit(password: string) {
this.password = password;
this.loading = true;
this.error = false;
this.unavailable = false;
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 });
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 404) {
this.unavailable = true;
} else if (e.statusCode === 400) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
} else {
this.error = true;
}
} else {
this.error = true;
}
} finally {
this.loading = false;
}
}
}

View File

@@ -0,0 +1,47 @@
<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>
<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>
<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>
</ng-template>

View File

@@ -0,0 +1,131 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
input,
OnInit,
output,
} from "@angular/core";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
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";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendAccess } from "@bitwarden/common/tools/send/models/domain/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";
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { SendAccessFileComponent } from "./send-access-file.component";
import { SendAccessTextComponent } from "./send-access-text.component";
@Component({
selector: "app-send-view",
templateUrl: "send-view.component.html",
imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendViewComponent implements OnInit {
readonly id = input.required<string>();
readonly key = input.required<string>();
readonly sendResponse = input<SendAccessResponse | null>(null);
readonly accessRequest = input<SendAccessRequest>(new SendAccessRequest());
authRequired = output<void>();
send: SendAccessView | null = null;
sendType = SendType;
loading = true;
unavailable = false;
error = false;
hideEmail = false;
decKey!: SymmetricCryptoKey;
constructor(
private keyService: KeyService,
private sendApiService: SendApiService,
private toastService: ToastService,
private i18nService: I18nService,
private layoutWrapperDataService: AnonLayoutWrapperDataService,
private cdRef: ChangeDetectorRef,
) {}
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();
}
private async load() {
this.unavailable = false;
this.error = false;
this.hideEmail = false;
this.loading = true;
let response = this.sendResponse();
try {
if (!response) {
response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest());
}
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);
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 401) {
this.authRequired.emit();
} else if (e.statusCode === 404) {
this.unavailable = true;
} else if (e.statusCode === 400) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
} else {
this.error = true;
}
} else {
this.error = true;
}
}
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) {
this.layoutWrapperDataService.setAnonLayoutWrapperData({
pageSubtitle: {
key: "sendAccessCreatorIdentifier",
placeholders: [this.creatorIdentifier],
},
});
}
this.cdRef.markForCheck();
}
}

View File

@@ -144,8 +144,9 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
}
}
// Archive button will not show in Admin Console
protected get showArchiveButton() {
if (!this.archiveEnabled()) {
if (!this.archiveEnabled() || this.viewingOrgVault) {
return false;
}

View File

@@ -26,7 +26,6 @@ import {
} from "rxjs/operators";
import {
AutomaticUserConfirmationService,
CollectionData,
CollectionDetailsResponse,
CollectionService,
@@ -42,6 +41,7 @@ import {
ItemTypes,
Icon,
} from "@bitwarden/assets/svg";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import {

View File

@@ -1749,6 +1749,9 @@
"noMembersInList": {
"message": "There are no members to list."
},
"noMembersToExport": {
"message": "There are no members to export."
},
"noEventsInList": {
"message": "There are no events to list."
},
@@ -2537,6 +2540,9 @@
"enabled": {
"message": "Turned on"
},
"optionEnabled": {
"message": "Enabled"
},
"restoreAccess": {
"message": "Restore access"
},
@@ -5649,6 +5655,9 @@
"revoked": {
"message": "Revoked"
},
"accepted": {
"message": "Accepted"
},
"sendLink": {
"message": "Send link",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -6307,6 +6316,12 @@
"enrolledAccountRecovery": {
"message": "Enrolled in account recovery"
},
"enrolled": {
"message": "Enrolled"
},
"notEnrolled": {
"message": "Not enrolled"
},
"withdrawAccountRecovery": {
"message": "Withdraw from account recovery"
},