1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

Merge branch 'main' into pm-13345-Add-Remove-Bitwarden-Families-policy-in-Admin-Console

This commit is contained in:
Cy Okeke
2024-11-08 10:26:51 +01:00
156 changed files with 2330 additions and 1128 deletions

View File

@@ -38,18 +38,20 @@
<div class="box-content">
<div
class="environment-selector-dialog"
data-testid="environment-selector-dialog"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
>
<ng-container *ngFor="let region of availableRegions">
<ng-container *ngFor="let region of availableRegions; let i = index">
<button
type="button"
class="environment-selector-dialog-item"
(click)="toggle(region.key)"
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
[attr.data-testid]="'environment-selector-dialog-item-' + i"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
@@ -66,6 +68,7 @@
class="environment-selector-dialog-item"
(click)="toggle(ServerEnvironmentType.SelfHosted)"
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
data-testid="environment-selector-dialog-item-self-hosted"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"

View File

@@ -178,6 +178,7 @@ import { BulkEncryptServiceImplementation } from "@bitwarden/common/platform/ser
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { DefaultBroadcasterService } from "@bitwarden/common/platform/services/default-broadcaster.service";
import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service";
import { DefaultServerSettingsService } from "@bitwarden/common/platform/services/default-server-settings.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
@@ -1322,6 +1323,11 @@ const safeProviders: SafeProvider[] = [
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
safeProvider({
provide: DefaultServerSettingsService,
useClass: DefaultServerSettingsService,
deps: [ConfigService],
}),
safeProvider({
provide: RegisterRouteService,
useClass: RegisterRouteService,

View File

@@ -699,7 +699,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
protected deleteCipher() {
const asAdmin = this.organization?.canEditAllCiphers;
const asAdmin = this.organization?.canEditAllCiphers || !this.cipher.collectionIds;
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, asAdmin)
: this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin);

View File

@@ -4,13 +4,14 @@ import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { RegisterRouteService } from "@bitwarden/auth/common";
import { DefaultServerSettingsService } from "@bitwarden/common/platform/services/default-server-settings.service";
import { LinkModule } from "@bitwarden/components";
@Component({
standalone: true,
imports: [CommonModule, JslibModule, LinkModule, RouterModule],
template: `
<div class="tw-text-center">
<div class="tw-text-center" *ngIf="!(isUserRegistrationDisabled$ | async)">
{{ "newToBitwarden" | i18n }}
<a bitLink [routerLink]="registerRoute$ | async">{{ "createAccount" | i18n }}</a>
</div>
@@ -18,7 +19,10 @@ import { LinkModule } from "@bitwarden/components";
})
export class LoginSecondaryContentComponent {
registerRouteService = inject(RegisterRouteService);
serverSettingsService = inject(DefaultServerSettingsService);
// TODO: remove when email verification flag is removed
protected registerRoute$ = this.registerRouteService.registerRoute$();
protected isUserRegistrationDisabled$ = this.serverSettingsService.isUserRegistrationDisabled$;
}

View File

@@ -11,7 +11,7 @@
-->
<form [bitSubmit]="submit" [formGroup]="formGroup">
<ng-container *ngIf="loginUiState === LoginUiState.EMAIL_ENTRY">
<div [ngClass]="{ 'tw-invisible tw-h-0': loginUiState !== LoginUiState.EMAIL_ENTRY }">
<!-- Email Address input -->
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
@@ -82,9 +82,9 @@
</button>
</ng-container>
</div>
</ng-container>
</div>
<ng-container *ngIf="loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY">
<div [ngClass]="{ 'tw-invisible tw-h-0': loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY }">
<!-- Master Password input -->
<bit-form-field class="!tw-mb-1">
<bit-label>{{ "masterPass" | i18n }}</bit-label>
@@ -140,5 +140,5 @@
</button>
</ng-container>
</div>
</ng-container>
</div>
</form>

View File

@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, ElementRef, Input, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom, Subject, take, takeUntil } from "rxjs";
import { firstValueFrom, Subject, take, takeUntil, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -19,9 +19,11 @@ import { CaptchaIFrame } from "@bitwarden/common/auth/captcha-iframe";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -139,12 +141,16 @@ export class LoginComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private logService: LogService,
private validationService: ValidationService,
private configService: ConfigService,
) {
this.clientType = this.platformUtilsService.getClientType();
this.loginViaAuthRequestSupported = this.loginComponentService.isLoginViaAuthRequestSupported();
}
async ngOnInit(): Promise<void> {
// TODO: remove this when the UnauthenticatedExtensionUIRefresh feature flag is removed.
this.listenForUnauthUiRefreshFlagChanges();
await this.defaultOnInit();
if (this.clientType === ClientType.Desktop) {
@@ -162,6 +168,29 @@ export class LoginComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private listenForUnauthUiRefreshFlagChanges() {
this.configService
.getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh)
.pipe(
tap(async (flag) => {
// If the flag is turned OFF, we must force a reload to ensure the correct UI is shown
if (!flag) {
const uniqueQueryParams = {
...this.activatedRoute.queryParams,
// adding a unique timestamp to the query params to force a reload
t: new Date().getTime().toString(), // Adding a unique timestamp as a query parameter
};
await this.router.navigate(["/"], {
queryParams: uniqueQueryParams,
});
}
}),
takeUntil(this.destroy$),
)
.subscribe();
}
submit = async (): Promise<void> => {
if (this.clientType === ClientType.Desktop) {
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {

View File

@@ -1,7 +1,7 @@
<form [formGroup]="formGroup" *ngIf="!hideEnvSelector">
<bit-form-field>
<bit-label>{{ "creatingAccountOn" | i18n }}</bit-label>
<bit-select formControlName="selectedRegion">
<bit-select formControlName="selectedRegion" (closed)="onSelectClosed()">
<bit-option
*ngFor="let regionConfig of availableRegionConfigs"
[value]="regionConfig"

View File

@@ -109,6 +109,9 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
.subscribe();
}
/**
* Listens for changes to the selected region and updates the form value and emits the selected region.
*/
private listenForSelectedRegionChanges() {
this.selectedRegion.valueChanges
.pipe(
@@ -124,16 +127,12 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
return of(null);
}
if (selectedRegion === Region.SelfHosted) {
return from(SelfHostedEnvConfigDialogComponent.open(this.dialogService)).pipe(
tap((result: boolean | undefined) =>
this.handleSelfHostedEnvConfigDialogResult(result, prevSelectedRegion),
),
);
if (selectedRegion !== Region.SelfHosted) {
this.selectedRegionChange.emit(selectedRegion);
return from(this.environmentService.setEnvironment(selectedRegion.key));
}
this.selectedRegionChange.emit(selectedRegion);
return from(this.environmentService.setEnvironment(selectedRegion.key));
return of(null);
},
),
takeUntil(this.destroy$),
@@ -170,6 +169,17 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
}
}
/**
* Handles the event when the select is closed.
* If the selected region is self-hosted, opens the self-hosted environment settings dialog.
*/
protected async onSelectClosed() {
if (this.selectedRegion.value === Region.SelfHosted) {
const result = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
return this.handleSelfHostedEnvConfigDialogResult(result, this.selectedRegion.value);
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();

View File

@@ -17,7 +17,6 @@ export enum FeatureFlag {
InlineMenuFieldQualification = "inline-menu-field-qualification",
MemberAccessReport = "ac-2059-member-access-report",
TwoFactorComponentRefactor = "two-factor-component-refactor",
EnableTimeThreshold = "PM-5864-dollar-threshold",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
VaultBulkManagementAction = "vault-bulk-management-action",
@@ -36,6 +35,7 @@ export enum FeatureFlag {
AccessIntelligence = "pm-13227-access-intelligence",
Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions",
LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split",
CriticalApps = "pm-14466-risk-insights-critical-application",
DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship",
}
@@ -64,7 +64,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
[FeatureFlag.MemberAccessReport]: FALSE,
[FeatureFlag.TwoFactorComponentRefactor]: FALSE,
[FeatureFlag.EnableTimeThreshold]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
@@ -83,6 +82,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AccessIntelligence]: FALSE,
[FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE,
[FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;

View File

@@ -3,6 +3,7 @@ import { SemVer } from "semver";
import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { ServerSettings } from "../../models/domain/server-settings";
import { Region } from "../environment.service";
import { ServerConfig } from "./server-config";
@@ -10,6 +11,8 @@ import { ServerConfig } from "./server-config";
export abstract class ConfigService {
/** The server config of the currently active user */
serverConfig$: Observable<ServerConfig | null>;
/** The server settings of the currently active user */
serverSettings$: Observable<ServerSettings | null>;
/** The cloud region of the currently active user */
cloudRegion$: Observable<Region>;
/**

View File

@@ -6,6 +6,7 @@ import {
ThirdPartyServerConfigData,
EnvironmentServerConfigData,
} from "../../models/data/server-config.data";
import { ServerSettings } from "../../models/domain/server-settings";
const dayInMilliseconds = 24 * 3600 * 1000;
@@ -16,6 +17,7 @@ export class ServerConfig {
environment?: EnvironmentServerConfigData;
utcDate: Date;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
settings: ServerSettings;
constructor(serverConfigData: ServerConfigData) {
this.version = serverConfigData.version;
@@ -24,6 +26,7 @@ export class ServerConfig {
this.utcDate = new Date(serverConfigData.utcDate);
this.environment = serverConfigData.environment;
this.featureStates = serverConfigData.featureStates;
this.settings = serverConfigData.settings;
if (this.server?.name == null && this.server?.url == null) {
this.server = null;

View File

@@ -3,6 +3,7 @@
export type SharedFlags = {
showPasswordless?: boolean;
sdk?: boolean;
prereleaseBuild?: boolean;
};
// required to avoid linting errors when there are no flags

View File

@@ -16,6 +16,9 @@ describe("ServerConfigData", () => {
name: "test",
url: "https://test.com",
},
settings: {
disableUserRegistration: false,
},
environment: {
cloudRegion: Region.EU,
vault: "https://vault.com",

View File

@@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { Region } from "../../abstractions/environment.service";
import { ServerSettings } from "../domain/server-settings";
import {
ServerConfigResponse,
ThirdPartyServerConfigResponse,
@@ -15,6 +16,7 @@ export class ServerConfigData {
environment?: EnvironmentServerConfigData;
utcDate: string;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
settings: ServerSettings;
constructor(serverConfigResponse: Partial<ServerConfigResponse>) {
this.version = serverConfigResponse?.version;
@@ -27,6 +29,7 @@ export class ServerConfigData {
? new EnvironmentServerConfigData(serverConfigResponse.environment)
: null;
this.featureStates = serverConfigResponse?.featureStates;
this.settings = new ServerSettings(serverConfigResponse.settings);
}
static fromJSON(obj: Jsonify<ServerConfigData>): ServerConfigData {

View File

@@ -0,0 +1,20 @@
import { ServerSettings } from "./server-settings";
describe("ServerSettings", () => {
describe("disableUserRegistration", () => {
it("defaults disableUserRegistration to false", () => {
const settings = new ServerSettings();
expect(settings.disableUserRegistration).toBe(false);
});
it("sets disableUserRegistration to true when provided", () => {
const settings = new ServerSettings({ disableUserRegistration: true });
expect(settings.disableUserRegistration).toBe(true);
});
it("sets disableUserRegistration to false when provided", () => {
const settings = new ServerSettings({ disableUserRegistration: false });
expect(settings.disableUserRegistration).toBe(false);
});
});
});

View File

@@ -0,0 +1,7 @@
export class ServerSettings {
disableUserRegistration: boolean;
constructor(data?: ServerSettings) {
this.disableUserRegistration = data?.disableUserRegistration ?? false;
}
}

View File

@@ -1,6 +1,7 @@
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { BaseResponse } from "../../../models/response/base.response";
import { Region } from "../../abstractions/environment.service";
import { ServerSettings } from "../domain/server-settings";
export class ServerConfigResponse extends BaseResponse {
version: string;
@@ -8,6 +9,7 @@ export class ServerConfigResponse extends BaseResponse {
server: ThirdPartyServerConfigResponse;
environment: EnvironmentServerConfigResponse;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
settings: ServerSettings;
constructor(response: any) {
super(response);
@@ -21,6 +23,7 @@ export class ServerConfigResponse extends BaseResponse {
this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server"));
this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment"));
this.featureStates = this.getResponseProperty("FeatureStates");
this.settings = new ServerSettings(this.getResponseProperty("Settings"));
}
}

View File

@@ -28,6 +28,7 @@ import { Environment, EnvironmentService, Region } from "../../abstractions/envi
import { LogService } from "../../abstractions/log.service";
import { devFlagEnabled, devFlagValue } from "../../misc/flags";
import { ServerConfigData } from "../../models/data/server-config.data";
import { ServerSettings } from "../../models/domain/server-settings";
import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state";
export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs")
@@ -57,6 +58,8 @@ export class DefaultConfigService implements ConfigService {
serverConfig$: Observable<ServerConfig>;
serverSettings$: Observable<ServerSettings>;
cloudRegion$: Observable<Region>;
constructor(
@@ -111,6 +114,10 @@ export class DefaultConfigService implements ConfigService {
this.cloudRegion$ = this.serverConfig$.pipe(
map((config) => config?.environment?.cloudRegion ?? Region.US),
);
this.serverSettings$ = this.serverConfig$.pipe(
map((config) => config?.settings ?? new ServerSettings()),
);
}
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {

View File

@@ -0,0 +1,47 @@
import { of } from "rxjs";
import { ConfigService } from "../abstractions/config/config.service";
import { ServerSettings } from "../models/domain/server-settings";
import { DefaultServerSettingsService } from "./default-server-settings.service";
describe("DefaultServerSettingsService", () => {
let service: DefaultServerSettingsService;
let configServiceMock: { serverSettings$: any };
beforeEach(() => {
configServiceMock = { serverSettings$: of() };
service = new DefaultServerSettingsService(configServiceMock as ConfigService);
});
describe("getSettings$", () => {
it("returns server settings", () => {
const mockSettings = new ServerSettings({ disableUserRegistration: true });
configServiceMock.serverSettings$ = of(mockSettings);
service.getSettings$().subscribe((settings) => {
expect(settings).toEqual(mockSettings);
});
});
});
describe("isUserRegistrationDisabled$", () => {
it("returns true when user registration is disabled", () => {
const mockSettings = new ServerSettings({ disableUserRegistration: true });
configServiceMock.serverSettings$ = of(mockSettings);
service.isUserRegistrationDisabled$.subscribe((isDisabled: boolean) => {
expect(isDisabled).toBe(true);
});
});
it("returns false when user registration is enabled", () => {
const mockSettings = new ServerSettings({ disableUserRegistration: false });
configServiceMock.serverSettings$ = of(mockSettings);
service.isUserRegistrationDisabled$.subscribe((isDisabled: boolean) => {
expect(isDisabled).toBe(false);
});
});
});
});

View File

@@ -0,0 +1,19 @@
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { ConfigService } from "../abstractions/config/config.service";
import { ServerSettings } from "../models/domain/server-settings";
export class DefaultServerSettingsService {
constructor(private configService: ConfigService) {}
getSettings$(): Observable<ServerSettings> {
return this.configService.serverSettings$;
}
get isUserRegistrationDisabled$(): Observable<boolean> {
return this.getSettings$().pipe(
map((settings: ServerSettings) => settings.disableUserRegistration),
);
}
}

View File

@@ -126,6 +126,7 @@ import { AppIdService } from "../platform/abstractions/app-id.service";
import { EnvironmentService } from "../platform/abstractions/environment.service";
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import { flagEnabled } from "../platform/misc/flags";
import { Utils } from "../platform/misc/utils";
import { SyncResponse } from "../platform/sync";
import { UserId } from "../types/guid";
@@ -583,7 +584,7 @@ export class ApiService implements ApiServiceAbstraction {
}
putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any> {
return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, false);
return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, true);
}
postPurgeCiphers(
@@ -1843,44 +1844,20 @@ export class ApiService implements ApiServiceAbstraction {
const requestUrl =
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
const headers = new Headers({
"Device-Type": this.deviceType,
});
if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent);
}
const [requestHeaders, requestBody] = await this.buildHeadersAndBody(
authed,
hasResponse,
body,
alterHeaders,
);
const requestInit: RequestInit = {
cache: "no-store",
credentials: await this.getCredentials(),
method: method,
};
if (authed) {
const authHeader = await this.getActiveBearerToken();
headers.set("Authorization", "Bearer " + authHeader);
}
if (body != null) {
if (typeof body === "string") {
requestInit.body = body;
headers.set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8");
} else if (typeof body === "object") {
if (body instanceof FormData) {
requestInit.body = body;
} else {
headers.set("Content-Type", "application/json; charset=utf-8");
requestInit.body = JSON.stringify(body);
}
}
}
if (hasResponse) {
headers.set("Accept", "application/json");
}
if (alterHeaders != null) {
alterHeaders(headers);
}
requestInit.headers = headers;
requestInit.headers = requestHeaders;
requestInit.body = requestBody;
const response = await this.fetch(new Request(requestUrl, requestInit));
const responseType = response.headers.get("content-type");
@@ -1897,6 +1874,51 @@ export class ApiService implements ApiServiceAbstraction {
}
}
private async buildHeadersAndBody(
authed: boolean,
hasResponse: boolean,
body: any,
alterHeaders: (headers: Headers) => void,
): Promise<[Headers, any]> {
let requestBody: any = null;
const headers = new Headers({
"Device-Type": this.deviceType,
});
if (flagEnabled("prereleaseBuild")) {
headers.set("Is-Prerelease", "1");
}
if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent);
}
if (hasResponse) {
headers.set("Accept", "application/json");
}
if (alterHeaders != null) {
alterHeaders(headers);
}
if (authed) {
const authHeader = await this.getActiveBearerToken();
headers.set("Authorization", "Bearer " + authHeader);
}
if (body != null) {
if (typeof body === "string") {
requestBody = body;
headers.set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8");
} else if (typeof body === "object") {
if (body instanceof FormData) {
requestBody = body;
} else {
headers.set("Content-Type", "application/json; charset=utf-8");
requestBody = JSON.stringify(body);
}
}
}
return [headers, requestBody];
}
private async handleError(
response: Response,
tokenError: boolean,

View File

@@ -22,6 +22,7 @@ export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>>
classifier: Classifier<State, Disclosed, Secret>;
format: "plain" | "classified";
options: UserKeyDefinitionOptions<State>;
initial?: State;
};
export function isObjectKey(key: any): key is ObjectKey<unknown> {

View File

@@ -254,17 +254,18 @@ export class UserStateSubject<
withConstraints,
map(([loadedState, constraints]) => {
// bypass nulls
if (!loadedState) {
if (!loadedState && !this.objectKey?.initial) {
return {
constraints: {} as Constraints<State>,
state: null,
} satisfies Constrained<State>;
}
const unconstrained = loadedState ?? structuredClone(this.objectKey.initial);
const calibration = isDynamic(constraints)
? constraints.calibrate(loadedState)
? constraints.calibrate(unconstrained)
: constraints;
const adjusted = calibration.adjust(loadedState);
const adjusted = calibration.adjust(unconstrained);
return {
constraints: calibration.constraints,

View File

@@ -119,7 +119,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* Used for Unassigned ciphers or when the user only has admin access to the cipher (not assigned normally).
* @param cipher
*/
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<void>;
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<Cipher>;
/**
* Bulk update collections for many ciphers with the server
* @param orgId

View File

@@ -880,9 +880,11 @@ export class CipherService implements CipherServiceAbstraction {
return new Cipher(updated[cipher.id as CipherId], cipher.localData);
}
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<void> {
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<Cipher> {
const request = new CipherCollectionsRequest(cipher.collectionIds);
await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
const data = new CipherData(response);
return new Cipher(data);
}
/**

View File

@@ -0,0 +1,27 @@
import { Directive, HostBinding, HostListener, Input } from "@angular/core";
import { DisclosureComponent } from "./disclosure.component";
@Directive({
selector: "[bitDisclosureTriggerFor]",
exportAs: "disclosureTriggerFor",
standalone: true,
})
export class DisclosureTriggerForDirective {
/**
* Accepts template reference for a bit-disclosure component instance
*/
@Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent;
@HostBinding("attr.aria-expanded") get ariaExpanded() {
return this.disclosure.open;
}
@HostBinding("attr.aria-controls") get ariaControls() {
return this.disclosure.id;
}
@HostListener("click") click() {
this.disclosure.open = !this.disclosure.open;
}
}

View File

@@ -0,0 +1,21 @@
import { Component, HostBinding, Input, booleanAttribute } from "@angular/core";
let nextId = 0;
@Component({
selector: "bit-disclosure",
standalone: true,
template: `<ng-content></ng-content>`,
})
export class DisclosureComponent {
/**
* Optionally init the disclosure in its opened state
*/
@Input({ transform: booleanAttribute }) open?: boolean = false;
@HostBinding("class") get classList() {
return this.open ? "" : "tw-hidden";
}
@HostBinding("id") id = `bit-disclosure-${nextId++}`;
}

View File

@@ -0,0 +1,55 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./disclosure.stories";
<Meta of={stories} />
```ts
import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components";
```
# Disclosure
The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to
create an accessible content area whose visibility is controlled by a trigger button.
To compose a disclosure and trigger:
1. Create a trigger component (see "Supported Trigger Components" section below)
2. Create a `bit-disclosure`
3. Set a template reference on the `bit-disclosure`
4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the
`bit-disclosure` template reference
5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently
expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to
being hidden.
```
<button
type="button"
bitIconButton="bwi-sliders"
[buttonType]="'muted'"
[bitDisclosureTriggerFor]="disclosureRef"
></button>
<bit-disclosure #disclosureRef open>click button to hide this content</bit-disclosure>
```
<Story of={stories.DisclosureWithIconButton} />
<br />
<br />
## Supported Trigger Components
This is the list of currently supported trigger components:
- Icon button `muted` variant
## Accessibility
The disclosure and trigger directive functionality follow the
[Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern for
accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button`
element must be used as the trigger for the disclosure. The `button` element must also have an
accessible label/title -- please follow the accessibility guidelines for whatever trigger component
you choose.

View File

@@ -0,0 +1,29 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { IconButtonModule } from "../icon-button";
import { DisclosureTriggerForDirective } from "./disclosure-trigger-for.directive";
import { DisclosureComponent } from "./disclosure.component";
export default {
title: "Component Library/Disclosure",
component: DisclosureComponent,
decorators: [
moduleMetadata({
imports: [DisclosureTriggerForDirective, DisclosureComponent, IconButtonModule],
}),
],
} as Meta<DisclosureComponent>;
type Story = StoryObj<DisclosureComponent>;
export const DisclosureWithIconButton: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitIconButton="bwi-sliders" [buttonType]="'muted'" [bitDisclosureTriggerFor]="disclosureRef">
</button>
<bit-disclosure #disclosureRef class="tw-text-main tw-block" open>click button to hide this content</bit-disclosure>
`,
}),
};

View File

@@ -0,0 +1,2 @@
export * from "./disclosure-trigger-for.directive";
export * from "./disclosure.component";

View File

@@ -52,10 +52,14 @@ const styles: Record<IconButtonType, string[]> = {
"tw-bg-transparent",
"!tw-text-muted",
"tw-border-transparent",
"aria-expanded:tw-bg-text-muted",
"aria-expanded:!tw-text-contrast",
"hover:tw-bg-transparent-hover",
"hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:tw-opacity-60",
"aria-expanded:hover:tw-bg-secondary-700",
"aria-expanded:hover:tw-border-secondary-700",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
...focusRing,

View File

@@ -29,8 +29,6 @@ Icon buttons can be found in other components such as: the
[dialog](?path=/docs/component-library-dialogs--docs), and
[table](?path=/docs/component-library-table--docs).
<Story id="component-library-banner--premium" />
## Styles
There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the
@@ -40,48 +38,48 @@ button component styles.
Used for general icon buttons appearing on the themes main `background`
<Story id="component-library-icon-button--main" />
<Story of={stories.Main} />
### Muted
Used for low emphasis icon buttons appearing on the themes main `background`
<Story id="component-library-icon-button--muted" />
<Story of={stories.Muted} />
### Contrast
Used on a themes colored or contrasting backgrounds such as in the navigation or on toasts and
banners.
<Story id="component-library-icon-button--contrast" />
<Story of={stories.Contrast} />
### Danger
Danger is used for “trash” actions throughout the experience, most commonly in the bottom right of
the dialog component.
<Story id="component-library-icon-button--danger" />
<Story of={stories.Danger} />
### Primary
Used in place of the main button component if no text is used. This allows the button to display
square.
<Story id="component-library-icon-button--primary" />
<Story of={stories.Primary} />
### Secondary
Used in place of the main button component if no text is used. This allows the button to display
square.
<Story id="component-library-icon-button--secondary" />
<Story of={stories.Secondary} />
### Light
Used on a background that is dark in both light theme and dark theme. Example: end user navigation
styles.
<Story id="component-library-icon-button--light" />
<Story of={stories.Light} />
**Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus
indicator does not meet WCAG graphic contrast guidelines.
@@ -95,11 +93,11 @@ with less padding around the icon, such as in the navigation component.
### Small
<Story id="component-library-icon-button--small" />
<Story of={stories.Small} />
### Default
<Story id="component-library-icon-button--default" />
<Story of={stories.Default} />
## Accessibility

View File

@@ -23,7 +23,7 @@ type Story = StoryObj<BitIconButtonComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: `
template: /*html*/ `
<div class="tw-space-x-4">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="main" [size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="muted" [size]="size">Button</button>
@@ -56,7 +56,7 @@ export const Small: Story = {
export const Primary: Story = {
render: (args) => ({
props: args,
template: `
template: /*html*/ `
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
`,
}),
@@ -96,7 +96,7 @@ export const Muted: Story = {
export const Light: Story = {
render: (args) => ({
props: args,
template: `
template: /*html*/ `
<div class="tw-bg-background-alt2 tw-p-6 tw-w-full tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
</div>
@@ -110,7 +110,7 @@ export const Light: Story = {
export const Contrast: Story = {
render: (args) => ({
props: args,
template: `
template: /*html*/ `
<div class="tw-bg-primary-600 tw-p-6 tw-w-full tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
</div>

View File

@@ -13,6 +13,7 @@ export * from "./chip-select";
export * from "./color-password";
export * from "./container";
export * from "./dialog";
export * from "./disclosure";
export * from "./form-field";
export * from "./icon-button";
export * from "./icon";

View File

@@ -7,6 +7,7 @@
(blur)="onBlur()"
[labelForId]="labelForId"
[clearable]="false"
(close)="onClose()"
appendTo="body"
>
<ng-template ng-option-tmp let-item="item">

View File

@@ -7,6 +7,8 @@ import {
QueryList,
Self,
ViewChild,
Output,
EventEmitter,
} from "@angular/core";
import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
import { NgSelectComponent } from "@ng-select/ng-select";
@@ -31,6 +33,7 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
/** Optional: Options can be provided using an array input or using `bit-option` */
@Input() items: Option<T>[] = [];
@Input() placeholder = this.i18nService.t("selectPlaceholder");
@Output() closed = new EventEmitter();
protected selectedValue: T;
protected selectedOption: Option<T>;
@@ -156,4 +159,9 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
private findSelectedOption(items: Option<T>[], value: T): Option<T> | undefined {
return items.find((item) => item.value === value);
}
/**Emits the closed event. */
protected onClose() {
this.closed.emit();
}
}

View File

@@ -1,7 +1,7 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, HostBinding, Input, OnInit } from "@angular/core";
import type { SortFn } from "./table-data-source";
import type { SortDirection, SortFn } from "./table-data-source";
import { TableComponent } from "./table.component";
@Component({
@@ -19,12 +19,16 @@ export class SortableComponent implements OnInit {
*/
@Input() bitSortable: string;
private _default: boolean;
private _default: SortDirection | boolean = false;
/**
* Mark the column as the default sort column
*/
@Input() set default(value: boolean | "") {
this._default = coerceBooleanProperty(value);
@Input() set default(value: SortDirection | boolean | "") {
if (value === "desc" || value === "asc") {
this._default = value;
} else {
this._default = coerceBooleanProperty(value) ? "asc" : false;
}
}
/**
@@ -32,6 +36,11 @@ export class SortableComponent implements OnInit {
*
* @example
* fn = (a, b) => a.name.localeCompare(b.name)
*
* fn = (a, b, direction) => {
* const result = a.name.localeCompare(b.name)
* return direction === 'asc' ? result : -result;
* }
*/
@Input() fn: SortFn;
@@ -52,8 +61,18 @@ export class SortableComponent implements OnInit {
protected setActive() {
if (this.table.dataSource) {
const direction = this.isActive && this.direction === "asc" ? "desc" : "asc";
this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn };
const defaultDirection = this._default === "desc" ? "desc" : "asc";
const direction = this.isActive
? this.direction === "asc"
? "desc"
: "asc"
: defaultDirection;
this.table.dataSource.sort = {
column: this.bitSortable,
direction: direction,
fn: this.fn,
};
}
}

View File

@@ -3,7 +3,7 @@ import { DataSource } from "@angular/cdk/collections";
import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs";
export type SortDirection = "asc" | "desc";
export type SortFn = (a: any, b: any) => number;
export type SortFn = (a: any, b: any, direction?: SortDirection) => number;
export type Sort = {
column?: string;
direction: SortDirection;
@@ -166,7 +166,7 @@ export class TableDataSource<T> extends DataSource<T> {
return data.sort((a, b) => {
// If a custom sort function is provided, use it instead of the default.
if (sort.fn) {
return sort.fn(a, b) * directionModifier;
return sort.fn(a, b, sort.direction) * directionModifier;
}
let valueA = this.sortingDataAccessor(a, column);

View File

@@ -105,7 +105,7 @@ within the `ng-template`which provides access to the rows using `let-rows$`.
We provide a simple component for displaying sortable column headers. The `bitSortable` component
wires up to the `TableDataSource` and will automatically sort the data when clicked and display an
indicator for which column is currently sorted. The dafault sorting can be specified by setting the
indicator for which column is currently sorted. The default sorting can be specified by setting the
`default`.
```html
@@ -113,10 +113,23 @@ indicator for which column is currently sorted. The dafault sorting can be speci
<th bitCell bitSortable="name" default>Name</th>
```
For default sorting in descending order, set default="desc"
```html
<th bitCell bitSortable="name" default="desc">Name</th>
```
It's also possible to define a custom sorting function by setting the `fn` input.
```ts
// Basic sort function
const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1);
// Direction aware sort function
const sortByName = (a: T, b: T, direction?: SortDirection) => {
const result = a.name.localeCompare(b.name);
return direction === "asc" ? result : -result;
};
```
### Filtering

View File

@@ -1,6 +1,11 @@
<form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field>
<bit-label>{{ "domainName" | i18n }}</bit-label>
<input bitInput formControlName="catchallDomain" type="text" />
<input
bitInput
formControlName="catchallDomain"
type="text"
(change)="save('catchallDomain')"
/>
</bit-form-field>
</form>

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs";
import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -60,7 +60,19 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy {
// the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings);
// now that outputs are set up, connect inputs
this.saveSettings
.pipe(
withLatestFrom(this.settings.valueChanges),
map(([, settings]) => settings),
takeUntil(this.destroyed$),
)
.subscribe(settings);
}
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
}
private singleUserId$() {
@@ -78,6 +90,7 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}

View File

@@ -22,6 +22,7 @@
buttonType="main"
(click)="generate('user request')"
[appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)"
>
{{ credentialTypeGenerateLabel$ | async }}
</button>
@@ -33,6 +34,7 @@
[appA11yTitle]="credentialTypeCopyLabel$ | async"
[appCopyClick]="value$ | async"
[valueLabel]="credentialTypeLabel$ | async"
[disabled]="!(algorithm$ | async)"
></button>
</div>
</bit-card>

View File

@@ -202,9 +202,8 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
});
});
// normalize cascade selections; introduce subjects to allow changes
// from user selections and changes from preference updates to
// update the template
// these subjects normalize cascade selections to ensure the current
// cascade is always well-known.
type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm };
const activeRoot$ = new Subject<CascadeValue>();
const activeIdentifier$ = new Subject<CascadeValue>();
@@ -385,7 +384,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
if (!a || a.onlyOnRequest) {
this.value$.next("-");
} else {
this.generate("autogenerate");
this.generate("autogenerate").catch((e: unknown) => this.logService.error(e));
}
});
});
@@ -495,7 +494,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
* @param requestor a label used to trace generation request
* origin in the debugger.
*/
protected generate(requestor: string) {
protected async generate(requestor: string) {
this.generate$.next(requestor);
}
@@ -510,6 +509,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
private readonly destroyed = new Subject<void>();
ngOnDestroy() {
this.destroyed.next();
this.destroyed.complete();
// finalize subjects

View File

@@ -1,16 +1,28 @@
<form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field *ngIf="displayDomain">
<bit-label>{{ "forwarderDomainName" | i18n }}</bit-label>
<input bitInput formControlName="domain" type="text" placeholder="example.com" />
<input
bitInput
formControlName="domain"
type="text"
placeholder="example.com"
(change)="save('domain')"
/>
<bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field *ngIf="displayToken">
<bit-label>{{ "apiKey" | i18n }}</bit-label>
<input bitInput formControlName="token" type="password" />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
(change)="save('token')"
></button>
</bit-form-field>
<bit-form-field *ngIf="displayBaseUrl" disableMargin>
<bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label>
<input bitInput formControlName="baseUrl" type="text" />
<input bitInput formControlName="baseUrl" type="text" (change)="save('baseUrl')" />
</bit-form-field>
</form>

View File

@@ -17,7 +17,6 @@ import {
skip,
Subject,
switchAll,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
@@ -33,7 +32,7 @@ import {
toCredentialGeneratorConfiguration,
} from "@bitwarden/generator-core";
import { completeOnAccountSwitch, toValidators } from "./util";
import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({
domain: "domain",
@@ -117,35 +116,17 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
this.settings.patchValue(settings as any, { emitEvent: false });
});
// bind policy to the reactive form
forwarder$
.pipe(
switchMap((forwarder) => {
const constraints$ = this.generatorService
.policy$(forwarder, { userId$: singleUserId$ })
.pipe(map(({ constraints }) => [constraints, forwarder] as const));
return constraints$;
}),
takeUntil(this.destroyed$),
)
.subscribe(([constraints, forwarder]) => {
for (const name in Controls) {
const control = this.settings.get(name);
if (forwarder.request.includes(name as any)) {
control.enable({ emitEvent: false });
control.setValidators(
// the configuration's type erasure affects `toValidators` as well
toValidators(name, forwarder, constraints),
);
} else {
control.disable({ emitEvent: false });
control.clearValidators();
}
// enable requested forwarder inputs
forwarder$.pipe(takeUntil(this.destroyed$)).subscribe((forwarder) => {
for (const name in Controls) {
const control = this.settings.get(name);
if (forwarder.request.includes(name as any)) {
control.enable({ emitEvent: false });
} else {
control.disable({ emitEvent: false });
}
this.settings.updateValueAndValidity({ emitEvent: false });
});
}
});
// the first emission is the current value; subsequent emissions are updates
settings$$
@@ -157,13 +138,18 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
.subscribe(this.onUpdated);
// now that outputs are set up, connect inputs
this.settings.valueChanges
.pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$))
.subscribe(([value, settings]) => {
this.saveSettings
.pipe(withLatestFrom(this.settings.valueChanges, settings$$), takeUntil(this.destroyed$))
.subscribe(([, value, settings]) => {
settings.next(value);
});
}
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
}
ngOnChanges(changes: SimpleChanges): void {
this.refresh$.complete();
if ("forwarder" in changes) {
@@ -192,6 +178,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}

View File

@@ -7,6 +7,7 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -79,6 +80,7 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
I18nService,
EncryptService,
KeyService,
AccountService,
],
}),
],

View File

@@ -7,7 +7,13 @@
<bit-card>
<bit-form-field disableMargin>
<bit-label>{{ "numWords" | i18n }}</bit-label>
<input bitInput formControlName="numWords" id="num-words" type="number" />
<input
bitInput
formControlName="numWords"
id="num-words"
type="number"
(change)="save('numWords')"
/>
<bit-hint>{{ numWordsBoundariesHint$ | async }}</bit-hint>
</bit-form-field>
</bit-card>
@@ -16,14 +22,33 @@
<bit-card>
<bit-form-field>
<bit-label>{{ "wordSeparator" | i18n }}</bit-label>
<input bitInput formControlName="wordSeparator" id="word-separator" type="text" />
<input
bitInput
formControlName="wordSeparator"
id="word-separator"
type="text"
[maxlength]="wordSeparatorMaxLength"
(change)="save('wordSeparator')"
/>
</bit-form-field>
<bit-form-control>
<input bitCheckbox formControlName="capitalize" id="capitalize" type="checkbox" />
<input
bitCheckbox
formControlName="capitalize"
id="capitalize"
type="checkbox"
(change)="save('capitalize')"
/>
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control [disableMargin]="!policyInEffect">
<input bitCheckbox formControlName="includeNumber" id="include-number" type="checkbox" />
<input
bitCheckbox
formControlName="includeNumber"
id="include-number"
type="checkbox"
(change)="save('includeNumber')"
/>
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>

View File

@@ -1,7 +1,15 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, takeUntil, Subject, ReplaySubject } from "rxjs";
import {
BehaviorSubject,
skip,
takeUntil,
Subject,
map,
withLatestFrom,
ReplaySubject,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -12,7 +20,7 @@ import {
PassphraseGenerationOptions,
} from "@bitwarden/generator-core";
import { completeOnAccountSwitch, toValidators } from "./util";
import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({
numWords: "numWords",
@@ -81,21 +89,12 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
// the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
// dynamic policy enforcement
// explain policy & disable policy-overridden fields
this.generatorService
.policy$(Generators.passphrase, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => {
this.settings
.get(Controls.numWords)
.setValidators(toValidators(Controls.numWords, Generators.passphrase, constraints));
this.settings
.get(Controls.wordSeparator)
.setValidators(toValidators(Controls.wordSeparator, Generators.passphrase, constraints));
this.settings.updateValueAndValidity({ emitEvent: false });
this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength;
this.policyInEffect = constraints.policyInEffect;
this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly);
@@ -110,7 +109,21 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
});
// now that outputs are set up, connect inputs
this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings);
this.saveSettings
.pipe(
withLatestFrom(this.settings.valueChanges),
map(([, settings]) => settings),
takeUntil(this.destroyed$),
)
.subscribe(settings);
}
/** attribute binding for wordSeparator[maxlength] */
protected wordSeparatorMaxLength: number;
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
}
/** display binding for enterprise policy notice */
@@ -144,6 +157,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}

View File

@@ -20,6 +20,7 @@
buttonType="main"
(click)="generate('user request')"
[appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)"
>
{{ credentialTypeGenerateLabel$ | async }}
</button>
@@ -31,6 +32,7 @@
[appA11yTitle]="credentialTypeCopyLabel$ | async"
[appCopyClick]="value$ | async"
[valueLabel]="credentialTypeLabel$ | async"
[disabled]="!(algorithm$ | async)"
></button>
</div>
</bit-card>

View File

@@ -22,11 +22,11 @@ import { Option } from "@bitwarden/components/src/select/option";
import {
CredentialGeneratorService,
Generators,
PasswordAlgorithm,
GeneratedCredential,
CredentialAlgorithm,
isPasswordAlgorithm,
AlgorithmInfo,
isSameAlgorithm,
} from "@bitwarden/generator-core";
import { GeneratorHistoryService } from "@bitwarden/generator-history";
@@ -57,7 +57,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
@Input({ transform: coerceBooleanProperty }) disableMargin = false;
/** tracks the currently selected credential type */
protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>(null);
protected credentialType$ = new BehaviorSubject<CredentialAlgorithm>(null);
/** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>("");
@@ -72,14 +72,14 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
* @param requestor a label used to trace generation request
* origin in the debugger.
*/
protected generate(requestor: string) {
protected async generate(requestor: string) {
this.generate$.next(requestor);
}
/** Tracks changes to the selected credential type
* @param type the new credential type
*/
protected onCredentialTypeChanged(type: PasswordAlgorithm) {
protected onCredentialTypeChanged(type: CredentialAlgorithm) {
// break subscription cycle
if (this.credentialType$.value !== type) {
this.zone.run(() => {
@@ -169,29 +169,34 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
preferences.next(preference);
});
// populate the form with the user's preferences to kick off interactivity
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ password }) => {
// update navigation
this.onCredentialTypeChanged(password.algorithm);
// load algorithm metadata
const algorithm = this.generatorService.algorithm(password.algorithm);
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// generate on load unless the generator prohibits it
this.algorithm$
// update active algorithm
preferences
.pipe(
distinctUntilChanged((prev, next) => prev.id === next.id),
filter((a) => !a.onlyOnRequest),
map(({ password }) => this.generatorService.algorithm(password.algorithm)),
distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)),
takeUntil(this.destroyed),
)
.subscribe(() => this.generate("autogenerate"));
.subscribe((algorithm) => {
// update navigation
this.onCredentialTypeChanged(algorithm.id);
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// generate on load unless the generator prohibits it
this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
this.zone.run(() => {
if (!a || a.onlyOnRequest) {
this.value$.next("-");
} else {
this.generate("autogenerate").catch((e: unknown) => this.logService.error(e));
}
});
});
}
private typeToGenerator$(type: CredentialAlgorithm) {

View File

@@ -7,7 +7,7 @@
<bit-card>
<bit-form-field disableMargin>
<bit-label>{{ "length" | i18n }}</bit-label>
<input bitInput formControlName="length" type="number" />
<input bitInput formControlName="length" type="number" (change)="save('length')" />
<bit-hint>{{ lengthBoundariesHint$ | async }}</bit-hint>
</bit-form-field>
</bit-card>
@@ -21,7 +21,12 @@
attr.aria-description="{{ 'uppercaseDescription' | i18n }}"
title="{{ 'uppercaseDescription' | i18n }}"
>
<input bitCheckbox type="checkbox" formControlName="uppercase" />
<input
bitCheckbox
type="checkbox"
formControlName="uppercase"
(change)="save('uppercase')"
/>
<bit-label>{{ "uppercaseLabel" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control
@@ -29,7 +34,12 @@
attr.aria-description="{{ 'lowercaseDescription' | i18n }}"
title="{{ 'lowercaseDescription' | i18n }}"
>
<input bitCheckbox type="checkbox" formControlName="lowercase" />
<input
bitCheckbox
type="checkbox"
formControlName="lowercase"
(change)="save('lowercase')"
/>
<bit-label>{{ "lowercaseLabel" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control
@@ -37,7 +47,7 @@
attr.aria-description="{{ 'numbersDescription' | i18n }}"
title="{{ 'numbersDescription' | i18n }}"
>
<input bitCheckbox type="checkbox" formControlName="number" />
<input bitCheckbox type="checkbox" formControlName="number" (change)="save('number')" />
<bit-label>{{ "numbersLabel" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control
@@ -45,22 +55,42 @@
attr.aria-description="{{ 'specialCharactersDescription' | i18n }}"
title="{{ 'specialCharactersDescription' | i18n }}"
>
<input bitCheckbox type="checkbox" formControlName="special" />
<input
bitCheckbox
type="checkbox"
formControlName="special"
(change)="save('special')"
/>
<bit-label>{{ "specialCharactersLabel" | i18n }}</bit-label>
</bit-form-control>
</div>
<div class="tw-flex">
<bit-form-field class="tw-w-full tw-basis-1/2 tw-mr-4">
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
<input bitInput type="number" formControlName="minNumber" />
<input
bitInput
type="number"
formControlName="minNumber"
(change)="save('minNumbers')"
/>
</bit-form-field>
<bit-form-field class="tw-w-full tw-basis-1/2">
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
<input bitInput type="number" formControlName="minSpecial" />
<input
bitInput
type="number"
formControlName="minSpecial"
(change)="save('minSpecial')"
/>
</bit-form-field>
</div>
<bit-form-control [disableMargin]="!policyInEffect">
<input bitCheckbox type="checkbox" formControlName="avoidAmbiguous" />
<input
bitCheckbox
type="checkbox"
formControlName="avoidAmbiguous"
(change)="save('avoidAmbiguous')"
/>
<bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label>
</bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>

View File

@@ -1,7 +1,17 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, takeUntil, Subject, map, filter, tap, skip, ReplaySubject } from "rxjs";
import {
BehaviorSubject,
takeUntil,
Subject,
map,
filter,
tap,
skip,
ReplaySubject,
withLatestFrom,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -12,7 +22,7 @@ import {
PasswordGenerationOptions,
} from "@bitwarden/generator-core";
import { completeOnAccountSwitch, toValidators } from "./util";
import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({
length: "length",
@@ -118,23 +128,11 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
this.settings.patchValue(s, { emitEvent: false });
});
// bind policy to the template
// explain policy & disable policy-overridden fields
this.generatorService
.policy$(Generators.password, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => {
this.settings
.get(Controls.length)
.setValidators(toValidators(Controls.length, Generators.password, constraints));
this.minNumber.setValidators(
toValidators(Controls.minNumber, Generators.password, constraints),
);
this.minSpecial.setValidators(
toValidators(Controls.minSpecial, Generators.password, constraints),
);
this.policyInEffect = constraints.policyInEffect;
const toggles = [
@@ -153,8 +151,8 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
const boundariesHint = this.i18nService.t(
"generatorBoundariesHint",
constraints.length.min,
constraints.length.max,
constraints.length.min?.toString(),
constraints.length.max?.toString(),
);
this.lengthBoundariesHint.next(boundariesHint);
});
@@ -201,9 +199,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
// now that outputs are set up, connect inputs
this.settings.valueChanges
this.saveSettings
.pipe(
map((settings) => {
withLatestFrom(this.settings.valueChanges),
map(([, settings]) => {
// interface is "avoid" while storage is "include"
const s: any = { ...settings };
s.ambiguous = s.avoidAmbiguous;
@@ -215,6 +214,11 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
.subscribe(settings);
}
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
}
/** display binding for enterprise policy notice */
protected policyInEffect: boolean;
@@ -246,6 +250,7 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}

View File

@@ -1,6 +1,11 @@
<form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label>
<input bitInput formControlName="subaddressEmail" type="text" />
<input
bitInput
formControlName="subaddressEmail"
type="text"
(change)="save('subaddressEmail')"
/>
</bit-form-field>
</form>

View File

@@ -53,28 +53,25 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy {
const singleUserId$ = this.singleUserId$();
const settings = await this.generatorService.settings(Generators.subaddress, { singleUserId$ });
settings
.pipe(
withLatestFrom(this.accountService.activeAccount$),
map(([settings, activeAccount]) => {
// if the subaddress isn't specified, copy it from
// the user's settings
if ((settings.subaddressEmail ?? "").length < 1) {
settings.subaddressEmail = activeAccount.email;
}
return settings;
}),
takeUntil(this.destroyed$),
)
.subscribe((s) => {
this.settings.patchValue(s, { emitEvent: false });
});
settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => {
this.settings.patchValue(s, { emitEvent: false });
});
// the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings);
this.saveSettings
.pipe(
withLatestFrom(this.settings.valueChanges),
map(([, settings]) => settings),
takeUntil(this.destroyed$),
)
.subscribe(settings);
}
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
}
private singleUserId$() {
@@ -92,6 +89,7 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}

View File

@@ -9,6 +9,7 @@
buttonType="main"
(click)="generate('user request')"
[appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)"
>
{{ credentialTypeGenerateLabel$ | async }}
</button>
@@ -20,6 +21,7 @@
[appA11yTitle]="credentialTypeCopyLabel$ | async"
[appCopyClick]="value$ | async"
[valueLabel]="credentialTypeLabel$ | async"
[disabled]="!(algorithm$ | async)"
>
{{ credentialTypeCopyLabel$ | async }}
</button>

View File

@@ -322,7 +322,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
if (!a || a.onlyOnRequest) {
this.value$.next("-");
} else {
this.generate("autogenerate");
this.generate("autogenerate").catch((e: unknown) => this.logService.error(e));
}
});
});
@@ -414,7 +414,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
* @param requestor a label used to trace generation request
* origin in the debugger.
*/
protected generate(requestor: string) {
protected async generate(requestor: string) {
this.generate$.next(requestor);
}
@@ -429,6 +429,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
private readonly destroyed = new Subject<void>();
ngOnDestroy() {
this.destroyed.next();
this.destroyed.complete();
// finalize subjects

View File

@@ -1,10 +1,20 @@
<form class="box" [formGroup]="settings" class="tw-container">
<bit-form-control>
<input bitCheckbox formControlName="wordCapitalize" type="checkbox" />
<input
bitCheckbox
formControlName="wordCapitalize"
type="checkbox"
(change)="save('wordCapitalize')"
/>
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input bitCheckbox formControlName="wordIncludeNumber" type="checkbox" />
<input
bitCheckbox
formControlName="wordIncludeNumber"
type="checkbox"
(change)="save('wordIncludeNumber')"
/>
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
</form>

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs";
import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -61,7 +61,18 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy {
// the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings);
this.saveSettings
.pipe(
withLatestFrom(this.settings.valueChanges),
map(([, settings]) => settings),
takeUntil(this.destroyed$),
)
.subscribe(settings);
}
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
}
private singleUserId$() {
@@ -79,6 +90,7 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}

View File

@@ -49,7 +49,7 @@ export function toValidators<Policy, Settings>(
}
const max = getConstraint("max", config, runtime);
if (max === undefined) {
if (max !== undefined) {
validators.push(Validators.max(max));
}

View File

@@ -1,7 +1,10 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import {
EmailRandomizer,
@@ -19,12 +22,12 @@ import {
PasswordGeneratorOptionsEvaluator,
passwordLeastPrivilege,
} from "../policies";
import { CatchallConstraints } from "../policies/catchall-constraints";
import { SubaddressConstraints } from "../policies/subaddress-constraints";
import {
CATCHALL_SETTINGS,
EFF_USERNAME_SETTINGS,
PASSPHRASE_SETTINGS,
PASSWORD_SETTINGS,
SUBADDRESS_SETTINGS,
} from "../strategies/storage";
import {
CatchallGenerationOptions,
@@ -178,79 +181,115 @@ const USERNAME = Object.freeze({
},
} satisfies CredentialGeneratorConfiguration<EffUsernameGenerationOptions, NoPolicy>);
const CATCHALL = Object.freeze({
id: "catchall",
category: "email",
nameKey: "catchallEmail",
descriptionKey: "catchallEmailDesc",
generateKey: "generateEmail",
generatedValueKey: "email",
copyKey: "copyEmail",
onlyOnRequest: false,
request: [],
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<CatchallGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
const CATCHALL: CredentialGeneratorConfiguration<CatchallGenerationOptions, NoPolicy> =
Object.freeze({
id: "catchall",
category: "email",
nameKey: "catchallEmail",
descriptionKey: "catchallEmailDesc",
generateKey: "generateEmail",
generatedValueKey: "email",
copyKey: "copyEmail",
onlyOnRequest: false,
request: [],
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<CatchallGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
},
},
},
settings: {
initial: DefaultCatchallOptions,
constraints: { catchallDomain: { minLength: 1 } },
account: CATCHALL_SETTINGS,
},
policy: {
type: PolicyType.PasswordGenerator,
disabledValue: {},
combine(_acc: NoPolicy, _policy: Policy) {
return {};
settings: {
initial: DefaultCatchallOptions,
constraints: { catchallDomain: { minLength: 1 } },
account: {
key: "catchallGeneratorSettings",
target: "object",
format: "plain",
classifier: new PublicClassifier<CatchallGenerationOptions>([
"catchallType",
"catchallDomain",
]),
state: GENERATOR_DISK,
initial: {
catchallType: "random",
catchallDomain: "",
},
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<CatchallGenerationOptions>,
},
createEvaluator(_policy: NoPolicy) {
return new DefaultPolicyEvaluator<CatchallGenerationOptions>();
policy: {
type: PolicyType.PasswordGenerator,
disabledValue: {},
combine(_acc: NoPolicy, _policy: Policy) {
return {};
},
createEvaluator(_policy: NoPolicy) {
return new DefaultPolicyEvaluator<CatchallGenerationOptions>();
},
toConstraints(_policy: NoPolicy, email: string) {
return new CatchallConstraints(email);
},
},
toConstraints(_policy: NoPolicy) {
return new IdentityConstraint<CatchallGenerationOptions>();
},
},
} satisfies CredentialGeneratorConfiguration<CatchallGenerationOptions, NoPolicy>);
});
const SUBADDRESS = Object.freeze({
id: "subaddress",
category: "email",
nameKey: "plusAddressedEmail",
descriptionKey: "plusAddressedEmailDesc",
generateKey: "generateEmail",
generatedValueKey: "email",
copyKey: "copyEmail",
onlyOnRequest: false,
request: [],
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<SubaddressGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
const SUBADDRESS: CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy> =
Object.freeze({
id: "subaddress",
category: "email",
nameKey: "plusAddressedEmail",
descriptionKey: "plusAddressedEmailDesc",
generateKey: "generateEmail",
generatedValueKey: "email",
copyKey: "copyEmail",
onlyOnRequest: false,
request: [],
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<SubaddressGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
},
},
},
settings: {
initial: DefaultSubaddressOptions,
constraints: {},
account: SUBADDRESS_SETTINGS,
},
policy: {
type: PolicyType.PasswordGenerator,
disabledValue: {},
combine(_acc: NoPolicy, _policy: Policy) {
return {};
settings: {
initial: DefaultSubaddressOptions,
constraints: {},
account: {
key: "subaddressGeneratorSettings",
target: "object",
format: "plain",
classifier: new PublicClassifier<SubaddressGenerationOptions>([
"subaddressType",
"subaddressEmail",
]),
state: GENERATOR_DISK,
initial: {
subaddressType: "random",
subaddressEmail: "",
},
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<SubaddressGenerationOptions>,
},
createEvaluator(_policy: NoPolicy) {
return new DefaultPolicyEvaluator<SubaddressGenerationOptions>();
policy: {
type: PolicyType.PasswordGenerator,
disabledValue: {},
combine(_acc: NoPolicy, _policy: Policy) {
return {};
},
createEvaluator(_policy: NoPolicy) {
return new DefaultPolicyEvaluator<SubaddressGenerationOptions>();
},
toConstraints(_policy: NoPolicy, email: string) {
return new SubaddressConstraints(email);
},
},
toConstraints(_policy: NoPolicy) {
return new IdentityConstraint<SubaddressGenerationOptions>();
},
},
} satisfies CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy>);
});
export function toCredentialGeneratorConfiguration<Settings extends ApiSettings = ApiSettings>(
configuration: ForwarderConfiguration<Settings>,

View File

@@ -0,0 +1,45 @@
import { Constraints, StateConstraints } from "@bitwarden/common/tools/types";
import { CatchallGenerationOptions } from "../types";
/** Parses the domain part of an email address
*/
const DOMAIN_PARSER = new RegExp("[^@]+@(?<domain>.+)");
/** A constraint that sets the catchall domain using a fixed email address */
export class CatchallConstraints implements StateConstraints<CatchallGenerationOptions> {
/** Creates a catchall constraints
* @param email - the email address containing the domain.
*/
constructor(email: string) {
if (!email) {
this.domain = "";
return;
}
const parsed = DOMAIN_PARSER.exec(email);
if (parsed && parsed.groups?.domain) {
this.domain = parsed.groups.domain;
}
}
private domain: string;
constraints: Readonly<Constraints<CatchallGenerationOptions>> = {};
adjust(state: CatchallGenerationOptions) {
const currentDomain = (state.catchallDomain ?? "").trim();
if (currentDomain !== "") {
return state;
}
const options = { ...state };
options.catchallDomain = this.domain;
return options;
}
fix(state: CatchallGenerationOptions) {
return state;
}
}

View File

@@ -2,6 +2,7 @@ import { Constraint } from "@bitwarden/common/tools/types";
import { sum } from "../util";
const Zero: Constraint<number> = { min: 0, max: 0 };
const AtLeastOne: Constraint<number> = { min: 1 };
const RequiresTrue: Constraint<boolean> = { requiredValue: true };
@@ -159,6 +160,7 @@ export {
enforceConstant,
readonlyTrueWhen,
fitLength,
Zero,
AtLeastOne,
RequiresTrue,
};

View File

@@ -1,6 +1,6 @@
import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data";
import { AtLeastOne } from "./constraints";
import { AtLeastOne, Zero } from "./constraints";
import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints";
describe("DynamicPasswordPolicyConstraints", () => {
@@ -207,7 +207,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber);
});
it("disables the minNumber constraint when the state's number flag is false", () => {
it("outputs the zero constraint when the state's number flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
@@ -216,7 +216,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minNumber).toBeUndefined();
expect(calibrated.constraints.minNumber).toEqual(Zero);
});
it("outputs the minSpecial constraint when the state's special flag is true", () => {
@@ -231,7 +231,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial);
});
it("disables the minSpecial constraint when the state's special flag is false", () => {
it("outputs the zero constraint when the state's special flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
@@ -240,23 +240,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
it("copies the minimum length constraint", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.minSpecial).toBeUndefined();
expect(calibrated.constraints.minSpecial).toEqual(Zero);
});
});
});

View File

@@ -7,7 +7,7 @@ import {
import { DefaultPasswordBoundaries } from "../data";
import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types";
import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints";
import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne, Zero } from "./constraints";
import { PasswordPolicyConstraints } from "./password-policy-constraints";
/** Creates state constraints by blending policy and password settings. */
@@ -68,8 +68,8 @@ export class DynamicPasswordPolicyConstraints
...this.constraints,
minLowercase: maybe<number>(lowercase, this.constraints.minLowercase ?? AtLeastOne),
minUppercase: maybe<number>(uppercase, this.constraints.minUppercase ?? AtLeastOne),
minNumber: maybe<number>(number, this.constraints.minNumber),
minSpecial: maybe<number>(special, this.constraints.minSpecial),
minNumber: maybe<number>(number, this.constraints.minNumber) ?? Zero,
minSpecial: maybe<number>(special, this.constraints.minSpecial) ?? Zero,
};
// lower bound of length must always at least fit its sub-lengths

View File

@@ -0,0 +1,34 @@
import { Constraints, StateConstraints } from "@bitwarden/common/tools/types";
import { SubaddressGenerationOptions } from "../types";
/** A constraint that sets the subaddress email using a fixed email address */
export class SubaddressConstraints implements StateConstraints<SubaddressGenerationOptions> {
/** Creates a catchall constraints
* @param email - the email address containing the domain.
*/
constructor(readonly email: string) {
if (!email) {
this.email = "";
}
}
constraints: Readonly<Constraints<SubaddressGenerationOptions>> = {};
adjust(state: SubaddressGenerationOptions) {
const currentDomain = (state.subaddressEmail ?? "").trim();
if (currentDomain !== "") {
return state;
}
const options = { ...state };
options.subaddressEmail = this.email;
return options;
}
fix(state: SubaddressGenerationOptions) {
return state;
}
}

View File

@@ -23,11 +23,12 @@ export function mapPolicyToEvaluator<Policy, Evaluator>(
*/
export function mapPolicyToConstraints<Policy, Evaluator>(
configuration: PolicyConfiguration<Policy, Evaluator>,
email: string,
) {
return pipe(
reduceCollection(configuration.combine, configuration.disabledValue),
distinctIfShallowMatch(),
map(configuration.toConstraints),
map((policy) => configuration.toConstraints(policy, email)),
);
}

View File

@@ -202,6 +202,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
@@ -223,6 +224,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
@@ -248,6 +250,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
@@ -276,6 +279,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const website$ = new BehaviorSubject("some website");
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
@@ -297,6 +301,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const website$ = new BehaviorSubject("some website");
let error = null;
@@ -322,6 +327,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const website$ = new BehaviorSubject("some website");
let completed = false;
@@ -348,6 +354,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
@@ -368,6 +375,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.pipe(filter((u) => !!u));
@@ -392,6 +400,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId$ = new BehaviorSubject(SomeUser);
let error = null;
@@ -417,6 +426,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId$ = new BehaviorSubject(SomeUser);
let completed = false;
@@ -443,6 +453,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const on$ = new Subject<void>();
const results: any[] = [];
@@ -485,6 +496,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const on$ = new Subject<void>();
let error: any = null;
@@ -511,6 +523,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const on$ = new Subject<void>();
let complete = false;
@@ -542,6 +555,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = generator.algorithms("password");
@@ -563,6 +577,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = generator.algorithms("username");
@@ -583,6 +598,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = generator.algorithms("email");
@@ -604,6 +620,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = generator.algorithms(["username", "email"]);
@@ -629,6 +646,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = await firstValueFrom(generator.algorithms$("password"));
@@ -646,6 +664,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = await firstValueFrom(generator.algorithms$("username"));
@@ -662,6 +681,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = await firstValueFrom(generator.algorithms$("email"));
@@ -679,6 +699,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = await firstValueFrom(generator.algorithms$(["username", "email"]));
@@ -701,6 +722,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = await firstValueFrom(generator.algorithms$(["password"]));
@@ -726,6 +748,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const results: any = [];
const sub = generator.algorithms$("password").subscribe((r) => results.push(r));
@@ -763,6 +786,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
@@ -784,6 +808,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -814,6 +839,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -840,6 +866,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -866,6 +893,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -898,6 +926,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@@ -916,6 +945,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@@ -936,6 +966,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@@ -961,6 +992,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const results: any = [];
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
@@ -986,6 +1018,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
@@ -1007,6 +1040,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -1034,6 +1068,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -1060,6 +1095,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -1086,6 +1122,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -1118,6 +1155,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
@@ -1139,6 +1177,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
let completed = false;
@@ -1165,6 +1204,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
@@ -1182,6 +1222,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const policy$ = new BehaviorSubject([somePolicy]);
@@ -1201,6 +1242,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -1230,6 +1272,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -1260,6 +1303,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
@@ -1286,6 +1330,7 @@ describe("CredentialGeneratorService", () => {
i18nService,
encryptService,
keyService,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();

View File

@@ -23,6 +23,7 @@ import { Simplify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
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 { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -98,6 +99,7 @@ export class CredentialGeneratorService {
private readonly i18nService: I18nService,
private readonly encryptService: EncryptService,
private readonly keyService: KeyService,
private readonly accountService: AccountService,
) {}
private getDependencyProvider(): GeneratorDependencyProvider {
@@ -380,17 +382,30 @@ export class CredentialGeneratorService {
configuration: Configuration<Settings, Policy>,
dependencies: Policy$Dependencies,
): Observable<GeneratorConstraints<Settings>> {
const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true));
const email$ = dependencies.userId$.pipe(
distinctUntilChanged(),
withLatestFrom(this.accountService.accounts$),
filter((accounts) => !!accounts),
map(([userId, accounts]) => {
if (userId in accounts) {
return { userId, email: accounts[userId].email };
}
const constraints$ = dependencies.userId$.pipe(
switchMap((userId) => {
// complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely
return { userId, email: null };
}),
);
const constraints$ = email$.pipe(
switchMap(({ userId, email }) => {
// complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely
const policies$ = this.policyService
.getAll$(configuration.policy.type, userId)
.pipe(takeUntil(completion$));
.pipe(
mapPolicyToConstraints(configuration.policy, email),
takeUntil(anyComplete(email$)),
);
return policies$;
}),
mapPolicyToConstraints(configuration.policy),
);
return constraints$;

View File

@@ -24,9 +24,13 @@ export type PolicyConfiguration<Policy, Settings> = {
createEvaluator: (policy: Policy) => PolicyEvaluator<Policy, Settings>;
/** Converts policy service data into actionable policy constraints.
*
* @param policy - the policy to map into policy constraints.
* @param email - the default email to extend.
*
* @remarks this version includes constraints needed for the reactive forms;
* it was introduced so that the constraints can be incrementally introduced
* as the new UI is built.
*/
toConstraints: (policy: Policy) => GeneratorConstraints<Settings>;
toConstraints: (policy: Policy, email: string) => GeneratorConstraints<Settings>;
};

View File

@@ -4,6 +4,7 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -43,6 +44,7 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
I18nService,
EncryptService,
KeyService,
AccountService,
],
}),
],

View File

@@ -87,7 +87,12 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
@@ -116,8 +121,18 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
@@ -367,9 +382,24 @@ describe("ItemDetailsSectionComponent", () => {
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{ id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
fixture.detectChanges();
@@ -387,7 +417,12 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
fixture.detectChanges();
@@ -414,13 +449,24 @@ describe("ItemDetailsSectionComponent", () => {
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => true,
} as CollectionView,
];
@@ -433,5 +479,94 @@ describe("ItemDetailsSectionComponent", () => {
expect(collectionHint).not.toBeNull();
});
it("should allow all collections to be altered when `config.admin` is true", async () => {
component.config.admin = true;
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: false,
canEditItems: (_org) => false,
} as CollectionView,
];
fixture.detectChanges();
await fixture.whenStable();
component.itemDetailsForm.controls.organizationId.setValue("org1");
expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]);
});
});
describe("readonlyCollections", () => {
beforeEach(() => {
component.config.mode = "edit";
component.config.admin = true;
component.config.collections = [
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
organizationId: "org1",
folderId: "folder1",
collectionIds: ["col1", "col2", "col3"],
favorite: true,
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
});
it("should not show collections as readonly when `config.admin` is true", async () => {
await component.ngOnInit();
fixture.detectChanges();
// Filters out all collections
expect(component["readOnlyCollections"]).toEqual([]);
// Non-admin, keep readonly collections
component.config.admin = false;
await component.ngOnInit();
fixture.detectChanges();
expect(component["readOnlyCollections"]).toEqual(["Collection 1", "Collection 3"]);
});
});
});

View File

@@ -240,7 +240,11 @@ export class ItemDetailsSectionComponent implements OnInit {
} else if (this.config.mode === "edit") {
this.readOnlyCollections = this.collections
.filter(
(c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId),
// When the configuration is set up for admins, they can alter read only collections
(c) =>
c.readOnly &&
!this.config.admin &&
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
)
.map((c) => c.name);
}
@@ -262,12 +266,24 @@ export class ItemDetailsSectionComponent implements OnInit {
collectionsControl.disable();
this.showCollectionsControl = false;
return;
} else {
collectionsControl.enable();
this.showCollectionsControl = true;
}
const organization = this.organizations.find((o) => o.id === orgId);
this.collectionOptions = this.collections
.filter((c) => {
// If partial edit mode, show all org collections because the control is disabled.
return c.organizationId === orgId && (this.partialEdit || !c.readOnly);
// Filter criteria:
// - The collection belongs to the organization
// - When in partial edit mode, show all org collections because the control is disabled.
// - The user can edit items within the collection
// - When viewing as an admin, all collections should be shown, even readonly. When non-admin, filter out readonly collections
return (
c.organizationId === orgId &&
(this.partialEdit || c.canEditItems(organization) || this.config.admin)
);
})
.map((c) => ({
id: c.id,

View File

@@ -1,6 +1,7 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -17,6 +18,7 @@ function isSetEqual(a: Set<string>, b: Set<string>) {
export class DefaultCipherFormService implements CipherFormService {
private cipherService: CipherService = inject(CipherService);
private accountService: AccountService = inject(AccountService);
private apiService: ApiService = inject(ApiService);
async decryptCipher(cipher: Cipher): Promise<CipherView> {
const activeUserId = await firstValueFrom(
@@ -66,11 +68,21 @@ export class DefaultCipherFormService implements CipherFormService {
// Updating a cipher with collection changes is not supported with a single request currently
// First update the cipher with the original collectionIds
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
await this.cipherService.updateWithServer(encryptedCipher, config.admin);
await this.cipherService.updateWithServer(
encryptedCipher,
config.admin || originalCollectionIds.size === 0,
config.mode !== "clone",
);
// Then save the new collection changes separately
encryptedCipher.collectionIds = cipher.collectionIds;
savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher);
if (config.admin || originalCollectionIds.size === 0) {
// When using an admin config or the cipher was unassigned, update collections as an admin
savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher);
} else {
savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher);
}
}
// Its possible the cipher was made no longer available due to collection assignment changes

View File

@@ -18,6 +18,7 @@
[organization]="organization$ | async"
[collections]="collections"
[folder]="folder$ | async"
[hideOwner]="isAdminConsole"
>
</app-item-details-v2>

View File

@@ -51,6 +51,10 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
* `CipherService` and the `collectionIds` property of the cipher.
*/
@Input() collections: CollectionView[];
/** Should be set to true when the component is used within the Admin Console */
@Input() isAdminConsole?: boolean = false;
organization$: Observable<Organization>;
folder$: Observable<FolderView>;
private destroyed$: Subject<void> = new Subject();
@@ -94,6 +98,7 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
async loadCipherData() {
// Load collections if not provided and the cipher has collectionIds
if (
this.cipher.collectionIds &&
this.cipher.collectionIds.length > 0 &&
(!this.collections || this.collections.length === 0)
) {

View File

@@ -4,10 +4,8 @@
</bit-section-header>
<bit-card>
<bit-form-field
[disableMargin]="!cipher.collectionIds?.length && !cipher.organizationId && !cipher.folderId"
[disableReadOnlyBorder]="
!cipher.collectionIds?.length && !cipher.organizationId && !cipher.folderId
"
[disableMargin]="!cipher.collectionIds?.length && !showOwnership && !cipher.folderId"
[disableReadOnlyBorder]="!cipher.collectionIds?.length && !showOwnership && !cipher.folderId"
>
<bit-label>
{{ "itemName" | i18n }}
@@ -24,11 +22,11 @@
<ul
[attr.aria-label]="'itemLocation' | i18n"
*ngIf="cipher.collectionIds?.length || cipher.organizationId || cipher.folderId"
*ngIf="cipher.collectionIds?.length || showOwnership || cipher.folderId"
class="tw-mb-0 tw-pl-0"
>
<li
*ngIf="cipher.organizationId && organization"
*ngIf="showOwnership && organization"
class="tw-flex tw-items-center tw-list-none"
[ngClass]="{ 'tw-mb-3': cipher.collectionIds }"
bitTypography="body2"

View File

@@ -0,0 +1,83 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ItemDetailsV2Component } from "./item-details-v2.component";
describe("ItemDetailsV2Component", () => {
let component: ItemDetailsV2Component;
let fixture: ComponentFixture<ItemDetailsV2Component>;
const cipher = {
id: "cipher1",
collectionIds: ["col1", "col2"],
organizationId: "org1",
folderId: "folder1",
name: "cipher name",
} as CipherView;
const organization = {
id: "org1",
name: "Organization 1",
} as Organization;
const collection = {
id: "col1",
name: "Collection 1",
} as CollectionView;
const collection2 = {
id: "col2",
name: "Collection 2",
} as CollectionView;
const folder = {
id: "folder1",
name: "Folder 1",
} as FolderView;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ItemDetailsV2Component],
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ItemDetailsV2Component);
component = fixture.componentInstance;
component.cipher = cipher;
component.organization = organization;
component.collections = [collection, collection2];
component.folder = folder;
fixture.detectChanges();
});
it("displays all available fields", () => {
const itemName = fixture.debugElement.query(By.css('[data-testid="item-name"]'));
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]'));
const collections = fixture.debugElement.queryAll(By.css('[data-testid="collections"] li'));
const folderElement = fixture.debugElement.query(By.css('[data-testid="folder"]'));
expect(itemName.nativeElement.value).toBe(cipher.name);
expect(owner.nativeElement.textContent.trim()).toBe(organization.name);
expect(collections.map((c) => c.nativeElement.textContent.trim())).toEqual([
collection.name,
collection2.name,
]);
expect(folderElement.nativeElement.textContent.trim()).toBe(folder.name);
});
it("does not render owner when `hideOwner` is true", () => {
component.hideOwner = true;
fixture.detectChanges();
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]'));
expect(owner).toBeNull();
});
});

View File

@@ -36,4 +36,9 @@ export class ItemDetailsV2Component {
@Input() organization?: Organization;
@Input() collections?: CollectionView[];
@Input() folder?: FolderView;
@Input() hideOwner?: boolean = false;
get showOwnership() {
return this.cipher.organizationId && this.organization && !this.hideOwner;
}
}

View File

@@ -111,6 +111,7 @@
[value]="totpCodeCopyObj?.totpCodeFormatted || '*** ***'"
aria-readonly="true"
data-testid="login-totp"
class="tw-font-mono"
/>
<div
*ngIf="isPremium$ | async"

View File

@@ -15,10 +15,10 @@
bitIconButton="bwi-clone"
[appA11yTitle]="'copyPassword' | i18n"
appStopClick
(click)="copy(h.password)"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
[appCopyClick]="h.password"
[valueLabel]="'password' | i18n"
showToast
></button>
</bit-item-action>
</ng-container>
</bit-item>

View File

@@ -3,14 +3,13 @@ import { By } from "@angular/platform-browser";
import { BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ColorPasswordModule, ItemModule, ToastService } from "@bitwarden/components";
import { ColorPasswordModule, ItemModule } from "@bitwarden/components";
import { ColorPasswordComponent } from "@bitwarden/components/src/color-password/color-password.component";
import { PasswordHistoryViewComponent } from "./password-history-view.component";
@@ -25,8 +24,6 @@ describe("PasswordHistoryViewComponent", () => {
organizationId: "222-444-555",
} as CipherView;
const copyToClipboard = jest.fn();
const showToast = jest.fn();
const activeAccount$ = new BehaviorSubject<{ id: string }>({ id: "666-444-444" });
const mockCipherService = {
get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }),
@@ -36,17 +33,13 @@ describe("PasswordHistoryViewComponent", () => {
beforeEach(async () => {
mockCipherService.get.mockClear();
mockCipherService.getKeyForCipherKeyDecryption.mockClear();
copyToClipboard.mockClear();
showToast.mockClear();
await TestBed.configureTestingModule({
imports: [ItemModule, ColorPasswordModule, JslibModule],
providers: [
{ provide: WINDOW, useValue: window },
{ provide: CipherService, useValue: mockCipherService },
{ provide: PlatformUtilsService, useValue: { copyToClipboard } },
{ provide: PlatformUtilsService },
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: ToastService, useValue: { showToast } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
}).compileComponents();
@@ -80,18 +73,5 @@ describe("PasswordHistoryViewComponent", () => {
"bad-password-2",
]);
});
it("copies a password", () => {
const copyButton = fixture.debugElement.query(By.css("button"));
copyButton.nativeElement.click();
expect(copyToClipboard).toHaveBeenCalledWith("bad-password-1", { window: window });
expect(showToast).toHaveBeenCalledWith({
message: "passwordCopied",
title: "",
variant: "info",
});
});
});
});

View File

@@ -1,21 +1,14 @@
import { CommonModule } from "@angular/common";
import { OnInit, Inject, Component, Input } from "@angular/core";
import { OnInit, Component, Input } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
import {
ToastService,
ItemModule,
ColorPasswordModule,
IconButtonModule,
} from "@bitwarden/components";
import { ItemModule, ColorPasswordModule, IconButtonModule } from "@bitwarden/components";
@Component({
selector: "vault-password-history-view",
@@ -33,29 +26,15 @@ export class PasswordHistoryViewComponent implements OnInit {
history: PasswordHistoryView[] = [];
constructor(
@Inject(WINDOW) private win: Window,
protected cipherService: CipherService,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected accountService: AccountService,
protected toastService: ToastService,
) {}
async ngOnInit() {
await this.init();
}
/** Copies a password to the clipboard. */
copy(password: string) {
const copyOptions = this.win != null ? { window: this.win } : undefined;
this.platformUtilsService.copyToClipboard(password, copyOptions);
this.toastService.showToast({
variant: "info",
title: "",
message: this.i18nService.t("passwordCopied"),
});
}
/** Retrieve the password history for the given cipher */
protected async init() {
const cipher = await this.cipherService.get(this.cipherId);