mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
Merge branch 'feature/SG-771-org-domain-claiming-web' into feature/SG-680-create-domain-verification-comp
This commit is contained in:
@@ -23,6 +23,7 @@ import { SecureNoteType } from "@bitwarden/common/enums/secureNoteType";
|
||||
import { UriMatchType } from "@bitwarden/common/enums/uriMatchType";
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import { Cipher } from "@bitwarden/common/models/domain/cipher";
|
||||
import { Organization } from "@bitwarden/common/models/domain/organization";
|
||||
import { CardView } from "@bitwarden/common/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
||||
@@ -74,6 +75,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
allowPersonal = true;
|
||||
reprompt = false;
|
||||
canUseReprompt = true;
|
||||
organization: Organization;
|
||||
|
||||
protected destroy$ = new Subject<void>();
|
||||
protected writeableCollections: CollectionView[];
|
||||
|
||||
@@ -292,4 +292,8 @@ export class AttachmentsComponent implements OnInit {
|
||||
protected deleteCipherAttachment(attachmentId: string) {
|
||||
return this.cipherService.deleteAttachmentWithServer(this.cipher.id, attachmentId);
|
||||
}
|
||||
|
||||
protected async reupload(attachment: AttachmentView) {
|
||||
// TODO: This should be removed but is needed since we re-use the same template
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
isNotProviderUser,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
@@ -54,7 +57,10 @@ export class ShareComponent implements OnInit, OnDestroy {
|
||||
this.organizations$ = this.organizationService.organizations$.pipe(
|
||||
map((orgs) => {
|
||||
return orgs
|
||||
.filter((o) => o.enabled && o.status === OrganizationUserStatusType.Confirmed)
|
||||
.filter(
|
||||
(o) =>
|
||||
o.enabled && o.status === OrganizationUserStatusType.Confirmed && isNotProviderUser(o)
|
||||
)
|
||||
.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<ng-container *ngIf="!usesKeyConnector">
|
||||
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
|
||||
<input
|
||||
id="masterPassword"
|
||||
type="password"
|
||||
name="MasterPasswordHash"
|
||||
class="form-control"
|
||||
[formControl]="secret"
|
||||
required
|
||||
appAutofocus
|
||||
appInputVerbatim
|
||||
/>
|
||||
<small class="form-text text-muted">{{ "confirmIdentity" | i18n }}</small>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="usesKeyConnector">
|
||||
<div class="form-group">
|
||||
<label class="d-block">{{ "sendVerificationCode" | i18n }}</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
(click)="requestOTP()"
|
||||
[disabled]="disableRequestOTP"
|
||||
>
|
||||
{{ "sendCode" | i18n }}
|
||||
</button>
|
||||
<span class="ml-2 text-success" role="alert" @sent *ngIf="sentCode">
|
||||
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
|
||||
{{ "codeSent" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="verificationCode">{{ "verificationCode" | i18n }}</label>
|
||||
<input
|
||||
id="verificationCode"
|
||||
type="input"
|
||||
name="verificationCode"
|
||||
class="form-control"
|
||||
[formControl]="secret"
|
||||
required
|
||||
appAutofocus
|
||||
appInputVerbatim
|
||||
/>
|
||||
<small class="form-text text-muted">{{ "confirmIdentity" | i18n }}</small>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -1,6 +1,5 @@
|
||||
import { animate, style, transition, trigger } from "@angular/animations";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl } from "@angular/forms";
|
||||
import { Directive, OnInit } from "@angular/core";
|
||||
import { ControlValueAccessor, FormControl } from "@angular/forms";
|
||||
|
||||
import { KeyConnectorService } from "@bitwarden/common/abstractions/keyConnector.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction";
|
||||
@@ -14,21 +13,8 @@ import { Verification } from "@bitwarden/common/types/verification";
|
||||
* This is exposed to the parent component via the ControlValueAccessor interface (e.g. bind it to a FormControl).
|
||||
* Use UserVerificationService to verify the user's input.
|
||||
*/
|
||||
@Component({
|
||||
@Directive({
|
||||
selector: "app-user-verification",
|
||||
templateUrl: "user-verification.component.html",
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
multi: true,
|
||||
useExisting: UserVerificationComponent,
|
||||
},
|
||||
],
|
||||
animations: [
|
||||
trigger("sent", [
|
||||
transition(":enter", [style({ opacity: 0 }), animate("100ms", style({ opacity: 1 }))]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class UserVerificationComponent implements ControlValueAccessor, OnInit {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Directive, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { Organization } from "@bitwarden/common/models/domain/organization";
|
||||
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||
|
||||
@Directive()
|
||||
@@ -17,6 +18,8 @@ export class VaultItemsComponent {
|
||||
searchPlaceholder: string = null;
|
||||
filter: (cipher: CipherView) => boolean = null;
|
||||
deleted = false;
|
||||
organization: Organization;
|
||||
accessEvents = false;
|
||||
|
||||
protected searchPending = false;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
export class I18nPipe implements PipeTransform {
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
transform(id: string, p1?: string, p2?: string, p3?: string): string {
|
||||
transform(id: string, p1?: string | number, p2?: string | number, p3?: string | number): string {
|
||||
return this.i18nService.t(id, p1, p2, p3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { firstValueFrom, from, mergeMap, Observable } from "rxjs";
|
||||
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
|
||||
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
isNotProviderUser,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { PolicyType } from "@bitwarden/common/enums/policyType";
|
||||
@@ -40,7 +43,9 @@ export class VaultFilterService {
|
||||
async buildOrganizations(): Promise<Organization[]> {
|
||||
let organizations = await this.organizationService.getAll();
|
||||
if (organizations != null) {
|
||||
organizations = organizations.sort((a, b) => a.name.localeCompare(b.name));
|
||||
organizations = organizations
|
||||
.filter(isNotProviderUser)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
return organizations;
|
||||
|
||||
3
libs/angular/test-utils.ts
Normal file
3
libs/angular/test-utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function awaitAsync(ms = 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -241,4 +241,72 @@ describe("Utils Service", () => {
|
||||
expect(Utils.fromByteStringToArray(null)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapToRecord", () => {
|
||||
it("should handle null", () => {
|
||||
expect(Utils.mapToRecord(null)).toEqual(null);
|
||||
});
|
||||
|
||||
it("should handle empty map", () => {
|
||||
expect(Utils.mapToRecord(new Map())).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle convert a Map to a Record", () => {
|
||||
const map = new Map([
|
||||
["key1", "value1"],
|
||||
["key2", "value2"],
|
||||
]);
|
||||
expect(Utils.mapToRecord(map)).toEqual({ key1: "value1", key2: "value2" });
|
||||
});
|
||||
|
||||
it("should handle convert a Map to a Record with non-string keys", () => {
|
||||
const map = new Map([
|
||||
[1, "value1"],
|
||||
[2, "value2"],
|
||||
]);
|
||||
const result = Utils.mapToRecord(map);
|
||||
expect(result).toEqual({ 1: "value1", 2: "value2" });
|
||||
expect(Utils.recordToMap(result)).toEqual(map);
|
||||
});
|
||||
|
||||
it("should not convert an object if it's not a map", () => {
|
||||
const obj = { key1: "value1", key2: "value2" };
|
||||
expect(Utils.mapToRecord(obj as any)).toEqual(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordToMap", () => {
|
||||
it("should handle null", () => {
|
||||
expect(Utils.recordToMap(null)).toEqual(null);
|
||||
});
|
||||
|
||||
it("should handle empty record", () => {
|
||||
expect(Utils.recordToMap({})).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("should handle convert a Record to a Map", () => {
|
||||
const record = { key1: "value1", key2: "value2" };
|
||||
expect(Utils.recordToMap(record)).toEqual(new Map(Object.entries(record)));
|
||||
});
|
||||
|
||||
it("should handle convert a Record to a Map with non-string keys", () => {
|
||||
const record = { 1: "value1", 2: "value2" };
|
||||
const result = Utils.recordToMap(record);
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
[1, "value1"],
|
||||
[2, "value2"],
|
||||
])
|
||||
);
|
||||
expect(Utils.mapToRecord(result)).toEqual(record);
|
||||
});
|
||||
|
||||
it("should not convert an object if already a map", () => {
|
||||
const map = new Map([
|
||||
["key1", "value1"],
|
||||
["key2", "value2"],
|
||||
]);
|
||||
expect(Utils.recordToMap(map as any)).toEqual(map);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,6 +163,11 @@ import { TwoFactorYubiKeyResponse } from "../models/response/two-factor-yubi-key
|
||||
import { UserKeyResponse } from "../models/response/user-key.response";
|
||||
import { SendAccessView } from "../models/view/send-access.view";
|
||||
|
||||
/**
|
||||
* @deprecated The `ApiService` class is deprecated and calls should be extracted into individual
|
||||
* api services. The `send` method is still allowed to be used within api services. For background
|
||||
* of this decision please read https://contributing.bitwarden.com/architecture/adr/refactor-api-service.
|
||||
*/
|
||||
export abstract class ApiService {
|
||||
send: (
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
|
||||
@@ -6,6 +6,6 @@ export abstract class I18nService {
|
||||
translationLocale: string;
|
||||
collator: Intl.Collator;
|
||||
localeNames: Map<string, string>;
|
||||
t: (id: string, p1?: string, p2?: string, p3?: string) => string;
|
||||
t: (id: string, p1?: string | number, p2?: string | number, p3?: string | number) => string;
|
||||
translate: (id: string, p1?: string, p2?: string, p3?: string) => string;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ export function canAccessAdmin(i18nService: I18nService) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isNotProviderUser(org: Organization): boolean {
|
||||
return !org.isProviderUser;
|
||||
}
|
||||
|
||||
export abstract class OrganizationService {
|
||||
organizations$: Observable<Organization[]>;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export abstract class PlatformUtilsService {
|
||||
isViewOpen: () => Promise<boolean>;
|
||||
launchUri: (uri: string, options?: any) => void;
|
||||
getApplicationVersion: () => Promise<string>;
|
||||
getApplicationVersionNumber: () => Promise<string>;
|
||||
supportsWebAuthn: (win: Window) => boolean;
|
||||
supportsDuo: () => boolean;
|
||||
showToast: (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
|
||||
import { Forwarder } from "./forwarder";
|
||||
import { ForwarderOptions } from "./forwarderOptions";
|
||||
import { ForwarderOptions } from "./forwarder-options";
|
||||
|
||||
export class AnonAddyForwarder implements Forwarder {
|
||||
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
|
||||
import { Forwarder } from "./forwarder";
|
||||
import { ForwarderOptions } from "./forwarderOptions";
|
||||
import { ForwarderOptions } from "./forwarder-options";
|
||||
|
||||
export class DuckDuckGoForwarder implements Forwarder {
|
||||
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
|
||||
import { Forwarder } from "./forwarder";
|
||||
import { ForwarderOptions } from "./forwarderOptions";
|
||||
import { ForwarderOptions } from "./forwarder-options";
|
||||
|
||||
export class FastmailForwarder implements Forwarder {
|
||||
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
|
||||
import { Forwarder } from "./forwarder";
|
||||
import { ForwarderOptions } from "./forwarderOptions";
|
||||
import { ForwarderOptions } from "./forwarder-options";
|
||||
|
||||
export class FirefoxRelayForwarder implements Forwarder {
|
||||
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
|
||||
import { ForwarderOptions } from "./forwarderOptions";
|
||||
import { ForwarderOptions } from "./forwarder-options";
|
||||
|
||||
export interface Forwarder {
|
||||
generate(apiService: ApiService, options: ForwarderOptions): Promise<string>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
|
||||
import { Forwarder } from "./forwarder";
|
||||
import { ForwarderOptions } from "./forwarderOptions";
|
||||
import { ForwarderOptions } from "./forwarder-options";
|
||||
|
||||
export class SimpleLoginForwarder implements Forwarder {
|
||||
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-useless-escape */
|
||||
import { getHostname, parse } from "tldts";
|
||||
import { Merge } from "type-fest";
|
||||
|
||||
import { CryptoService } from "../abstractions/crypto.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
@@ -55,6 +56,10 @@ export class Utils {
|
||||
}
|
||||
|
||||
static fromB64ToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "base64"));
|
||||
} else {
|
||||
@@ -108,6 +113,9 @@ export class Utils {
|
||||
}
|
||||
|
||||
static fromBufferToB64(buffer: ArrayBuffer): string {
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(buffer).toString("base64");
|
||||
} else {
|
||||
@@ -423,6 +431,57 @@ export class Utils {
|
||||
return this.global.bitwardenContainerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts map to a Record<string, V> with the same data. Inverse of recordToMap
|
||||
* Useful in toJSON methods, since Maps are not serializable
|
||||
* @param map
|
||||
* @returns
|
||||
*/
|
||||
static mapToRecord<K extends string | number, V>(map: Map<K, V>): Record<string, V> {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(map instanceof Map)) {
|
||||
return map;
|
||||
}
|
||||
return Object.fromEntries(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts record to a Map<string, V> with the same data. Inverse of mapToRecord
|
||||
* Useful in fromJSON methods, since Maps are not serializable
|
||||
*
|
||||
* Warning: If the record has string keys that are numbers, they will be converted to numbers in the map
|
||||
* @param record
|
||||
* @returns
|
||||
*/
|
||||
static recordToMap<K extends string | number, V>(record: Record<K, V>): Map<K, V> {
|
||||
if (record == null) {
|
||||
return null;
|
||||
} else if (record instanceof Map) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const entries = Object.entries(record);
|
||||
if (entries.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
if (isNaN(Number(entries[0][0]))) {
|
||||
return new Map(entries) as Map<K, V>;
|
||||
} else {
|
||||
return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map<K, V>;
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies Object.assign, but converts the type nicely using Type-Fest Merge<Destination, Source> */
|
||||
static merge<Destination, Source>(
|
||||
destination: Destination,
|
||||
source: Source
|
||||
): Merge<Destination, Source> {
|
||||
return Object.assign(destination, source) as unknown as Merge<Destination, Source>;
|
||||
}
|
||||
|
||||
private static isMobile(win: Window) {
|
||||
let mobile = false;
|
||||
((a) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Except, Jsonify } from "type-fest";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AuthenticationStatus } from "../../enums/authenticationStatus";
|
||||
import { KdfType } from "../../enums/kdfType";
|
||||
@@ -40,7 +40,7 @@ export class EncryptionPair<TEncrypted, TDecrypted> {
|
||||
}
|
||||
|
||||
static fromJSON<TEncrypted, TDecrypted>(
|
||||
obj: Jsonify<EncryptionPair<Jsonify<TEncrypted>, Jsonify<TDecrypted>>>,
|
||||
obj: { encrypted?: Jsonify<TEncrypted>; decrypted?: string | Jsonify<TDecrypted> },
|
||||
decryptedFromJson?: (decObj: Jsonify<TDecrypted> | string) => TDecrypted,
|
||||
encryptedFromJson?: (encObj: Jsonify<TEncrypted>) => TEncrypted
|
||||
) {
|
||||
@@ -123,7 +123,7 @@ export class AccountKeys {
|
||||
apiKeyClientSecret?: string;
|
||||
|
||||
toJSON() {
|
||||
return Object.assign(this as Except<AccountKeys, "publicKey">, {
|
||||
return Utils.merge(this, {
|
||||
publicKey: Utils.fromBufferToByteString(this.publicKey),
|
||||
});
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export class AccountSettings {
|
||||
}
|
||||
|
||||
export type AccountSettingsSettings = {
|
||||
equivalentDomains?: { [id: string]: any };
|
||||
equivalentDomains?: string[][];
|
||||
};
|
||||
|
||||
export class AccountTokens {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType";
|
||||
import { OrganizationUserType } from "../../enums/organizationUserType";
|
||||
import { ProductType } from "../../enums/productType";
|
||||
@@ -205,4 +207,15 @@ export class Organization {
|
||||
get hasProvider() {
|
||||
return this.providerId != null || this.providerName != null;
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<Organization>) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new Organization(), json, {
|
||||
familySponsorshipLastSyncDate: new Date(json.familySponsorshipLastSyncDate),
|
||||
familySponsorshipValidUntil: new Date(json.familySponsorshipValidUntil),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,25 @@ import { State } from "./state";
|
||||
describe("state", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(State.fromJSON({})).toBeInstanceOf(State);
|
||||
expect(State.fromJSON({}, () => new Account({}))).toBeInstanceOf(State);
|
||||
});
|
||||
|
||||
it("should always assign an object to accounts", () => {
|
||||
const state = State.fromJSON({});
|
||||
const state = State.fromJSON({}, () => new Account({}));
|
||||
expect(state.accounts).not.toBeNull();
|
||||
expect(state.accounts).toEqual({});
|
||||
});
|
||||
|
||||
it("should build an account map", () => {
|
||||
const accountsSpy = jest.spyOn(Account, "fromJSON");
|
||||
const state = State.fromJSON({
|
||||
accounts: {
|
||||
userId: {},
|
||||
const state = State.fromJSON(
|
||||
{
|
||||
accounts: {
|
||||
userId: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Account.fromJSON
|
||||
);
|
||||
|
||||
expect(state.accounts["userId"]).toBeInstanceOf(Account);
|
||||
expect(accountsSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -19,26 +19,28 @@ export class State<
|
||||
|
||||
// TODO, make Jsonify<State,TGlobalState,TAccount> work. It currently doesn't because Globals doesn't implement Jsonify.
|
||||
static fromJSON<TGlobalState extends GlobalState, TAccount extends Account>(
|
||||
obj: any
|
||||
obj: any,
|
||||
accountDeserializer: (json: Jsonify<TAccount>) => TAccount
|
||||
): State<TGlobalState, TAccount> {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new State(null), obj, {
|
||||
accounts: State.buildAccountMapFromJSON(obj?.accounts),
|
||||
accounts: State.buildAccountMapFromJSON(obj?.accounts, accountDeserializer),
|
||||
});
|
||||
}
|
||||
|
||||
private static buildAccountMapFromJSON(
|
||||
jsonAccounts: Jsonify<{ [userId: string]: Jsonify<Account> }>
|
||||
private static buildAccountMapFromJSON<TAccount extends Account>(
|
||||
jsonAccounts: { [userId: string]: Jsonify<TAccount> },
|
||||
accountDeserializer: (json: Jsonify<TAccount>) => TAccount
|
||||
) {
|
||||
if (!jsonAccounts) {
|
||||
return {};
|
||||
}
|
||||
const accounts: { [userId: string]: Account } = {};
|
||||
const accounts: { [userId: string]: TAccount } = {};
|
||||
for (const userId in jsonAccounts) {
|
||||
accounts[userId] = Account.fromJSON(jsonAccounts[userId]);
|
||||
accounts[userId] = accountDeserializer(jsonAccounts[userId]);
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DeepJsonify } from "../../types/deep-jsonify";
|
||||
import { SendFile } from "../domain/send-file";
|
||||
|
||||
import { View } from "./view";
|
||||
@@ -28,4 +29,12 @@ export class SendFileView implements View {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static fromJSON(json: DeepJsonify<SendFileView>) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new SendFileView(), json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DeepJsonify } from "../../types/deep-jsonify";
|
||||
import { SendText } from "../domain/send-text";
|
||||
|
||||
import { View } from "./view";
|
||||
@@ -17,4 +18,12 @@ export class SendTextView implements View {
|
||||
get maskedText(): string {
|
||||
return this.text != null ? "••••••••" : null;
|
||||
}
|
||||
|
||||
static fromJSON(json: DeepJsonify<SendTextView>) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new SendTextView(), json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SendType } from "../../enums/sendType";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { DeepJsonify } from "../../types/deep-jsonify";
|
||||
import { Send } from "../domain/send";
|
||||
import { SymmetricCryptoKey } from "../domain/symmetric-crypto-key";
|
||||
|
||||
@@ -65,4 +66,26 @@ export class SendView implements View {
|
||||
get pendingDelete(): boolean {
|
||||
return this.deletionDate <= new Date();
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return Utils.merge(this, {
|
||||
key: Utils.fromBufferToB64(this.key),
|
||||
});
|
||||
}
|
||||
|
||||
static fromJSON(json: DeepJsonify<SendView>) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new SendView(), json, {
|
||||
key: Utils.fromB64ToArray(json.key)?.buffer,
|
||||
cryptoKey: SymmetricCryptoKey.fromJSON(json.cryptoKey),
|
||||
text: SendTextView.fromJSON(json.text),
|
||||
file: SendFileView.fromJSON(json.file),
|
||||
revisionDate: json.revisionDate == null ? null : new Date(json.revisionDate),
|
||||
deletionDate: json.deletionDate == null ? null : new Date(json.deletionDate),
|
||||
expirationDate: json.expirationDate == null ? null : new Date(json.expirationDate),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,11 @@ import { TwoFactorYubiKeyResponse } from "../models/response/two-factor-yubi-key
|
||||
import { UserKeyResponse } from "../models/response/user-key.response";
|
||||
import { SendAccessView } from "../models/view/send-access.view";
|
||||
|
||||
/**
|
||||
* @deprecated The `ApiService` class is deprecated and calls should be extracted into individual
|
||||
* api services. The `send` method is still allowed to be used within api services. For background
|
||||
* of this decision please read https://contributing.bitwarden.com/architecture/adr/refactor-api-service.
|
||||
*/
|
||||
export class ApiService implements ApiServiceAbstraction {
|
||||
private device: DeviceType;
|
||||
private deviceType: string;
|
||||
@@ -2081,7 +2086,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
request.headers.set("Bitwarden-Client-Name", this.platformUtilsService.getClientType());
|
||||
request.headers.set(
|
||||
"Bitwarden-Client-Version",
|
||||
await this.platformUtilsService.getApplicationVersion()
|
||||
await this.platformUtilsService.getApplicationVersionNumber()
|
||||
);
|
||||
return this.nativeFetch(request);
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
: firstValueFrom(this.settingsService.settings$).then(
|
||||
(settings: AccountSettingsSettings) => {
|
||||
let matches: any[] = [];
|
||||
settings.equivalentDomains?.forEach((eqDomain: any) => {
|
||||
settings?.equivalentDomains?.forEach((eqDomain: any) => {
|
||||
if (eqDomain.length && eqDomain.indexOf(domain) >= 0) {
|
||||
matches = matches.concat(eqDomain);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class I18nService implements I18nServiceAbstraction {
|
||||
return this.translate(id, p1, p2, p3);
|
||||
}
|
||||
|
||||
translate(id: string, p1?: string, p2?: string, p3?: string): string {
|
||||
translate(id: string, p1?: string | number, p2?: string | number, p3?: string | number): string {
|
||||
let result: string;
|
||||
// eslint-disable-next-line
|
||||
if (this.localeMessages.hasOwnProperty(id) && this.localeMessages[id]) {
|
||||
@@ -136,13 +136,13 @@ export class I18nService implements I18nServiceAbstraction {
|
||||
|
||||
if (result !== "") {
|
||||
if (p1 != null) {
|
||||
result = result.split("__$1__").join(p1);
|
||||
result = result.split("__$1__").join(p1.toString());
|
||||
}
|
||||
if (p2 != null) {
|
||||
result = result.split("__$2__").join(p2);
|
||||
result = result.split("__$2__").join(p2.toString());
|
||||
}
|
||||
if (p3 != null) {
|
||||
result = result.split("__$3__").join(p3);
|
||||
result = result.split("__$3__").join(p3.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
export class OrganizationService implements InternalOrganizationServiceAbstraction {
|
||||
private _organizations = new BehaviorSubject<Organization[]>([]);
|
||||
protected _organizations = new BehaviorSubject<Organization[]>([]);
|
||||
|
||||
organizations$ = this._organizations.asObservable();
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ListResponse } from "../../models/response/list.response";
|
||||
import { PolicyResponse } from "../../models/response/policy.response";
|
||||
|
||||
export class PolicyService implements InternalPolicyServiceAbstraction {
|
||||
private _policies: BehaviorSubject<Policy[]> = new BehaviorSubject([]);
|
||||
protected _policies: BehaviorSubject<Policy[]> = new BehaviorSubject([]);
|
||||
|
||||
policies$ = this._policies.asObservable();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Utils } from "../misc/utils";
|
||||
import { AccountSettingsSettings } from "../models/domain/account";
|
||||
|
||||
export class SettingsService implements SettingsServiceAbstraction {
|
||||
private _settings: BehaviorSubject<AccountSettingsSettings> = new BehaviorSubject({});
|
||||
protected _settings: BehaviorSubject<AccountSettingsSettings> = new BehaviorSubject({});
|
||||
|
||||
settings$ = this._settings.asObservable();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BehaviorSubject, concatMap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
|
||||
@@ -13,6 +14,7 @@ import { StorageLocation } from "../enums/storageLocation";
|
||||
import { ThemeType } from "../enums/themeType";
|
||||
import { UriMatchType } from "../enums/uriMatchType";
|
||||
import { StateFactory } from "../factories/stateFactory";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
import { CollectionData } from "../models/data/collection.data";
|
||||
import { EncryptedOrganizationKeyData } from "../models/data/encrypted-organization-key.data";
|
||||
@@ -65,13 +67,13 @@ export class StateService<
|
||||
TAccount extends Account = Account
|
||||
> implements StateServiceAbstraction<TAccount>
|
||||
{
|
||||
private accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
|
||||
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
|
||||
accounts$ = this.accountsSubject.asObservable();
|
||||
|
||||
private activeAccountSubject = new BehaviorSubject<string | null>(null);
|
||||
protected activeAccountSubject = new BehaviorSubject<string | null>(null);
|
||||
activeAccount$ = this.activeAccountSubject.asObservable();
|
||||
|
||||
private activeAccountUnlockedSubject = new BehaviorSubject<boolean>(false);
|
||||
protected activeAccountUnlockedSubject = new BehaviorSubject<boolean>(false);
|
||||
activeAccountUnlocked$ = this.activeAccountUnlockedSubject.asObservable();
|
||||
|
||||
private hasBeenInited = false;
|
||||
@@ -79,6 +81,9 @@ export class StateService<
|
||||
|
||||
private accountDiskCache = new Map<string, TAccount>();
|
||||
|
||||
// default account serializer, must be overridden by child class
|
||||
protected accountDeserializer = Account.fromJSON as (json: Jsonify<TAccount>) => TAccount;
|
||||
|
||||
constructor(
|
||||
protected storageService: AbstractStorageService,
|
||||
protected secureStorageService: AbstractStorageService,
|
||||
@@ -676,7 +681,7 @@ export class StateService<
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
return this.recordToMap(account?.keys?.organizationKeys?.decrypted);
|
||||
return Utils.recordToMap(account?.keys?.organizationKeys?.decrypted);
|
||||
}
|
||||
|
||||
async setDecryptedOrganizationKeys(
|
||||
@@ -686,7 +691,7 @@ export class StateService<
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.keys.organizationKeys.decrypted = this.mapToRecord(value);
|
||||
account.keys.organizationKeys.decrypted = Utils.mapToRecord(value);
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
@@ -774,7 +779,7 @@ export class StateService<
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
return this.recordToMap(account?.keys?.providerKeys?.decrypted);
|
||||
return Utils.recordToMap(account?.keys?.providerKeys?.decrypted);
|
||||
}
|
||||
|
||||
async setDecryptedProviderKeys(
|
||||
@@ -784,7 +789,7 @@ export class StateService<
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.keys.providerKeys.decrypted = this.mapToRecord(value);
|
||||
account.keys.providerKeys.decrypted = Utils.mapToRecord(value);
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
@@ -2744,7 +2749,7 @@ export class StateService<
|
||||
|
||||
protected async state(): Promise<State<TGlobalState, TAccount>> {
|
||||
const state = await this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state, {
|
||||
deserializer: (s) => State.fromJSON(s),
|
||||
deserializer: (s) => State.fromJSON(s, this.accountDeserializer),
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -2765,50 +2770,6 @@ export class StateService<
|
||||
await this.setState(updatedState);
|
||||
});
|
||||
}
|
||||
|
||||
private mapToRecord<V>(map: Map<string, V>): Record<string, V> {
|
||||
return map == null ? null : Object.fromEntries(map);
|
||||
}
|
||||
|
||||
private recordToMap<V>(record: Record<string, V>): Map<string, V> {
|
||||
return record == null ? null : new Map(Object.entries(record));
|
||||
}
|
||||
}
|
||||
|
||||
export function withPrototype<T>(
|
||||
constructor: new (...args: any[]) => T,
|
||||
converter: (input: any) => T = (i) => i
|
||||
): (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
) => { value: (...args: any[]) => Promise<T> } {
|
||||
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
return {
|
||||
value: function (...args: any[]) {
|
||||
const originalResult: Promise<T> = originalMethod.apply(this, args);
|
||||
|
||||
if (!(originalResult instanceof Promise)) {
|
||||
throw new Error(
|
||||
`Error applying prototype to stored value -- result is not a promise for method ${String(
|
||||
propertyKey
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
return originalResult.then((result) => {
|
||||
return result == null ||
|
||||
result.constructor.name === constructor.prototype.constructor.name
|
||||
? converter(result as T)
|
||||
: converter(
|
||||
Object.create(constructor.prototype, Object.getOwnPropertyDescriptors(result)) as T
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function withPrototypeForArrayMembers<T>(
|
||||
@@ -2847,7 +2808,7 @@ function withPrototypeForArrayMembers<T>(
|
||||
return result.map((r) => {
|
||||
return r == null ||
|
||||
r.constructor.name === memberConstructor.prototype.constructor.name
|
||||
? memberConverter(r)
|
||||
? r
|
||||
: memberConverter(
|
||||
Object.create(memberConstructor.prototype, Object.getOwnPropertyDescriptors(r))
|
||||
);
|
||||
|
||||
@@ -2,13 +2,13 @@ import { ApiService } from "../abstractions/api.service";
|
||||
import { CryptoService } from "../abstractions/crypto.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { UsernameGenerationService as BaseUsernameGenerationService } from "../abstractions/usernameGeneration.service";
|
||||
import { AnonAddyForwarder } from "../emailForwarders/anonAddyForwarder";
|
||||
import { DuckDuckGoForwarder } from "../emailForwarders/duckDuckGoForwarder";
|
||||
import { FastmailForwarder } from "../emailForwarders/fastmailForwarder";
|
||||
import { FirefoxRelayForwarder } from "../emailForwarders/firefoxRelayForwarder";
|
||||
import { Forwarder } from "../emailForwarders/forwarder";
|
||||
import { ForwarderOptions } from "../emailForwarders/forwarderOptions";
|
||||
import { SimpleLoginForwarder } from "../emailForwarders/simpleLoginForwarder";
|
||||
import { AnonAddyForwarder } from "../email-forwarders/anon-addy-forwarder";
|
||||
import { DuckDuckGoForwarder } from "../email-forwarders/duck-duck-go-forwarder";
|
||||
import { FastmailForwarder } from "../email-forwarders/fastmail-forwarder";
|
||||
import { FirefoxRelayForwarder } from "../email-forwarders/firefox-relay-forwarder";
|
||||
import { Forwarder } from "../email-forwarders/forwarder";
|
||||
import { ForwarderOptions } from "../email-forwarders/forwarder-options";
|
||||
import { SimpleLoginForwarder } from "../email-forwarders/simple-login-forwarder";
|
||||
import { EFFLongWordList } from "../misc/wordlist";
|
||||
|
||||
const DefaultOptions = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
export type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
|
||||
const styles: Record<BadgeTypes, string[]> = {
|
||||
primary: ["tw-bg-primary-500"],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<span class="tw-relative">
|
||||
<span [ngClass]="{ 'tw-invisible': loading }">
|
||||
<i class="bwi bwi-lg" [ngClass]="iconClass" aria-hidden="true" *ngIf="icon"></i>
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -20,8 +20,8 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
|
||||
"tw-bg-transparent",
|
||||
"tw-border-text-muted",
|
||||
"!tw-text-muted",
|
||||
"hover:tw-bg-secondary-500",
|
||||
"hover:tw-border-secondary-500",
|
||||
"hover:tw-bg-text-muted",
|
||||
"hover:tw-border-text-muted",
|
||||
"hover:!tw-text-contrast",
|
||||
"disabled:tw-bg-transparent",
|
||||
"disabled:tw-border-text-muted/60",
|
||||
@@ -76,7 +76,16 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
}
|
||||
|
||||
@Input() buttonType: ButtonTypes = null;
|
||||
|
||||
@Input() block?: boolean;
|
||||
|
||||
@Input() loading = false;
|
||||
|
||||
@Input() disabled = false;
|
||||
|
||||
@Input("bitIconButton") icon: string;
|
||||
|
||||
get iconClass() {
|
||||
return [this.icon, "!tw-m-0"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,3 +101,17 @@ export const Block = BlockTemplate.bind({});
|
||||
Block.args = {
|
||||
block: true,
|
||||
};
|
||||
|
||||
const IconTemplate: Story = (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<button bitButton [bitIconButton]="icon" buttonType="primary" class="tw-mr-2"></button>
|
||||
<button bitButton [bitIconButton]="icon"buttonType="secondary" class="tw-mr-2"></button>
|
||||
<button bitButton [bitIconButton]="icon" buttonType="danger" class="tw-mr-2"></button>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Icon = IconTemplate.bind({});
|
||||
Icon.args = {
|
||||
icon: "bwi-eye",
|
||||
};
|
||||
|
||||
104
libs/components/src/checkbox/checkbox.component.ts
Normal file
104
libs/components/src/checkbox/checkbox.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Component, HostBinding, Input, Optional, Self } from "@angular/core";
|
||||
import { NgControl, Validators } from "@angular/forms";
|
||||
|
||||
import { BitFormControlAbstraction } from "../form-control";
|
||||
|
||||
@Component({
|
||||
selector: "input[type=checkbox][bitCheckbox]",
|
||||
template: "",
|
||||
providers: [{ provide: BitFormControlAbstraction, useExisting: CheckboxComponent }],
|
||||
styles: [
|
||||
`
|
||||
:host:checked:before {
|
||||
-webkit-mask-image: url('data:image/svg+xml,%3Csvg class="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8" viewBox="0 0 10 10"%3E%3Cpath d="M0.5 6.2L2.9 8.6L9.5 1.4" fill="none" stroke="white" stroke-width="2"%3E%3C/path%3E%3C/svg%3E');
|
||||
mask-image: url('data:image/svg+xml,%3Csvg class="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8" viewBox="0 0 10 10"%3E%3Cpath d="M0.5 6.2L2.9 8.6L9.5 1.4" fill="none" stroke="white" stroke-width="2"%3E%3C/path%3E%3C/svg%3E');
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CheckboxComponent implements BitFormControlAbstraction {
|
||||
@HostBinding("class")
|
||||
protected inputClasses = [
|
||||
"tw-appearance-none",
|
||||
"tw-outline-none",
|
||||
"tw-relative",
|
||||
"tw-transition",
|
||||
"tw-cursor-pointer",
|
||||
"tw-inline-block",
|
||||
"tw-rounded",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-border-secondary-500",
|
||||
"tw-h-3.5",
|
||||
"tw-w-3.5",
|
||||
"tw-mr-1.5",
|
||||
"tw-bottom-[-1px]", // Fix checkbox looking off-center
|
||||
|
||||
"before:tw-content-['']",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:tw-inset-0",
|
||||
|
||||
"hover:tw-border-2",
|
||||
"[&>label]:tw-border-2",
|
||||
|
||||
"focus-visible:tw-ring-2",
|
||||
"focus-visible:tw-ring-offset-2",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
|
||||
"disabled:tw-cursor-auto",
|
||||
"disabled:tw-border",
|
||||
"disabled:tw-bg-secondary-100",
|
||||
|
||||
"checked:tw-bg-primary-500",
|
||||
"checked:tw-border-primary-500",
|
||||
|
||||
"checked:hover:tw-bg-primary-700",
|
||||
"checked:hover:tw-border-primary-700",
|
||||
"[&>label:hover]:checked:tw-bg-primary-700",
|
||||
"[&>label:hover]:checked:tw-border-primary-700",
|
||||
|
||||
"checked:before:tw-bg-text-contrast",
|
||||
|
||||
"checked:disabled:tw-border-secondary-100",
|
||||
"checked:disabled:tw-bg-secondary-100",
|
||||
|
||||
"checked:disabled:before:tw-bg-text-muted",
|
||||
];
|
||||
|
||||
constructor(@Optional() @Self() private ngControl?: NgControl) {}
|
||||
|
||||
@HostBinding()
|
||||
@Input()
|
||||
get disabled() {
|
||||
return this._disabled ?? this.ngControl?.disabled ?? false;
|
||||
}
|
||||
set disabled(value: any) {
|
||||
this._disabled = value != null && value !== false;
|
||||
}
|
||||
private _disabled: boolean;
|
||||
|
||||
@Input()
|
||||
get required() {
|
||||
return (
|
||||
this._required ?? this.ngControl?.control?.hasValidator(Validators.requiredTrue) ?? false
|
||||
);
|
||||
}
|
||||
set required(value: any) {
|
||||
this._required = value != null && value !== false;
|
||||
}
|
||||
private _required: boolean;
|
||||
|
||||
get hasError() {
|
||||
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
|
||||
}
|
||||
|
||||
get error(): [string, any] {
|
||||
const key = Object.keys(this.ngControl.errors)[0];
|
||||
return [key, this.ngControl.errors[key]];
|
||||
}
|
||||
}
|
||||
14
libs/components/src/checkbox/checkbox.module.ts
Normal file
14
libs/components/src/checkbox/checkbox.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { FormControlModule } from "../form-control";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
import { CheckboxComponent } from "./checkbox.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, CommonModule, FormControlModule],
|
||||
declarations: [CheckboxComponent],
|
||||
exports: [CheckboxComponent],
|
||||
})
|
||||
export class CheckboxModule {}
|
||||
104
libs/components/src/checkbox/checkbox.stories.ts
Normal file
104
libs/components/src/checkbox/checkbox.stories.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/src/abstractions/i18n.service";
|
||||
|
||||
import { FormControlModule } from "../form-control";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { CheckboxModule } from "./checkbox.module";
|
||||
|
||||
const template = `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox">
|
||||
<bit-label>Click me</bit-label>
|
||||
</bit-form-control>
|
||||
</form>`;
|
||||
|
||||
@Component({
|
||||
selector: "app-example",
|
||||
template,
|
||||
})
|
||||
class ExampleComponent {
|
||||
protected formObj = this.formBuilder.group({
|
||||
checkbox: [false, Validators.requiredTrue],
|
||||
});
|
||||
|
||||
@Input() set checked(value: boolean) {
|
||||
this.formObj.patchValue({ checkbox: value });
|
||||
}
|
||||
|
||||
@Input() set disabled(disable: boolean) {
|
||||
if (disable) {
|
||||
this.formObj.disable();
|
||||
} else {
|
||||
this.formObj.enable();
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private formBuilder: FormBuilder) {}
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Component Library/Form/Checkbox",
|
||||
component: ExampleComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [ExampleComponent],
|
||||
imports: [FormsModule, ReactiveFormsModule, FormControlModule, CheckboxModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
required: "required",
|
||||
inputRequired: "Input is required.",
|
||||
inputEmail: "Input is not an email-address.",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=3930%3A16850&t=xXPx6GJYsJfuMQPE-4",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const DefaultTemplate: Story<ExampleComponent> = (args: ExampleComponent) => ({
|
||||
props: args,
|
||||
template: `<app-example [checked]="checked" [disabled]="disabled"></app-example>`,
|
||||
});
|
||||
|
||||
export const Default = DefaultTemplate.bind({});
|
||||
|
||||
const CustomTemplate: Story = (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-flex tw-flex-col tw-w-32">
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
|
||||
A-Z
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
|
||||
</label>
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
|
||||
a-z
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
|
||||
</label>
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
|
||||
0-9
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Custom = CustomTemplate.bind({});
|
||||
1
libs/components/src/checkbox/index.ts
Normal file
1
libs/components/src/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./checkbox.module";
|
||||
@@ -0,0 +1,6 @@
|
||||
export abstract class BitFormControlAbstraction {
|
||||
disabled: boolean;
|
||||
required: boolean;
|
||||
hasError: boolean;
|
||||
error: [string, any];
|
||||
}
|
||||
13
libs/components/src/form-control/form-control.component.html
Normal file
13
libs/components/src/form-control/form-control.component.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<label [class]="labelClasses">
|
||||
<ng-content></ng-content>
|
||||
<span [class]="labelContentClasses">
|
||||
<ng-content select="bit-label"></ng-content>
|
||||
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<ng-content select="bit-hint" *ngIf="!hasError"></ng-content>
|
||||
</div>
|
||||
<div *ngIf="hasError" class="tw-mt-1 tw-text-danger">
|
||||
<i class="bwi bwi-error"></i> {{ displayError }}
|
||||
</div>
|
||||
68
libs/components/src/form-control/form-control.component.ts
Normal file
68
libs/components/src/form-control/form-control.component.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { Component, ContentChild, HostBinding, Input } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { BitFormControlAbstraction } from "./form-control.abstraction";
|
||||
|
||||
@Component({
|
||||
selector: "bit-form-control",
|
||||
templateUrl: "form-control.component.html",
|
||||
})
|
||||
export class FormControlComponent {
|
||||
@Input() label: string;
|
||||
|
||||
private _inline: boolean;
|
||||
@Input() get inline() {
|
||||
return this._inline;
|
||||
}
|
||||
set inline(value: boolean | string | null) {
|
||||
this._inline = coerceBooleanProperty(value);
|
||||
}
|
||||
|
||||
@ContentChild(BitFormControlAbstraction) protected formControl: BitFormControlAbstraction;
|
||||
|
||||
@HostBinding("class") get classes() {
|
||||
return ["tw-mb-6"].concat(this.inline ? ["tw-inline-block", "tw-mr-4"] : ["tw-block"]);
|
||||
}
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
protected get labelClasses() {
|
||||
return ["tw-transition", "tw-select-none", "tw-mb-0"].concat(
|
||||
this.formControl.disabled ? "tw-cursor-auto" : "tw-cursor-pointer"
|
||||
);
|
||||
}
|
||||
|
||||
protected get labelContentClasses() {
|
||||
return ["tw-font-semibold"].concat(
|
||||
this.formControl.disabled ? "tw-text-muted" : "tw-text-main"
|
||||
);
|
||||
}
|
||||
|
||||
get required() {
|
||||
return this.formControl.required;
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.formControl.hasError;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this.formControl.error;
|
||||
}
|
||||
|
||||
get displayError() {
|
||||
switch (this.error[0]) {
|
||||
case "required":
|
||||
return this.i18nService.t("inputRequired");
|
||||
default:
|
||||
// Attempt to show a custom error message.
|
||||
if (this.error[1]?.message) {
|
||||
return this.error[1]?.message;
|
||||
}
|
||||
|
||||
return this.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
libs/components/src/form-control/form-control.module.ts
Normal file
14
libs/components/src/form-control/form-control.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
import { FormControlComponent } from "./form-control.component";
|
||||
import { BitHintComponent } from "./hint.component";
|
||||
import { BitLabel } from "./label.directive";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule],
|
||||
declarations: [FormControlComponent, BitLabel, BitHintComponent],
|
||||
exports: [FormControlComponent, BitLabel, BitHintComponent],
|
||||
})
|
||||
export class FormControlModule {}
|
||||
3
libs/components/src/form-control/index.ts
Normal file
3
libs/components/src/form-control/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./form-control.module";
|
||||
export * from "./form-control.abstraction";
|
||||
export * from "./form-control.component";
|
||||
@@ -1,3 +1,5 @@
|
||||
export type InputTypes = "text" | "password" | "number" | "datetime-local" | "email" | "checkbox";
|
||||
|
||||
export abstract class BitFormFieldControl {
|
||||
ariaDescribedBy: string;
|
||||
id: string;
|
||||
@@ -5,4 +7,7 @@ export abstract class BitFormFieldControl {
|
||||
required: boolean;
|
||||
hasError: boolean;
|
||||
error: [string, any];
|
||||
type?: InputTypes;
|
||||
spellcheck?: boolean;
|
||||
focus?: () => void;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
ViewChild,
|
||||
} from "@angular/core";
|
||||
|
||||
import { BitHintComponent } from "../form-control/hint.component";
|
||||
|
||||
import { BitErrorComponent } from "./error.component";
|
||||
import { BitFormFieldControl } from "./form-field-control";
|
||||
import { BitHintComponent } from "./hint.component";
|
||||
import { BitPrefixDirective } from "./prefix.directive";
|
||||
import { BitSuffixDirective } from "./suffix.directive";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { FormControlModule } from "../form-control";
|
||||
import { BitInputDirective } from "../input/input.directive";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { MultiSelectComponent } from "../multi-select/multi-select.component";
|
||||
@@ -9,32 +10,30 @@ import { SharedModule } from "../shared";
|
||||
import { BitErrorSummary } from "./error-summary.component";
|
||||
import { BitErrorComponent } from "./error.component";
|
||||
import { BitFormFieldComponent } from "./form-field.component";
|
||||
import { BitHintComponent } from "./hint.component";
|
||||
import { BitLabel } from "./label.directive";
|
||||
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
|
||||
import { BitPrefixDirective } from "./prefix.directive";
|
||||
import { BitSuffixDirective } from "./suffix.directive";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, InputModule, MultiSelectModule],
|
||||
exports: [
|
||||
BitErrorComponent,
|
||||
BitErrorSummary,
|
||||
BitFormFieldComponent,
|
||||
BitHintComponent,
|
||||
BitLabel,
|
||||
BitPrefixDirective,
|
||||
BitSuffixDirective,
|
||||
BitInputDirective,
|
||||
MultiSelectComponent,
|
||||
],
|
||||
imports: [SharedModule, FormControlModule, InputModule, MultiSelectModule],
|
||||
declarations: [
|
||||
BitErrorComponent,
|
||||
BitErrorSummary,
|
||||
BitFormFieldComponent,
|
||||
BitHintComponent,
|
||||
BitLabel,
|
||||
BitPasswordInputToggleDirective,
|
||||
BitPrefixDirective,
|
||||
BitSuffixDirective,
|
||||
],
|
||||
exports: [
|
||||
BitErrorComponent,
|
||||
BitErrorSummary,
|
||||
BitFormFieldComponent,
|
||||
BitInputDirective,
|
||||
BitPasswordInputToggleDirective,
|
||||
BitPrefixDirective,
|
||||
BitSuffixDirective,
|
||||
MultiSelectComponent,
|
||||
FormControlModule,
|
||||
],
|
||||
})
|
||||
export class FormFieldModule {}
|
||||
|
||||
@@ -12,7 +12,9 @@ import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { CheckboxModule } from "../checkbox";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { RadioButtonModule } from "../radio-button";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { BitFormFieldComponent } from "./form-field.component";
|
||||
@@ -23,7 +25,15 @@ export default {
|
||||
component: BitFormFieldComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule],
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormFieldModule,
|
||||
InputModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
RadioButtonModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
@@ -55,6 +65,8 @@ const formObj = fb.group({
|
||||
const defaultFormObj = fb.group({
|
||||
name: ["", [Validators.required]],
|
||||
email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]],
|
||||
terms: [false, [Validators.requiredTrue]],
|
||||
updates: ["yes"],
|
||||
});
|
||||
|
||||
// Custom error message, `message` is shown as the error message
|
||||
@@ -166,13 +178,9 @@ const ButtonGroupTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldCom
|
||||
template: `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<input bitInput placeholder="Placeholder" />
|
||||
<button bitSuffix bitButton>
|
||||
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
|
||||
</button>
|
||||
<button bitSuffix bitButton>
|
||||
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
|
||||
</button>
|
||||
<input bitInput placeholder="Placeholder" type="password" />
|
||||
<button bitSuffix bitButton bitIconButton="bwi-eye"></button>
|
||||
<button bitSuffix bitButton bitIconButton="bwi-clone"></button>
|
||||
</bit-form-field>
|
||||
`,
|
||||
});
|
||||
@@ -188,12 +196,8 @@ const DisabledButtonInputGroupTemplate: Story<BitFormFieldComponent> = (
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<input bitInput placeholder="Placeholder" disabled />
|
||||
<button bitSuffix bitButton disabled>
|
||||
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
|
||||
</button>
|
||||
<button bitSuffix bitButton>
|
||||
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
|
||||
</button>
|
||||
<button bitSuffix bitButton bitIconButton="bwi-eye" disabled></button>
|
||||
<button bitSuffix bitButton bitIconButton="bwi-clone"></button>
|
||||
</bit-form-field>
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
AfterContentInit,
|
||||
Directive,
|
||||
EventEmitter,
|
||||
Host,
|
||||
HostListener,
|
||||
Input,
|
||||
OnChanges,
|
||||
Output,
|
||||
} from "@angular/core";
|
||||
|
||||
import { ButtonComponent } from "../button";
|
||||
|
||||
import { BitFormFieldComponent } from "./form-field.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitPasswordInputToggle]",
|
||||
})
|
||||
export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges {
|
||||
@Input() toggled = false;
|
||||
@Output() toggledChange = new EventEmitter<boolean>();
|
||||
|
||||
@HostListener("click") onClick() {
|
||||
this.toggled = !this.toggled;
|
||||
this.toggledChange.emit(this.toggled);
|
||||
|
||||
this.update();
|
||||
|
||||
this.formField.input?.focus();
|
||||
}
|
||||
|
||||
constructor(@Host() private button: ButtonComponent, private formField: BitFormFieldComponent) {}
|
||||
|
||||
get icon() {
|
||||
return this.toggled ? "bwi-eye-slash" : "bwi-eye";
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.update();
|
||||
}
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
this.toggled = this.formField.input.type !== "password";
|
||||
this.button.icon = this.icon;
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.button.icon = this.icon;
|
||||
if (this.formField.input?.type != null) {
|
||||
this.formField.input.type = this.toggled ? "text" : "password";
|
||||
this.formField.input.spellcheck = this.toggled ? false : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
libs/components/src/form-field/password-input-toggle.spec.ts
Normal file
100
libs/components/src/form-field/password-input-toggle.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Component, DebugElement } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { ButtonComponent, ButtonModule } from "../button";
|
||||
import { InputModule } from "../input/input.module";
|
||||
|
||||
import { BitFormFieldControl } from "./form-field-control";
|
||||
import { BitFormFieldComponent } from "./form-field.component";
|
||||
import { FormFieldModule } from "./form-field.module";
|
||||
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
|
||||
|
||||
@Component({
|
||||
selector: "test-form-field",
|
||||
template: `
|
||||
<form>
|
||||
<bit-form-field>
|
||||
<bit-label>Password</bit-label>
|
||||
<input bitInput type="password" />
|
||||
<button type="button" bitButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
class TestFormFieldComponent {}
|
||||
|
||||
describe("PasswordInputToggle", () => {
|
||||
let fixture: ComponentFixture<TestFormFieldComponent>;
|
||||
let button: ButtonComponent;
|
||||
let input: BitFormFieldControl;
|
||||
let toggle: DebugElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FormFieldModule, ButtonModule, InputModule],
|
||||
declarations: [TestFormFieldComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestFormFieldComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
toggle = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective));
|
||||
const buttonEl = fixture.debugElement.query(By.directive(ButtonComponent));
|
||||
button = buttonEl.componentInstance;
|
||||
const formFieldEl = fixture.debugElement.query(By.directive(BitFormFieldComponent));
|
||||
const formField: BitFormFieldComponent = formFieldEl.componentInstance;
|
||||
input = formField.input;
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("has correct icon", () => {
|
||||
expect(button.icon).toBe("bwi-eye");
|
||||
});
|
||||
|
||||
it("input is type password", () => {
|
||||
expect(input.type).toBe("password");
|
||||
});
|
||||
|
||||
it("spellcheck is disabled", () => {
|
||||
expect(input.spellcheck).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when toggled", () => {
|
||||
beforeEach(() => {
|
||||
toggle.triggerEventHandler("click");
|
||||
});
|
||||
|
||||
it("has correct icon", () => {
|
||||
expect(button.icon).toBe("bwi-eye-slash");
|
||||
});
|
||||
|
||||
it("input is type text", () => {
|
||||
expect(input.type).toBe("text");
|
||||
});
|
||||
|
||||
it("spellcheck is disabled", () => {
|
||||
expect(input.spellcheck).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when toggled twice", () => {
|
||||
beforeEach(() => {
|
||||
toggle.triggerEventHandler("click");
|
||||
toggle.triggerEventHandler("click");
|
||||
});
|
||||
|
||||
it("has correct icon", () => {
|
||||
expect(button.icon).toBe("bwi-eye");
|
||||
});
|
||||
|
||||
it("input is type password", () => {
|
||||
expect(input.type).toBe("password");
|
||||
});
|
||||
|
||||
it("spellcheck is disabled", () => {
|
||||
expect(input.spellcheck).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { InputModule } from "../input/input.module";
|
||||
|
||||
import { FormFieldModule } from "./form-field.module";
|
||||
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Form/Password Toggle",
|
||||
component: BitPasswordInputToggleDirective,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17689",
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Directive for toggling the visibility of a password input. Works by either having living inside a `bit-form-field` or by using the `toggled` two-way binding.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<BitPasswordInputToggleDirective> = (
|
||||
args: BitPasswordInputToggleDirective
|
||||
) => ({
|
||||
props: {
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
<form>
|
||||
<bit-form-field>
|
||||
<bit-label>Password</bit-label>
|
||||
<input bitInput type="password" />
|
||||
<button type="button" bitButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.props = {};
|
||||
|
||||
const TemplateBinding: Story<BitPasswordInputToggleDirective> = (
|
||||
args: BitPasswordInputToggleDirective
|
||||
) => ({
|
||||
props: {
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
<form>
|
||||
<bit-form-field>
|
||||
<bit-label>Password</bit-label>
|
||||
<input bitInput type="password" />
|
||||
<button type="button" bitButton bitSuffix bitPasswordInputToggle [(toggled)]="toggled"></button>
|
||||
</bit-form-field>
|
||||
|
||||
<label class="tw-text-main">
|
||||
Checked:
|
||||
<input type="checkbox" [(ngModel)]="toggled" [ngModelOptions]="{standalone: true}" />
|
||||
</label>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Binding = TemplateBinding.bind({});
|
||||
Binding.props = {
|
||||
toggled: false,
|
||||
};
|
||||
111
libs/components/src/form/form.stories.ts
Normal file
111
libs/components/src/form/form.stories.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
AbstractControl,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
} from "@angular/forms";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { CheckboxModule } from "../checkbox";
|
||||
import { FormControlModule } from "../form-control";
|
||||
import { FormFieldModule } from "../form-field";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { RadioButtonModule } from "../radio-button";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Form",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormFieldModule,
|
||||
InputModule,
|
||||
ButtonModule,
|
||||
FormControlModule,
|
||||
CheckboxModule,
|
||||
RadioButtonModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
required: "required",
|
||||
checkboxRequired: "Option is required",
|
||||
inputRequired: "Input is required.",
|
||||
inputEmail: "Input is not an email-address.",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A17689",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const fb = new FormBuilder();
|
||||
const exampleFormObj = fb.group({
|
||||
name: ["", [Validators.required]],
|
||||
email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]],
|
||||
terms: [false, [Validators.requiredTrue]],
|
||||
updates: ["yes"],
|
||||
});
|
||||
|
||||
// Custom error message, `message` is shown as the error message
|
||||
function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const forbidden = nameRe.test(control.value);
|
||||
return forbidden ? { forbiddenName: { message: "forbiddenName" } } : null;
|
||||
};
|
||||
}
|
||||
|
||||
const FullExampleTemplate: Story = (args) => ({
|
||||
props: {
|
||||
formObj: exampleFormObj,
|
||||
submit: () => exampleFormObj.markAllAsTouched(),
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
<form [formGroup]="formObj" (ngSubmit)="submit()">
|
||||
<bit-form-field>
|
||||
<bit-label>Name</bit-label>
|
||||
<input bitInput formControlName="name" />
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Email</bit-label>
|
||||
<input bitInput formControlName="email" />
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-control>
|
||||
<bit-label>Agree to terms</bit-label>
|
||||
<input type="checkbox" bitCheckbox formControlName="terms">
|
||||
<bit-hint>Required for the service to work properly</bit-hint>
|
||||
</bit-form-control>
|
||||
|
||||
<bit-radio-group formControlName="updates">
|
||||
<bit-label>Subscribe to updates?</bit-label>
|
||||
<bit-radio-button value="yes">Yes</bit-radio-button>
|
||||
<bit-radio-button value="no">No</bit-radio-button>
|
||||
<bit-radio-button value="later">Decide later</bit-radio-button>
|
||||
</bit-radio-group>
|
||||
|
||||
<button type="submit" bitButton buttonType="primary">Submit</button>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
export const FullExample = FullExampleTemplate.bind({});
|
||||
@@ -79,7 +79,7 @@ const sizes: Record<IconButtonSize, string[]> = {
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "button[bitIconButton]",
|
||||
selector: "button[bitIconButton]:not(button[bitButton])",
|
||||
templateUrl: "icon-button.component.html",
|
||||
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from "./badge";
|
||||
export * from "./banner";
|
||||
export * from "./button";
|
||||
export * from "./callout";
|
||||
export * from "./checkbox";
|
||||
export * from "./dialog";
|
||||
export * from "./form-field";
|
||||
export * from "./icon-button";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Directive, HostBinding, Input, Optional, Self } from "@angular/core";
|
||||
import { Directive, ElementRef, HostBinding, Input, NgZone, Optional, Self } from "@angular/core";
|
||||
import { NgControl, Validators } from "@angular/forms";
|
||||
|
||||
import { BitFormFieldControl } from "../form-field/form-field-control";
|
||||
import { BitFormFieldControl, InputTypes } from "../form-field/form-field-control";
|
||||
|
||||
// Increments for each instance of this component
|
||||
let nextId = 0;
|
||||
@@ -41,14 +41,14 @@ export class BitInputDirective implements BitFormFieldControl {
|
||||
|
||||
@HostBinding("attr.aria-describedby") ariaDescribedBy: string;
|
||||
|
||||
get labelForId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
@HostBinding("attr.aria-invalid") get ariaInvalid() {
|
||||
return this.hasError ? true : undefined;
|
||||
}
|
||||
|
||||
@HostBinding("attr.type") @Input() type?: InputTypes;
|
||||
|
||||
@HostBinding("attr.spellcheck") @Input() spellcheck?: boolean;
|
||||
|
||||
@HostBinding()
|
||||
@Input()
|
||||
get required() {
|
||||
@@ -62,6 +62,10 @@ export class BitInputDirective implements BitFormFieldControl {
|
||||
@Input() hasPrefix = false;
|
||||
@Input() hasSuffix = false;
|
||||
|
||||
get labelForId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
|
||||
}
|
||||
@@ -70,5 +74,18 @@ export class BitInputDirective implements BitFormFieldControl {
|
||||
const key = Object.keys(this.ngControl.errors)[0];
|
||||
return [key, this.ngControl.errors[key]];
|
||||
}
|
||||
constructor(@Optional() @Self() private ngControl: NgControl) {}
|
||||
|
||||
constructor(
|
||||
@Optional() @Self() private ngControl: NgControl,
|
||||
private ngZone: NgZone,
|
||||
private elementRef: ElementRef<HTMLInputElement>
|
||||
) {}
|
||||
|
||||
focus() {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
const end = this.elementRef.nativeElement.value.length;
|
||||
this.elementRef.nativeElement.setSelectionRange(end, end);
|
||||
this.elementRef.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
3
libs/components/src/radio-button/index.ts
Normal file
3
libs/components/src/radio-button/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./radio-button.module";
|
||||
export * from "./radio-button.component";
|
||||
export * from "./radio-group.component";
|
||||
14
libs/components/src/radio-button/radio-button.component.html
Normal file
14
libs/components/src/radio-button/radio-button.component.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<bit-form-control inline>
|
||||
<input
|
||||
type="radio"
|
||||
bitRadio
|
||||
[id]="inputId"
|
||||
[name]="name"
|
||||
[disabled]="disabled"
|
||||
[value]="value"
|
||||
[checked]="selected"
|
||||
(change)="onInputChange()"
|
||||
(blur)="onBlur()"
|
||||
/>
|
||||
<bit-label><ng-content></ng-content></bit-label>
|
||||
</bit-form-control>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { RadioButtonModule } from "./radio-button.module";
|
||||
import { RadioGroupComponent } from "./radio-group.component";
|
||||
|
||||
describe("RadioButton", () => {
|
||||
let mockGroupComponent: MockedButtonGroupComponent;
|
||||
let fixture: ComponentFixture<TestApp>;
|
||||
let testAppComponent: TestApp;
|
||||
let radioButton: HTMLInputElement;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
mockGroupComponent = new MockedButtonGroupComponent();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RadioButtonModule],
|
||||
declarations: [TestApp],
|
||||
providers: [
|
||||
{ provide: RadioGroupComponent, useValue: mockGroupComponent },
|
||||
{ provide: I18nService, useValue: new I18nMockService({}) },
|
||||
],
|
||||
});
|
||||
|
||||
TestBed.compileComponents();
|
||||
fixture = TestBed.createComponent(TestApp);
|
||||
fixture.detectChanges();
|
||||
testAppComponent = fixture.debugElement.componentInstance;
|
||||
radioButton = fixture.debugElement.query(By.css("input[type=radio]")).nativeElement;
|
||||
}));
|
||||
|
||||
it("should emit value when clicking on radio button", () => {
|
||||
testAppComponent.value = "value";
|
||||
fixture.detectChanges();
|
||||
|
||||
radioButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockGroupComponent.onInputChange).toHaveBeenCalledWith("value");
|
||||
});
|
||||
|
||||
it("should check radio button when selected matches value", () => {
|
||||
testAppComponent.value = "value";
|
||||
fixture.detectChanges();
|
||||
|
||||
mockGroupComponent.selected = "value";
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(radioButton.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("should not check radio button when selected does not match value", () => {
|
||||
testAppComponent.value = "value";
|
||||
fixture.detectChanges();
|
||||
|
||||
mockGroupComponent.selected = "nonMatchingValue";
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(radioButton.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
class MockedButtonGroupComponent implements Partial<RadioGroupComponent> {
|
||||
onInputChange = jest.fn();
|
||||
selected = null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "test-app",
|
||||
template: ` <bit-radio-button [value]="value">Element</bit-radio-button>`,
|
||||
})
|
||||
class TestApp {
|
||||
value?: string;
|
||||
}
|
||||
40
libs/components/src/radio-button/radio-button.component.ts
Normal file
40
libs/components/src/radio-button/radio-button.component.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
|
||||
import { RadioGroupComponent } from "./radio-group.component";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
@Component({
|
||||
selector: "bit-radio-button",
|
||||
templateUrl: "radio-button.component.html",
|
||||
})
|
||||
export class RadioButtonComponent {
|
||||
@HostBinding("attr.id") @Input() id = `bit-radio-button-${nextId++}`;
|
||||
@Input() value: unknown;
|
||||
|
||||
constructor(private groupComponent: RadioGroupComponent) {}
|
||||
|
||||
get inputId() {
|
||||
return `${this.id}-input`;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.groupComponent.name;
|
||||
}
|
||||
|
||||
get selected() {
|
||||
return this.groupComponent.selected === this.value;
|
||||
}
|
||||
|
||||
get disabled() {
|
||||
return this.groupComponent.disabled;
|
||||
}
|
||||
|
||||
protected onInputChange() {
|
||||
this.groupComponent.onInputChange(this.value);
|
||||
}
|
||||
|
||||
protected onBlur() {
|
||||
this.groupComponent.onBlur();
|
||||
}
|
||||
}
|
||||
15
libs/components/src/radio-button/radio-button.module.ts
Normal file
15
libs/components/src/radio-button/radio-button.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { FormControlModule } from "../form-control";
|
||||
|
||||
import { RadioButtonComponent } from "./radio-button.component";
|
||||
import { RadioGroupComponent } from "./radio-group.component";
|
||||
import { RadioInputComponent } from "./radio-input.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, FormControlModule],
|
||||
declarations: [RadioInputComponent, RadioButtonComponent, RadioGroupComponent],
|
||||
exports: [FormControlModule, RadioInputComponent, RadioButtonComponent, RadioGroupComponent],
|
||||
})
|
||||
export class RadioButtonModule {}
|
||||
107
libs/components/src/radio-button/radio-button.stories.ts
Normal file
107
libs/components/src/radio-button/radio-button.stories.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder } from "@angular/forms";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { RadioButtonModule } from "./radio-button.module";
|
||||
|
||||
const template = `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-radio-group formControlName="radio" aria-label="Example radio group">
|
||||
<bit-label *ngIf="label">Group of radio buttons</bit-label>
|
||||
<bit-radio-button [value]="TestValue.First" id="radio-first">First</bit-radio-button>
|
||||
<bit-radio-button [value]="TestValue.Second" id="radio-second">Second</bit-radio-button>
|
||||
<bit-radio-button [value]="TestValue.Third" id="radio-third">Third</bit-radio-button>
|
||||
</bit-radio-group>
|
||||
</form>`;
|
||||
|
||||
enum TestValue {
|
||||
First,
|
||||
Second,
|
||||
Third,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-example",
|
||||
template,
|
||||
})
|
||||
class ExampleComponent {
|
||||
protected TestValue = TestValue;
|
||||
|
||||
protected formObj = this.formBuilder.group({
|
||||
radio: TestValue.First,
|
||||
});
|
||||
|
||||
@Input() label: boolean;
|
||||
|
||||
@Input() set selected(value: TestValue) {
|
||||
this.formObj.patchValue({ radio: value });
|
||||
}
|
||||
|
||||
@Input() set disabled(disable: boolean) {
|
||||
if (disable) {
|
||||
this.formObj.disable();
|
||||
} else {
|
||||
this.formObj.enable();
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private formBuilder: FormBuilder) {}
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Component Library/Form/Radio Button",
|
||||
component: ExampleComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [ExampleComponent],
|
||||
imports: [FormsModule, ReactiveFormsModule, RadioButtonModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
required: "required",
|
||||
inputRequired: "Input is required.",
|
||||
inputEmail: "Input is not an email-address.",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=3930%3A16850&t=xXPx6GJYsJfuMQPE-4",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
selected: TestValue.First,
|
||||
disabled: false,
|
||||
label: true,
|
||||
},
|
||||
argTypes: {
|
||||
selected: {
|
||||
options: [TestValue.First, TestValue.Second, TestValue.Third],
|
||||
control: {
|
||||
type: "inline-radio",
|
||||
labels: {
|
||||
[TestValue.First]: "First",
|
||||
[TestValue.Second]: "Second",
|
||||
[TestValue.Third]: "Third",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const DefaultTemplate: Story<ExampleComponent> = (args: ExampleComponent) => ({
|
||||
props: args,
|
||||
template: `<app-example [selected]="selected" [disabled]="disabled" [label]="label"></app-example>`,
|
||||
});
|
||||
|
||||
export const Default = DefaultTemplate.bind({});
|
||||
14
libs/components/src/radio-button/radio-group.component.html
Normal file
14
libs/components/src/radio-button/radio-group.component.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<ng-container *ngIf="label">
|
||||
<fieldset>
|
||||
<legend class="tw-mb-1 tw-block tw-text-sm tw-font-semibold tw-text-main">
|
||||
<ng-content select="bit-label"></ng-content>
|
||||
</legend>
|
||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||
</fieldset>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!label">
|
||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #content><ng-content></ng-content></ng-template>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { RadioButtonComponent } from "./radio-button.component";
|
||||
import { RadioButtonModule } from "./radio-button.module";
|
||||
|
||||
describe("RadioGroupComponent", () => {
|
||||
let fixture: ComponentFixture<TestApp>;
|
||||
let testAppComponent: TestApp;
|
||||
let buttonElements: RadioButtonComponent[];
|
||||
let radioButtons: HTMLInputElement[];
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [FormsModule, RadioButtonModule],
|
||||
declarations: [TestApp],
|
||||
providers: [{ provide: I18nService, useValue: new I18nMockService({}) }],
|
||||
});
|
||||
|
||||
TestBed.compileComponents();
|
||||
fixture = TestBed.createComponent(TestApp);
|
||||
fixture.detectChanges();
|
||||
testAppComponent = fixture.debugElement.componentInstance;
|
||||
buttonElements = fixture.debugElement
|
||||
.queryAll(By.css("bit-radio-button"))
|
||||
.map((e) => e.componentInstance);
|
||||
radioButtons = fixture.debugElement
|
||||
.queryAll(By.css("input[type=radio]"))
|
||||
.map((e) => e.nativeElement);
|
||||
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it("should select second element when setting selected to second", async () => {
|
||||
testAppComponent.selected = "second";
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(buttonElements[1].selected).toBe(true);
|
||||
});
|
||||
|
||||
it("should not select second element when setting selected to third", async () => {
|
||||
testAppComponent.selected = "third";
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(buttonElements[1].selected).toBe(false);
|
||||
});
|
||||
|
||||
it("should emit new value when changing selection by clicking on radio button", async () => {
|
||||
testAppComponent.selected = "first";
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
radioButtons[1].click();
|
||||
|
||||
expect(testAppComponent.selected).toBe("second");
|
||||
});
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: "test-app",
|
||||
template: `
|
||||
<bit-radio-group [(ngModel)]="selected">
|
||||
<bit-radio-button value="first">First</bit-radio-button>
|
||||
<bit-radio-button value="second">Second</bit-radio-button>
|
||||
<bit-radio-button value="third">Third</bit-radio-button>
|
||||
</bit-radio-group>
|
||||
`,
|
||||
})
|
||||
class TestApp {
|
||||
selected?: string;
|
||||
}
|
||||
63
libs/components/src/radio-button/radio-group.component.ts
Normal file
63
libs/components/src/radio-button/radio-group.component.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Component, ContentChild, HostBinding, Input, Optional, Self } from "@angular/core";
|
||||
import { ControlValueAccessor, NgControl } from "@angular/forms";
|
||||
|
||||
import { BitLabel } from "../form-control/label.directive";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
@Component({
|
||||
selector: "bit-radio-group",
|
||||
templateUrl: "radio-group.component.html",
|
||||
})
|
||||
export class RadioGroupComponent implements ControlValueAccessor {
|
||||
selected: unknown;
|
||||
disabled = false;
|
||||
|
||||
private _name?: string;
|
||||
@Input() get name() {
|
||||
return this._name ?? this.ngControl?.name?.toString();
|
||||
}
|
||||
set name(value: string) {
|
||||
this._name = value;
|
||||
}
|
||||
|
||||
@HostBinding("attr.role") role = "radiogroup";
|
||||
@HostBinding("attr.id") @Input() id = `bit-radio-group-${nextId++}`;
|
||||
|
||||
@ContentChild(BitLabel) protected label: BitLabel;
|
||||
|
||||
constructor(@Optional() @Self() private ngControl?: NgControl) {
|
||||
if (ngControl != null) {
|
||||
ngControl.valueAccessor = this;
|
||||
}
|
||||
}
|
||||
|
||||
// ControlValueAccessor
|
||||
onChange: (value: unknown) => void;
|
||||
onTouched: () => void;
|
||||
|
||||
writeValue(value: boolean): void {
|
||||
this.selected = value;
|
||||
}
|
||||
|
||||
registerOnChange(fn: (value: unknown) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
}
|
||||
|
||||
onInputChange(value: unknown) {
|
||||
this.selected = value;
|
||||
this.onChange(this.selected);
|
||||
}
|
||||
|
||||
onBlur() {
|
||||
this.onTouched();
|
||||
}
|
||||
}
|
||||
99
libs/components/src/radio-button/radio-input.component.ts
Normal file
99
libs/components/src/radio-button/radio-input.component.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Component, HostBinding, Input, Optional, Self } from "@angular/core";
|
||||
import { NgControl, Validators } from "@angular/forms";
|
||||
|
||||
import { BitFormControlAbstraction } from "../form-control";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
@Component({
|
||||
selector: "input[type=radio][bitRadio]",
|
||||
template: "",
|
||||
providers: [{ provide: BitFormControlAbstraction, useExisting: RadioInputComponent }],
|
||||
})
|
||||
export class RadioInputComponent implements BitFormControlAbstraction {
|
||||
@HostBinding("attr.id") @Input() id = `bit-radio-input-${nextId++}`;
|
||||
|
||||
@HostBinding("class")
|
||||
protected inputClasses = [
|
||||
"tw-appearance-none",
|
||||
"tw-outline-none",
|
||||
"tw-relative",
|
||||
"tw-transition",
|
||||
"tw-cursor-pointer",
|
||||
"tw-inline-block",
|
||||
"tw-rounded-full",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-border-secondary-500",
|
||||
"tw-w-3.5",
|
||||
"tw-h-3.5",
|
||||
"tw-mr-1.5",
|
||||
"tw-bottom-[-1px]", // Fix checkbox looking off-center
|
||||
|
||||
"hover:tw-border-2",
|
||||
"[&>label:hover]:tw-border-2",
|
||||
|
||||
"before:tw-content-['']",
|
||||
"before:tw-transition",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:tw-rounded-full",
|
||||
"before:tw-inset-[2px]",
|
||||
|
||||
"focus-visible:tw-ring-2",
|
||||
"focus-visible:tw-ring-offset-2",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
|
||||
"disabled:tw-cursor-auto",
|
||||
"disabled:tw-border",
|
||||
"disabled:tw-bg-secondary-100",
|
||||
|
||||
"checked:tw-bg-text-contrast",
|
||||
"checked:tw-border-primary-500",
|
||||
|
||||
"checked:hover:tw-border",
|
||||
"checked:hover:tw-border-primary-700",
|
||||
"checked:hover:before:tw-bg-primary-700",
|
||||
"[&>label:hover]:checked:tw-bg-primary-700",
|
||||
"[&>label:hover]:checked:tw-border-primary-700",
|
||||
|
||||
"checked:before:tw-bg-primary-500",
|
||||
|
||||
"checked:disabled:tw-border-secondary-100",
|
||||
"checked:disabled:tw-bg-secondary-100",
|
||||
|
||||
"checked:disabled:before:tw-bg-text-muted",
|
||||
];
|
||||
|
||||
constructor(@Optional() @Self() private ngControl?: NgControl) {}
|
||||
|
||||
@HostBinding()
|
||||
@Input()
|
||||
get disabled() {
|
||||
return this._disabled ?? this.ngControl?.disabled ?? false;
|
||||
}
|
||||
set disabled(value: any) {
|
||||
this._disabled = value != null && value !== false;
|
||||
}
|
||||
private _disabled: boolean;
|
||||
|
||||
@Input()
|
||||
get required() {
|
||||
return (
|
||||
this._required ?? this.ngControl?.control?.hasValidator(Validators.requiredTrue) ?? false
|
||||
);
|
||||
}
|
||||
set required(value: any) {
|
||||
this._required = value != null && value !== false;
|
||||
}
|
||||
private _required: boolean;
|
||||
|
||||
get hasError() {
|
||||
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
|
||||
}
|
||||
|
||||
get error(): [string, any] {
|
||||
const key = Object.keys(this.ngControl.errors)[0];
|
||||
return [key, this.ngControl.errors[key]];
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||
|
||||
const { compilerOptions } = require("../shared/tsconfig.libs");
|
||||
|
||||
const sharedConfig = require("../../libs/shared/jest.config.base");
|
||||
|
||||
module.exports = {
|
||||
...sharedConfig,
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/",
|
||||
}),
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@bitwarden/electron",
|
||||
"version": "0.0.0",
|
||||
"description": "Common code used across Bitwarden JavaScript projects.",
|
||||
"keywords": [
|
||||
"bitwarden"
|
||||
],
|
||||
"author": "Bitwarden Inc.",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bitwarden/jslib"
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist/**/*",
|
||||
"build": "npm run clean && tsc",
|
||||
"build:watch": "npm run clean && tsc -watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitwarden/common": "file:../common"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ElectronLogService } from "@bitwarden/electron/services/electronLog.service";
|
||||
|
||||
describe("ElectronLogService", () => {
|
||||
it("sets dev based on electron method", () => {
|
||||
process.env.ELECTRON_IS_DEV = "1";
|
||||
const logService = new ElectronLogService();
|
||||
expect(logService).toEqual(expect.objectContaining({ isDev: true }) as any);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { cleanUserAgent } from "@bitwarden/electron/utils";
|
||||
|
||||
const expectedUserAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${process.versions.chrome} Safari/537.36`;
|
||||
|
||||
describe("cleanUserAgent", () => {
|
||||
it("cleans mac agent", () => {
|
||||
const initialMacAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Bitwarden/${process.version} Chrome/${process.versions.chrome} Electron/${process.versions.electron} Safari/537.36`;
|
||||
expect(cleanUserAgent(initialMacAgent)).toEqual(expectedUserAgent);
|
||||
});
|
||||
|
||||
it("cleans windows agent", () => {
|
||||
const initialWindowsAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Bitwarden/${process.version} Chrome/${process.versions.chrome} Electron/${process.versions.electron} Safari/537.36`;
|
||||
expect(cleanUserAgent(initialWindowsAgent)).toEqual(expectedUserAgent);
|
||||
});
|
||||
|
||||
it("cleans linux agent", () => {
|
||||
const initialWindowsAgent = `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Bitwarden/${process.version} Chrome/${process.versions.chrome} Electron/${process.versions.electron} Safari/537.36`;
|
||||
expect(cleanUserAgent(initialWindowsAgent)).toEqual(expectedUserAgent);
|
||||
});
|
||||
|
||||
it("does not change version numbers", () => {
|
||||
const expected = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36`;
|
||||
const initialAgent = `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Bitwarden/1.28.3 Chrome/87.0.4280.141 Electron/11.4.5 Safari/537.36`;
|
||||
|
||||
expect(cleanUserAgent(initialAgent)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
import { Menu, MenuItemConstructorOptions } from "electron";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { WindowMain } from "./window.main";
|
||||
|
||||
export class BaseMenu {
|
||||
protected editMenuItemOptions: MenuItemConstructorOptions;
|
||||
protected viewSubMenuItemOptions: MenuItemConstructorOptions[];
|
||||
protected windowMenuItemOptions: MenuItemConstructorOptions;
|
||||
protected macAppMenuItemOptions: MenuItemConstructorOptions[];
|
||||
protected macWindowSubmenuOptions: MenuItemConstructorOptions[];
|
||||
|
||||
constructor(protected i18nService: I18nService, protected windowMain: WindowMain) {}
|
||||
|
||||
protected initProperties() {
|
||||
this.editMenuItemOptions = {
|
||||
label: this.i18nService.t("edit"),
|
||||
submenu: [
|
||||
{
|
||||
label: this.i18nService.t("undo"),
|
||||
role: "undo",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("redo"),
|
||||
role: "redo",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("cut"),
|
||||
role: "cut",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("copy"),
|
||||
role: "copy",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("paste"),
|
||||
role: "paste",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("selectAll"),
|
||||
role: "selectAll",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.viewSubMenuItemOptions = [
|
||||
{
|
||||
label: this.i18nService.t("zoomIn"),
|
||||
role: "zoomIn",
|
||||
accelerator: "CmdOrCtrl+=",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("zoomOut"),
|
||||
role: "zoomOut",
|
||||
accelerator: "CmdOrCtrl+-",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("resetZoom"),
|
||||
role: "resetZoom",
|
||||
accelerator: "CmdOrCtrl+0",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("toggleFullScreen"),
|
||||
role: "togglefullscreen",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("reload"),
|
||||
role: "forceReload",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("toggleDevTools"),
|
||||
role: "toggleDevTools",
|
||||
accelerator: "F12",
|
||||
},
|
||||
];
|
||||
|
||||
this.windowMenuItemOptions = {
|
||||
label: this.i18nService.t("window"),
|
||||
role: "window",
|
||||
submenu: [
|
||||
{
|
||||
label: this.i18nService.t("minimize"),
|
||||
role: "minimize",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("close"),
|
||||
role: "close",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
this.macAppMenuItemOptions = [
|
||||
{
|
||||
label: this.i18nService.t("services"),
|
||||
role: "services",
|
||||
submenu: [],
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("hideBitwarden"),
|
||||
role: "hide",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("hideOthers"),
|
||||
role: "hideOthers",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("showAll"),
|
||||
role: "unhide",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("quitBitwarden"),
|
||||
role: "quit",
|
||||
},
|
||||
];
|
||||
|
||||
this.macWindowSubmenuOptions = [
|
||||
{
|
||||
label: this.i18nService.t("minimize"),
|
||||
role: "minimize",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("zoom"),
|
||||
role: "zoom",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("bringAllToFront"),
|
||||
role: "front",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("close"),
|
||||
role: "close",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected initContextMenu() {
|
||||
if (this.windowMain.win == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: this.i18nService.t("copy"),
|
||||
role: "copy",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("selectAll"),
|
||||
role: "selectAll",
|
||||
},
|
||||
]);
|
||||
|
||||
const inputMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: this.i18nService.t("undo"),
|
||||
role: "undo",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("redo"),
|
||||
role: "redo",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("cut"),
|
||||
role: "cut",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("copy"),
|
||||
role: "copy",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("paste"),
|
||||
role: "paste",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("selectAll"),
|
||||
role: "selectAll",
|
||||
},
|
||||
]);
|
||||
|
||||
const inputSelectionMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: this.i18nService.t("cut"),
|
||||
role: "cut",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("copy"),
|
||||
role: "copy",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("paste"),
|
||||
role: "paste",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("selectAll"),
|
||||
role: "selectAll",
|
||||
},
|
||||
]);
|
||||
|
||||
this.windowMain.win.webContents.on("context-menu", (e, props) => {
|
||||
const selected = props.selectionText && props.selectionText.trim() !== "";
|
||||
if (props.isEditable && selected) {
|
||||
inputSelectionMenu.popup({ window: this.windowMain.win });
|
||||
} else if (props.isEditable) {
|
||||
inputMenu.popup({ window: this.windowMain.win });
|
||||
} else if (selected) {
|
||||
selectionMenu.popup({ window: this.windowMain.win });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
0
libs/electron/src/globals.d.ts
vendored
0
libs/electron/src/globals.d.ts
vendored
@@ -1,73 +0,0 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service";
|
||||
import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/enums/keySuffixOptions";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { CryptoService } from "@bitwarden/common/services/crypto.service";
|
||||
|
||||
export class ElectronCryptoService extends CryptoService {
|
||||
constructor(
|
||||
cryptoFunctionService: CryptoFunctionService,
|
||||
encryptService: EncryptService,
|
||||
platformUtilService: PlatformUtilsService,
|
||||
logService: LogService,
|
||||
stateService: StateService
|
||||
) {
|
||||
super(cryptoFunctionService, encryptService, platformUtilService, logService, stateService);
|
||||
}
|
||||
|
||||
async hasKeyStored(keySuffix: KeySuffixOptions): Promise<boolean> {
|
||||
await this.upgradeSecurelyStoredKey();
|
||||
return super.hasKeyStored(keySuffix);
|
||||
}
|
||||
|
||||
protected async storeKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Auto, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId });
|
||||
} else {
|
||||
this.clearStoredKey(KeySuffixOptions.Auto);
|
||||
}
|
||||
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Biometric, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(key.keyB64, { userId: userId });
|
||||
} else {
|
||||
this.clearStoredKey(KeySuffixOptions.Biometric);
|
||||
}
|
||||
}
|
||||
|
||||
protected async retrieveKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string) {
|
||||
await this.upgradeSecurelyStoredKey();
|
||||
return super.retrieveKeyFromStorage(keySuffix, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 4 Jun 2021 This is temporary upgrade method to move from a single shared stored key to
|
||||
* multiple, unique stored keys for each use, e.g. never logout vs. biometric authentication.
|
||||
*/
|
||||
private async upgradeSecurelyStoredKey() {
|
||||
// attempt key upgrade, but if we fail just delete it. Keys will be stored property upon unlock anyway.
|
||||
const key = await this.stateService.getCryptoMasterKeyB64();
|
||||
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Auto)) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key);
|
||||
}
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Biometric)) {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(key);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(
|
||||
`Encountered error while upgrading obsolete Bitwarden secure storage item:`
|
||||
);
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
await this.stateService.setCryptoMasterKeyB64(null);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import * as path from "path";
|
||||
|
||||
import log from "electron-log";
|
||||
|
||||
import { LogLevelType } from "@bitwarden/common/enums/logLevelType";
|
||||
import { ConsoleLogService as BaseLogService } from "@bitwarden/common/services/consoleLog.service";
|
||||
|
||||
import { isDev } from "../utils";
|
||||
|
||||
export class ElectronLogService extends BaseLogService {
|
||||
constructor(protected filter: (level: LogLevelType) => boolean = null, logDir: string = null) {
|
||||
super(isDev(), filter);
|
||||
if (log.transports == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.transports.file.level = "info";
|
||||
if (logDir != null) {
|
||||
log.transports.file.file = path.join(logDir, "app.log");
|
||||
}
|
||||
}
|
||||
|
||||
write(level: LogLevelType, message: string) {
|
||||
if (this.filter != null && this.filter(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case LogLevelType.Debug:
|
||||
log.debug(message);
|
||||
break;
|
||||
case LogLevelType.Info:
|
||||
log.info(message);
|
||||
break;
|
||||
case LogLevelType.Warning:
|
||||
log.warn(message);
|
||||
break;
|
||||
case LogLevelType.Error:
|
||||
log.error(message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { app, dialog, ipcMain, Menu, MenuItem, nativeTheme, session } from "electron";
|
||||
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { ThemeType } from "@bitwarden/common/enums/themeType";
|
||||
|
||||
import { RendererMenuItem } from "../utils";
|
||||
import { WindowMain } from "../window.main";
|
||||
|
||||
export class ElectronMainMessagingService implements MessagingService {
|
||||
constructor(private windowMain: WindowMain, private onMessage: (message: any) => void) {
|
||||
ipcMain.handle("appVersion", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("systemTheme", () => {
|
||||
return nativeTheme.shouldUseDarkColors ? ThemeType.Dark : ThemeType.Light;
|
||||
});
|
||||
|
||||
ipcMain.handle("showMessageBox", (event, options) => {
|
||||
return dialog.showMessageBox(this.windowMain.win, options);
|
||||
});
|
||||
|
||||
ipcMain.handle("openContextMenu", (event, options: { menu: RendererMenuItem[] }) => {
|
||||
return new Promise((resolve) => {
|
||||
const menu = new Menu();
|
||||
options.menu.forEach((m, index) => {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: m.label,
|
||||
type: m.type,
|
||||
click: () => {
|
||||
resolve(index);
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
menu.popup({
|
||||
window: windowMain.win,
|
||||
callback: () => {
|
||||
resolve(-1);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("windowVisible", () => {
|
||||
return windowMain.win?.isVisible();
|
||||
});
|
||||
|
||||
ipcMain.handle("getCookie", async (event, options) => {
|
||||
return await session.defaultSession.cookies.get(options);
|
||||
});
|
||||
|
||||
nativeTheme.on("updated", () => {
|
||||
windowMain.win?.webContents.send(
|
||||
"systemThemeUpdated",
|
||||
nativeTheme.shouldUseDarkColors ? ThemeType.Dark : ThemeType.Light
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
send(subscriber: string, arg: any = {}) {
|
||||
const message = Object.assign({}, { command: subscriber }, arg);
|
||||
this.onMessage(message);
|
||||
if (this.windowMain.win != null) {
|
||||
this.windowMain.win.webContents.send("messagingService", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { clipboard, ipcRenderer, shell } from "electron";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { ClientType } from "@bitwarden/common/enums/clientType";
|
||||
import { DeviceType } from "@bitwarden/common/enums/deviceType";
|
||||
|
||||
import { isDev, isMacAppStore } from "../utils";
|
||||
|
||||
export class ElectronPlatformUtilsService implements PlatformUtilsService {
|
||||
private deviceCache: DeviceType = null;
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
private messagingService: MessagingService,
|
||||
private clientType: ClientType.Desktop | ClientType.DirectoryConnector,
|
||||
private stateService: StateService
|
||||
) {}
|
||||
|
||||
getDevice(): DeviceType {
|
||||
if (!this.deviceCache) {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
this.deviceCache = DeviceType.WindowsDesktop;
|
||||
break;
|
||||
case "darwin":
|
||||
this.deviceCache = DeviceType.MacOsDesktop;
|
||||
break;
|
||||
case "linux":
|
||||
default:
|
||||
this.deviceCache = DeviceType.LinuxDesktop;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return this.deviceCache;
|
||||
}
|
||||
|
||||
getDeviceString(): string {
|
||||
const device = DeviceType[this.getDevice()].toLowerCase();
|
||||
return device.replace("desktop", "");
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return this.clientType;
|
||||
}
|
||||
|
||||
isFirefox(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isChrome(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
isEdge(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isOpera(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isVivaldi(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isSafari(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isMacAppStore(): boolean {
|
||||
return isMacAppStore();
|
||||
}
|
||||
|
||||
isViewOpen(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
launchUri(uri: string, options?: any): void {
|
||||
shell.openExternal(uri);
|
||||
}
|
||||
|
||||
getApplicationVersion(): Promise<string> {
|
||||
return ipcRenderer.invoke("appVersion");
|
||||
}
|
||||
|
||||
// Temporarily restricted to only Windows until https://github.com/electron/electron/pull/28349
|
||||
// has been merged and an updated electron build is available.
|
||||
supportsWebAuthn(win: Window): boolean {
|
||||
return process.platform === "win32";
|
||||
}
|
||||
|
||||
supportsDuo(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
showToast(
|
||||
type: "error" | "success" | "warning" | "info",
|
||||
title: string,
|
||||
text: string | string[],
|
||||
options?: any
|
||||
): void {
|
||||
this.messagingService.send("showToast", {
|
||||
text: text,
|
||||
title: title,
|
||||
type: type,
|
||||
options: options,
|
||||
});
|
||||
}
|
||||
|
||||
async showDialog(
|
||||
text: string,
|
||||
title?: string,
|
||||
confirmText?: string,
|
||||
cancelText?: string,
|
||||
type?: string
|
||||
): Promise<boolean> {
|
||||
const buttons = [confirmText == null ? this.i18nService.t("ok") : confirmText];
|
||||
if (cancelText != null) {
|
||||
buttons.push(cancelText);
|
||||
}
|
||||
|
||||
const result = await ipcRenderer.invoke("showMessageBox", {
|
||||
type: type,
|
||||
title: title,
|
||||
message: title,
|
||||
detail: text,
|
||||
buttons: buttons,
|
||||
cancelId: buttons.length === 2 ? 1 : null,
|
||||
defaultId: 0,
|
||||
noLink: true,
|
||||
});
|
||||
|
||||
return Promise.resolve(result.response === 0);
|
||||
}
|
||||
|
||||
isDev(): boolean {
|
||||
return isDev();
|
||||
}
|
||||
|
||||
isSelfHost(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
copyToClipboard(text: string, options?: any): void {
|
||||
const type = options ? options.type : null;
|
||||
const clearing = options ? !!options.clearing : false;
|
||||
const clearMs: number = options && options.clearMs ? options.clearMs : null;
|
||||
clipboard.writeText(text, type);
|
||||
if (!clearing) {
|
||||
this.messagingService.send("copiedToClipboard", {
|
||||
clipboardValue: text,
|
||||
clearMs: clearMs,
|
||||
type: type,
|
||||
clearing: clearing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
readFromClipboard(options?: any): Promise<string> {
|
||||
const type = options ? options.type : null;
|
||||
return Promise.resolve(clipboard.readText(type));
|
||||
}
|
||||
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
return await this.stateService.getEnableBiometric();
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const val = await ipcRenderer.invoke("biometric", {
|
||||
action: "authenticate",
|
||||
});
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
supportsSecureStorage(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
|
||||
export class ElectronRendererMessagingService implements MessagingService {
|
||||
constructor(private broadcasterService: BroadcasterService) {
|
||||
ipcRenderer.on("messagingService", async (event: any, message: any) => {
|
||||
if (message.command) {
|
||||
this.sendMessage(message.command, message, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(subscriber: string, arg: any = {}) {
|
||||
this.sendMessage(subscriber, arg, true);
|
||||
}
|
||||
|
||||
private sendMessage(subscriber: string, arg: any = {}, toMain: boolean) {
|
||||
const message = Object.assign({}, { command: subscriber }, arg);
|
||||
this.broadcasterService.send(message);
|
||||
if (toMain) {
|
||||
ipcRenderer.send("messagingService", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storage-options";
|
||||
|
||||
export class ElectronRendererSecureStorageService implements AbstractStorageService {
|
||||
async get<T>(key: string, options?: StorageOptions): Promise<T> {
|
||||
const val = await ipcRenderer.invoke("keytar", {
|
||||
action: "getPassword",
|
||||
key: key,
|
||||
keySuffix: options?.keySuffix ?? "",
|
||||
});
|
||||
return val != null ? (JSON.parse(val) as T) : null;
|
||||
}
|
||||
|
||||
async has(key: string, options?: StorageOptions): Promise<boolean> {
|
||||
const val = await ipcRenderer.invoke("keytar", {
|
||||
action: "hasPassword",
|
||||
key: key,
|
||||
keySuffix: options?.keySuffix ?? "",
|
||||
});
|
||||
return !!val;
|
||||
}
|
||||
|
||||
async save(key: string, obj: any, options?: StorageOptions): Promise<any> {
|
||||
await ipcRenderer.invoke("keytar", {
|
||||
action: "setPassword",
|
||||
key: key,
|
||||
keySuffix: options?.keySuffix ?? "",
|
||||
value: JSON.stringify(obj),
|
||||
});
|
||||
}
|
||||
|
||||
async remove(key: string, options?: StorageOptions): Promise<any> {
|
||||
await ipcRenderer.invoke("keytar", {
|
||||
action: "deletePassword",
|
||||
key: key,
|
||||
keySuffix: options?.keySuffix ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
|
||||
export class ElectronRendererStorageService implements AbstractStorageService {
|
||||
get<T>(key: string): Promise<T> {
|
||||
return ipcRenderer.invoke("storageService", {
|
||||
action: "get",
|
||||
key: key,
|
||||
});
|
||||
}
|
||||
|
||||
has(key: string): Promise<boolean> {
|
||||
return ipcRenderer.invoke("storageService", {
|
||||
action: "has",
|
||||
key: key,
|
||||
});
|
||||
}
|
||||
|
||||
save(key: string, obj: any): Promise<any> {
|
||||
return ipcRenderer.invoke("storageService", {
|
||||
action: "save",
|
||||
key: key,
|
||||
obj: obj,
|
||||
});
|
||||
}
|
||||
|
||||
remove(key: string): Promise<any> {
|
||||
return ipcRenderer.invoke("storageService", {
|
||||
action: "remove",
|
||||
key: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { NodeUtils } from "@bitwarden/common/misc/nodeUtils";
|
||||
|
||||
// See: https://github.com/sindresorhus/electron-store/blob/main/index.d.ts
|
||||
interface ElectronStoreOptions {
|
||||
defaults: unknown;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type ElectronStoreConstructor = new (options: ElectronStoreOptions) => ElectronStore;
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Store: ElectronStoreConstructor = require("electron-store");
|
||||
|
||||
interface ElectronStore {
|
||||
get: (key: string) => unknown;
|
||||
set: (key: string, obj: unknown) => void;
|
||||
delete: (key: string) => void;
|
||||
}
|
||||
|
||||
interface BaseOptions<T extends string> {
|
||||
action: T;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface SaveOptions extends BaseOptions<"save"> {
|
||||
obj: unknown;
|
||||
}
|
||||
|
||||
type Options = BaseOptions<"get"> | BaseOptions<"has"> | SaveOptions | BaseOptions<"remove">;
|
||||
|
||||
export class ElectronStorageService implements AbstractStorageService {
|
||||
private store: ElectronStore;
|
||||
|
||||
constructor(dir: string, defaults = {}) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
NodeUtils.mkdirpSync(dir, "700");
|
||||
}
|
||||
const storeConfig: ElectronStoreOptions = {
|
||||
defaults: defaults,
|
||||
name: "data",
|
||||
};
|
||||
this.store = new Store(storeConfig);
|
||||
|
||||
ipcMain.handle("storageService", (event, options: Options) => {
|
||||
switch (options.action) {
|
||||
case "get":
|
||||
return this.get(options.key);
|
||||
case "has":
|
||||
return this.has(options.key);
|
||||
case "save":
|
||||
return this.save(options.key, options.obj);
|
||||
case "remove":
|
||||
return this.remove(options.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
const val = this.store.get(key) as T;
|
||||
return Promise.resolve(val != null ? val : null);
|
||||
}
|
||||
|
||||
has(key: string): Promise<boolean> {
|
||||
const val = this.store.get(key);
|
||||
return Promise.resolve(val != null);
|
||||
}
|
||||
|
||||
save(key: string, obj: unknown): Promise<void> {
|
||||
if (obj instanceof Set) {
|
||||
obj = Array.from(obj);
|
||||
}
|
||||
this.store.set(key, obj);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
remove(key: string): Promise<void> {
|
||||
this.store.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import * as path from "path";
|
||||
|
||||
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
|
||||
import { WindowMain } from "./window.main";
|
||||
|
||||
export class TrayMain {
|
||||
contextMenu: Menu;
|
||||
|
||||
private appName: string;
|
||||
private tray: Tray;
|
||||
private icon: string | Electron.NativeImage;
|
||||
private pressedIcon: Electron.NativeImage;
|
||||
|
||||
constructor(
|
||||
private windowMain: WindowMain,
|
||||
private i18nService: I18nService,
|
||||
private stateService: StateService
|
||||
) {
|
||||
if (process.platform === "win32") {
|
||||
this.icon = path.join(__dirname, "/images/icon.ico");
|
||||
} else if (process.platform === "darwin") {
|
||||
const nImage = nativeImage.createFromPath(path.join(__dirname, "/images/icon-template.png"));
|
||||
nImage.setTemplateImage(true);
|
||||
this.icon = nImage;
|
||||
this.pressedIcon = nativeImage.createFromPath(
|
||||
path.join(__dirname, "/images/icon-highlight.png")
|
||||
);
|
||||
} else {
|
||||
this.icon = path.join(__dirname, "/images/icon.png");
|
||||
}
|
||||
}
|
||||
|
||||
async init(appName: string, additionalMenuItems: MenuItemConstructorOptions[] = null) {
|
||||
this.appName = appName;
|
||||
|
||||
const menuItemOptions: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: this.i18nService.t("showHide"),
|
||||
click: () => this.toggleWindow(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("exit"),
|
||||
click: () => this.closeWindow(),
|
||||
},
|
||||
];
|
||||
|
||||
if (additionalMenuItems != null) {
|
||||
menuItemOptions.splice(1, 0, ...additionalMenuItems);
|
||||
}
|
||||
|
||||
this.contextMenu = Menu.buildFromTemplate(menuItemOptions);
|
||||
if (await this.stateService.getEnableTray()) {
|
||||
this.showTray();
|
||||
}
|
||||
}
|
||||
|
||||
setupWindowListeners(win: BrowserWindow) {
|
||||
win.on("minimize", async (e: Event) => {
|
||||
if (await this.stateService.getEnableMinimizeToTray()) {
|
||||
e.preventDefault();
|
||||
this.hideToTray();
|
||||
}
|
||||
});
|
||||
|
||||
win.on("close", async (e: Event) => {
|
||||
if (await this.stateService.getEnableCloseToTray()) {
|
||||
if (!this.windowMain.isQuitting) {
|
||||
e.preventDefault();
|
||||
this.hideToTray();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
win.on("show", async () => {
|
||||
const enableTray = await this.stateService.getEnableTray();
|
||||
if (!enableTray) {
|
||||
setTimeout(() => this.removeTray(false), 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeTray(showWindow = true) {
|
||||
// Due to https://github.com/electron/electron/issues/17622
|
||||
// we cannot destroy the tray icon on linux.
|
||||
if (this.tray != null && process.platform !== "linux") {
|
||||
this.tray.destroy();
|
||||
this.tray = null;
|
||||
}
|
||||
|
||||
if (showWindow && this.windowMain.win != null && !this.windowMain.win.isVisible()) {
|
||||
this.windowMain.win.show();
|
||||
}
|
||||
}
|
||||
|
||||
async hideToTray() {
|
||||
this.showTray();
|
||||
if (this.windowMain.win != null) {
|
||||
this.windowMain.win.hide();
|
||||
}
|
||||
if (this.isDarwin() && !(await this.stateService.getAlwaysShowDock())) {
|
||||
this.hideDock();
|
||||
}
|
||||
}
|
||||
|
||||
restoreFromTray() {
|
||||
if (this.windowMain.win == null || !this.windowMain.win.isVisible()) {
|
||||
this.toggleWindow();
|
||||
}
|
||||
}
|
||||
|
||||
showTray() {
|
||||
if (this.tray != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tray = new Tray(this.icon);
|
||||
this.tray.setToolTip(this.appName);
|
||||
this.tray.on("click", () => this.toggleWindow());
|
||||
this.tray.on("right-click", () => this.tray.popUpContextMenu(this.contextMenu));
|
||||
|
||||
if (this.pressedIcon != null) {
|
||||
this.tray.setPressedImage(this.pressedIcon);
|
||||
}
|
||||
if (this.contextMenu != null && !this.isDarwin()) {
|
||||
this.tray.setContextMenu(this.contextMenu);
|
||||
}
|
||||
}
|
||||
|
||||
updateContextMenu() {
|
||||
if (this.contextMenu != null && this.isLinux()) {
|
||||
this.tray.setContextMenu(this.contextMenu);
|
||||
}
|
||||
}
|
||||
|
||||
private hideDock() {
|
||||
app.dock.hide();
|
||||
}
|
||||
|
||||
private showDock() {
|
||||
app.dock.show();
|
||||
}
|
||||
|
||||
private isDarwin() {
|
||||
return process.platform === "darwin";
|
||||
}
|
||||
|
||||
private isLinux() {
|
||||
return process.platform === "linux";
|
||||
}
|
||||
|
||||
private async toggleWindow() {
|
||||
if (this.windowMain.win == null) {
|
||||
if (this.isDarwin()) {
|
||||
// On MacOS, closing the window via the red button destroys the BrowserWindow instance.
|
||||
this.windowMain.createWindow().then(() => {
|
||||
this.windowMain.win.show();
|
||||
this.showDock();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.windowMain.win.isVisible()) {
|
||||
this.windowMain.win.hide();
|
||||
if (this.isDarwin() && !(await this.stateService.getAlwaysShowDock())) {
|
||||
this.hideDock();
|
||||
}
|
||||
} else {
|
||||
this.windowMain.win.show();
|
||||
if (this.isDarwin()) {
|
||||
this.showDock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private closeWindow() {
|
||||
this.windowMain.isQuitting = true;
|
||||
if (this.windowMain.win != null) {
|
||||
this.windowMain.win.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { dialog, shell } from "electron";
|
||||
import log from "electron-log";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { isAppImage, isDev, isMacAppStore, isWindowsPortable, isWindowsStore } from "./utils";
|
||||
import { WindowMain } from "./window.main";
|
||||
|
||||
const UpdaterCheckInitalDelay = 5 * 1000; // 5 seconds
|
||||
const UpdaterCheckInterval = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
export class UpdaterMain {
|
||||
private doingUpdateCheck = false;
|
||||
private doingUpdateCheckWithFeedback = false;
|
||||
private canUpdate = false;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private gitHubProject: string,
|
||||
private onCheckingForUpdate: () => void = null,
|
||||
private onReset: () => void = null,
|
||||
private onUpdateDownloaded: () => void = null,
|
||||
private projectName: string
|
||||
) {
|
||||
autoUpdater.logger = log;
|
||||
|
||||
const linuxCanUpdate = process.platform === "linux" && isAppImage();
|
||||
const windowsCanUpdate =
|
||||
process.platform === "win32" && !isWindowsStore() && !isWindowsPortable();
|
||||
const macCanUpdate = process.platform === "darwin" && !isMacAppStore();
|
||||
this.canUpdate =
|
||||
process.env.ELECTRON_NO_UPDATER !== "1" &&
|
||||
(linuxCanUpdate || windowsCanUpdate || macCanUpdate);
|
||||
}
|
||||
|
||||
async init() {
|
||||
global.setTimeout(async () => await this.checkForUpdate(), UpdaterCheckInitalDelay);
|
||||
global.setInterval(async () => await this.checkForUpdate(), UpdaterCheckInterval);
|
||||
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
if (this.onCheckingForUpdate != null) {
|
||||
this.onCheckingForUpdate();
|
||||
}
|
||||
this.doingUpdateCheck = true;
|
||||
});
|
||||
|
||||
autoUpdater.on("update-available", async () => {
|
||||
if (this.doingUpdateCheckWithFeedback) {
|
||||
if (this.windowMain.win == null) {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dialog.showMessageBox(this.windowMain.win, {
|
||||
type: "info",
|
||||
title:
|
||||
this.i18nService.t(this.projectName) + " - " + this.i18nService.t("updateAvailable"),
|
||||
message: this.i18nService.t("updateAvailable"),
|
||||
detail: this.i18nService.t("updateAvailableDesc"),
|
||||
buttons: [this.i18nService.t("yes"), this.i18nService.t("no")],
|
||||
cancelId: 1,
|
||||
defaultId: 0,
|
||||
noLink: true,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
autoUpdater.downloadUpdate();
|
||||
} else {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
if (this.doingUpdateCheckWithFeedback && this.windowMain.win != null) {
|
||||
dialog.showMessageBox(this.windowMain.win, {
|
||||
message: this.i18nService.t("noUpdatesAvailable"),
|
||||
buttons: [this.i18nService.t("ok")],
|
||||
defaultId: 0,
|
||||
noLink: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.reset();
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", async (info) => {
|
||||
if (this.onUpdateDownloaded != null) {
|
||||
this.onUpdateDownloaded();
|
||||
}
|
||||
|
||||
if (this.windowMain.win == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dialog.showMessageBox(this.windowMain.win, {
|
||||
type: "info",
|
||||
title: this.i18nService.t(this.projectName) + " - " + this.i18nService.t("restartToUpdate"),
|
||||
message: this.i18nService.t("restartToUpdate"),
|
||||
detail: this.i18nService.t("restartToUpdateDesc", info.version),
|
||||
buttons: [this.i18nService.t("restart"), this.i18nService.t("later")],
|
||||
cancelId: 1,
|
||||
defaultId: 0,
|
||||
noLink: true,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
// Quit and install have a different window logic, setting `isQuitting` just to be safe.
|
||||
this.windowMain.isQuitting = true;
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (error) => {
|
||||
if (this.doingUpdateCheckWithFeedback) {
|
||||
dialog.showErrorBox(
|
||||
this.i18nService.t("updateError"),
|
||||
error == null ? this.i18nService.t("unknown") : (error.stack || error).toString()
|
||||
);
|
||||
}
|
||||
|
||||
this.reset();
|
||||
});
|
||||
}
|
||||
|
||||
async checkForUpdate(withFeedback = false) {
|
||||
if (this.doingUpdateCheck || isDev()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.canUpdate) {
|
||||
if (withFeedback) {
|
||||
shell.openExternal("https://github.com/bitwarden/" + this.gitHubProject + "/releases");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.doingUpdateCheckWithFeedback = withFeedback;
|
||||
if (withFeedback) {
|
||||
autoUpdater.autoDownload = false;
|
||||
}
|
||||
|
||||
await autoUpdater.checkForUpdates();
|
||||
}
|
||||
|
||||
private reset() {
|
||||
if (this.onReset != null) {
|
||||
this.onReset();
|
||||
}
|
||||
autoUpdater.autoDownload = true;
|
||||
this.doingUpdateCheck = false;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
export type RendererMenuItem = {
|
||||
label?: string;
|
||||
type?: "normal" | "separator" | "submenu" | "checkbox" | "radio";
|
||||
click?: () => any;
|
||||
};
|
||||
|
||||
export function invokeMenu(menu: RendererMenuItem[]) {
|
||||
const menuWithoutClick = menu.map((m) => {
|
||||
return { label: m.label, type: m.type };
|
||||
});
|
||||
ipcRenderer.invoke("openContextMenu", { menu: menuWithoutClick }).then((i: number) => {
|
||||
if (i !== -1) {
|
||||
menu[i].click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isDev() {
|
||||
// ref: https://github.com/sindresorhus/electron-is-dev
|
||||
if ("ELECTRON_IS_DEV" in process.env) {
|
||||
return parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;
|
||||
}
|
||||
return process.defaultApp || /node_modules[\\/]electron[\\/]/.test(process.execPath);
|
||||
}
|
||||
|
||||
export function isAppImage() {
|
||||
return process.platform === "linux" && "APPIMAGE" in process.env;
|
||||
}
|
||||
|
||||
export function isMac() {
|
||||
return process.platform === "darwin";
|
||||
}
|
||||
|
||||
export function isMacAppStore() {
|
||||
return isMac() && process.mas === true;
|
||||
}
|
||||
|
||||
export function isWindowsStore() {
|
||||
const isWindows = process.platform === "win32";
|
||||
let windowsStore = process.windowsStore;
|
||||
if (
|
||||
isWindows &&
|
||||
!windowsStore &&
|
||||
process.resourcesPath.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1
|
||||
) {
|
||||
windowsStore = true;
|
||||
}
|
||||
return isWindows && windowsStore === true;
|
||||
}
|
||||
|
||||
export function isSnapStore() {
|
||||
return process.platform === "linux" && process.env.SNAP_USER_DATA != null;
|
||||
}
|
||||
|
||||
export function isWindowsPortable() {
|
||||
return process.platform === "win32" && process.env.PORTABLE_EXECUTABLE_DIR != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize user agent so external resources used by the app can't built data on our users.
|
||||
*/
|
||||
export function cleanUserAgent(userAgent: string): string {
|
||||
const userAgentItem = (startString: string, endString: string) => {
|
||||
const startIndex = userAgent.indexOf(startString);
|
||||
return userAgent.substring(startIndex, userAgent.indexOf(endString, startIndex) + 1);
|
||||
};
|
||||
const systemInformation = "(Windows NT 10.0; Win64; x64)";
|
||||
|
||||
// Set system information, remove bitwarden, and electron information
|
||||
return userAgent
|
||||
.replace(userAgentItem("(", ")"), systemInformation)
|
||||
.replace(userAgentItem("Bitwarden", " "), "")
|
||||
.replace(userAgentItem("Electron", " "), "");
|
||||
}
|
||||
|
||||
export async function getCookie(url: string, name: string): Promise<Electron.Cookie[]> {
|
||||
return await ipcRenderer.invoke("getCookie", { url: url, name: name });
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import * as path from "path";
|
||||
import * as url from "url";
|
||||
|
||||
import { app, BrowserWindow, screen } from "electron";
|
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
|
||||
import { cleanUserAgent, isDev, isMacAppStore, isSnapStore } from "./utils";
|
||||
|
||||
const mainWindowSizeKey = "mainWindowSize";
|
||||
const WindowEventHandlingDelay = 100;
|
||||
export class WindowMain {
|
||||
win: BrowserWindow;
|
||||
isQuitting = false;
|
||||
|
||||
private windowStateChangeTimer: NodeJS.Timer;
|
||||
private windowStates: { [key: string]: any } = {};
|
||||
private enableAlwaysOnTop = false;
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private logService: LogService,
|
||||
private hideTitleBar = false,
|
||||
private defaultWidth = 950,
|
||||
private defaultHeight = 600,
|
||||
private argvCallback: (argv: string[]) => void = null,
|
||||
private createWindowCallback: (win: BrowserWindow) => void
|
||||
) {}
|
||||
|
||||
init(): Promise<any> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
if (!isMacAppStore() && !isSnapStore()) {
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
return;
|
||||
} else {
|
||||
// eslint-disable-next-line
|
||||
app.on("second-instance", (event, argv, workingDirectory) => {
|
||||
// Someone tried to run a second instance, we should focus our window.
|
||||
if (this.win != null) {
|
||||
if (this.win.isMinimized() || !this.win.isVisible()) {
|
||||
this.win.show();
|
||||
}
|
||||
this.win.focus();
|
||||
}
|
||||
if (process.platform === "win32" || process.platform === "linux") {
|
||||
if (this.argvCallback != null) {
|
||||
this.argvCallback(argv);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// This method will be called when Electron is shutting
|
||||
// down the application.
|
||||
app.on("before-quit", () => {
|
||||
this.isQuitting = true;
|
||||
});
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on("ready", async () => {
|
||||
await this.createWindow();
|
||||
resolve();
|
||||
if (this.argvCallback != null) {
|
||||
this.argvCallback(process.argv);
|
||||
}
|
||||
});
|
||||
|
||||
// Quit when all windows are closed.
|
||||
app.on("window-all-closed", () => {
|
||||
// On OS X it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if (process.platform !== "darwin" || this.isQuitting || isMacAppStore()) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", async () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (this.win === null) {
|
||||
await this.createWindow();
|
||||
} else {
|
||||
// Show the window when clicking on Dock icon
|
||||
this.win.show();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Catch Error
|
||||
// throw e;
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createWindow(): Promise<void> {
|
||||
this.windowStates[mainWindowSizeKey] = await this.getWindowState(
|
||||
this.defaultWidth,
|
||||
this.defaultHeight
|
||||
);
|
||||
this.enableAlwaysOnTop = await this.stateService.getEnableAlwaysOnTop();
|
||||
|
||||
// Create the browser window.
|
||||
this.win = new BrowserWindow({
|
||||
width: this.windowStates[mainWindowSizeKey].width,
|
||||
height: this.windowStates[mainWindowSizeKey].height,
|
||||
minWidth: 680,
|
||||
minHeight: 500,
|
||||
x: this.windowStates[mainWindowSizeKey].x,
|
||||
y: this.windowStates[mainWindowSizeKey].y,
|
||||
title: app.name,
|
||||
icon: process.platform === "linux" ? path.join(__dirname, "/images/icon.png") : undefined,
|
||||
titleBarStyle: this.hideTitleBar && process.platform === "darwin" ? "hiddenInset" : undefined,
|
||||
show: false,
|
||||
backgroundColor: "#fff",
|
||||
alwaysOnTop: this.enableAlwaysOnTop,
|
||||
webPreferences: {
|
||||
spellcheck: false,
|
||||
nodeIntegration: true,
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.windowStates[mainWindowSizeKey].isMaximized) {
|
||||
this.win.maximize();
|
||||
}
|
||||
|
||||
// Show it later since it might need to be maximized.
|
||||
this.win.show();
|
||||
|
||||
// and load the index.html of the app.
|
||||
this.win.loadURL(
|
||||
url.format({
|
||||
protocol: "file:",
|
||||
pathname: path.join(__dirname, "/index.html"),
|
||||
slashes: true,
|
||||
}),
|
||||
{
|
||||
userAgent: cleanUserAgent(this.win.webContents.userAgent),
|
||||
}
|
||||
);
|
||||
|
||||
// Open the DevTools.
|
||||
if (isDev()) {
|
||||
this.win.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// Emitted when the window is closed.
|
||||
this.win.on("closed", async () => {
|
||||
await this.updateWindowState(mainWindowSizeKey, this.win);
|
||||
|
||||
// Dereference the window object, usually you would store window
|
||||
// in an array if your app supports multi windows, this is the time
|
||||
// when you should delete the corresponding element.
|
||||
this.win = null;
|
||||
});
|
||||
|
||||
this.win.on("close", async () => {
|
||||
await this.updateWindowState(mainWindowSizeKey, this.win);
|
||||
});
|
||||
|
||||
this.win.on("maximize", async () => {
|
||||
await this.updateWindowState(mainWindowSizeKey, this.win);
|
||||
});
|
||||
|
||||
this.win.on("unmaximize", async () => {
|
||||
await this.updateWindowState(mainWindowSizeKey, this.win);
|
||||
});
|
||||
|
||||
this.win.on("resize", () => {
|
||||
this.windowStateChangeHandler(mainWindowSizeKey, this.win);
|
||||
});
|
||||
|
||||
this.win.on("move", () => {
|
||||
this.windowStateChangeHandler(mainWindowSizeKey, this.win);
|
||||
});
|
||||
this.win.on("focus", () => {
|
||||
this.win.webContents.send("messagingService", {
|
||||
command: "windowIsFocused",
|
||||
windowIsFocused: true,
|
||||
});
|
||||
});
|
||||
|
||||
if (this.createWindowCallback) {
|
||||
this.createWindowCallback(this.win);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleAlwaysOnTop() {
|
||||
this.enableAlwaysOnTop = !this.win.isAlwaysOnTop();
|
||||
this.win.setAlwaysOnTop(this.enableAlwaysOnTop);
|
||||
await this.stateService.setEnableAlwaysOnTop(this.enableAlwaysOnTop);
|
||||
}
|
||||
|
||||
private windowStateChangeHandler(configKey: string, win: BrowserWindow) {
|
||||
global.clearTimeout(this.windowStateChangeTimer);
|
||||
this.windowStateChangeTimer = global.setTimeout(async () => {
|
||||
await this.updateWindowState(configKey, win);
|
||||
}, WindowEventHandlingDelay);
|
||||
}
|
||||
|
||||
private async updateWindowState(configKey: string, win: BrowserWindow) {
|
||||
if (win == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bounds = win.getBounds();
|
||||
|
||||
if (this.windowStates[configKey] == null) {
|
||||
this.windowStates[configKey] = await this.stateService.getWindow();
|
||||
if (this.windowStates[configKey] == null) {
|
||||
this.windowStates[configKey] = {};
|
||||
}
|
||||
}
|
||||
|
||||
this.windowStates[configKey].isMaximized = win.isMaximized();
|
||||
this.windowStates[configKey].displayBounds = screen.getDisplayMatching(bounds).bounds;
|
||||
|
||||
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
|
||||
this.windowStates[configKey].x = bounds.x;
|
||||
this.windowStates[configKey].y = bounds.y;
|
||||
this.windowStates[configKey].width = bounds.width;
|
||||
this.windowStates[configKey].height = bounds.height;
|
||||
}
|
||||
|
||||
await this.stateService.setWindow(this.windowStates[configKey]);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getWindowState(defaultWidth: number, defaultHeight: number) {
|
||||
const state = await this.stateService.getWindow();
|
||||
|
||||
const isValid = state != null && (this.stateHasBounds(state) || state.isMaximized);
|
||||
let displayBounds: Electron.Rectangle = null;
|
||||
if (!isValid) {
|
||||
state.width = defaultWidth;
|
||||
state.height = defaultHeight;
|
||||
|
||||
displayBounds = screen.getPrimaryDisplay().bounds;
|
||||
} else if (this.stateHasBounds(state) && state.displayBounds) {
|
||||
// Check if the display where the window was last open is still available
|
||||
displayBounds = screen.getDisplayMatching(state.displayBounds).bounds;
|
||||
|
||||
if (
|
||||
displayBounds.width !== state.displayBounds.width ||
|
||||
displayBounds.height !== state.displayBounds.height ||
|
||||
displayBounds.x !== state.displayBounds.x ||
|
||||
displayBounds.y !== state.displayBounds.y
|
||||
) {
|
||||
state.x = undefined;
|
||||
state.y = undefined;
|
||||
displayBounds = screen.getPrimaryDisplay().bounds;
|
||||
}
|
||||
}
|
||||
|
||||
if (displayBounds != null) {
|
||||
if (state.width > displayBounds.width && state.height > displayBounds.height) {
|
||||
state.isMaximized = true;
|
||||
}
|
||||
|
||||
if (state.width > displayBounds.width) {
|
||||
state.width = displayBounds.width - 10;
|
||||
}
|
||||
if (state.height > displayBounds.height) {
|
||||
state.height = displayBounds.height - 10;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private stateHasBounds(state: any): boolean {
|
||||
return (
|
||||
state != null &&
|
||||
Number.isInteger(state.x) &&
|
||||
Number.isInteger(state.y) &&
|
||||
Number.isInteger(state.width) &&
|
||||
state.width > 0 &&
|
||||
Number.isInteger(state.height) &&
|
||||
state.height > 0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../shared/tsconfig.libs",
|
||||
"include": ["src", "spec"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json"
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
"paths": {
|
||||
"@bitwarden/common/*": ["../common/src/*"],
|
||||
"@bitwarden/angular/*": ["../angular/src/*"],
|
||||
"@bitwarden/electron/*": ["../electron/src/*"],
|
||||
"@bitwarden/node/*": ["../node/src/*"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user