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:
@@ -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 organization’s data security. "
|
||||
},
|
||||
"autoConfirmWarningLink": {
|
||||
"message": "Learn about the risks"
|
||||
},
|
||||
"accountSecurity": {
|
||||
"message": "Account security"
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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$))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
45
apps/desktop/desktop_native/autotype/src/modifier_keys.rs
Normal file
45
apps/desktop/desktop_native/autotype/src/modifier_keys.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,6 @@ pub fn get_foreground_window_title() -> Result<String> {
|
||||
/// - Control
|
||||
/// - Alt
|
||||
/// - Super
|
||||
/// - Shift
|
||||
/// - \[a-z\]\[A-Z\]
|
||||
struct KeyboardShortcutInput(INPUT);
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<div bitDialogContent>
|
||||
<p>
|
||||
{{ "editAutotypeShortcutDescription" | i18n }}
|
||||
{{ "editAutotypeKeyboardModifiersDescription" | i18n }}
|
||||
</p>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "typeShortcut" | i18n }}</bit-label>
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") } };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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$),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./members.module";
|
||||
export * from "./pipes";
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./user-status.pipe";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./member.export";
|
||||
export * from "./member-export.service";
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
131
apps/web/src/app/tools/send/send-access/send-view.component.ts
Normal file
131
apps/web/src/app/tools/send/send-access/send-view.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user