mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 01:33:33 +00:00
Merge master into merge/feature/org-admin-refresh (using imerge)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
|
||||
import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core";
|
||||
|
||||
import { EventService } from "@bitwarden/common/abstractions/event.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { CipherType } from "@bitwarden/common/enums/cipherType";
|
||||
import { EventType } from "@bitwarden/common/enums/eventType";
|
||||
@@ -25,7 +25,10 @@ export class AddEditCustomFieldsComponent implements OnChanges {
|
||||
fieldType = FieldType;
|
||||
eventType = EventType;
|
||||
|
||||
constructor(private i18nService: I18nService, private eventService: EventService) {
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private eventCollectionService: EventCollectionService
|
||||
) {
|
||||
this.addFieldTypeOptions = [
|
||||
{ name: i18nService.t("cfTypeText"), value: FieldType.Text },
|
||||
{ name: i18nService.t("cfTypeHidden"), value: FieldType.Hidden },
|
||||
@@ -74,7 +77,10 @@ export class AddEditCustomFieldsComponent implements OnChanges {
|
||||
const f = field as any;
|
||||
f.showValue = !f.showValue;
|
||||
if (this.editMode && f.showValue) {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledHiddenFieldVisible, this.cipher.id);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledHiddenFieldVisible,
|
||||
this.cipher.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Observable, Subject, takeUntil, concatMap } from "rxjs";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
|
||||
import { EventService } from "@bitwarden/common/abstractions/event.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
@@ -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[];
|
||||
@@ -89,7 +91,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected stateService: StateService,
|
||||
protected collectionService: CollectionService,
|
||||
protected messagingService: MessagingService,
|
||||
protected eventService: EventService,
|
||||
protected eventCollectionService: EventCollectionService,
|
||||
protected policyService: PolicyService,
|
||||
private logService: LogService,
|
||||
protected passwordRepromptService: PasswordRepromptService,
|
||||
@@ -264,7 +266,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
this.folders$ = this.folderService.folderViews$;
|
||||
|
||||
if (this.editMode && this.previousCipherId !== this.cipherId) {
|
||||
this.eventService.collect(EventType.Cipher_ClientViewed, this.cipherId);
|
||||
this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
|
||||
}
|
||||
this.previousCipherId = this.cipherId;
|
||||
this.reprompt = this.cipher.reprompt !== CipherRepromptType.None;
|
||||
@@ -487,14 +489,20 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
this.showPassword = !this.showPassword;
|
||||
document.getElementById("loginPassword").focus();
|
||||
if (this.editMode && this.showPassword) {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledPasswordVisible, this.cipherId);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledPasswordVisible,
|
||||
this.cipherId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleCardNumber() {
|
||||
this.showCardNumber = !this.showCardNumber;
|
||||
if (this.showCardNumber) {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledCardNumberVisible, this.cipherId);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledCardNumberVisible,
|
||||
this.cipherId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +510,10 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
this.showCardCode = !this.showCardCode;
|
||||
document.getElementById("cardCode").focus();
|
||||
if (this.editMode && this.showCardCode) {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledCardCodeVisible,
|
||||
this.cipherId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { UntypedFormBuilder, Validators } from "@angular/forms";
|
||||
import { merge, takeUntil, Subject, startWith } from "rxjs";
|
||||
|
||||
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
|
||||
import { EventService } from "@bitwarden/common/abstractions/event.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { ExportService } from "@bitwarden/common/abstractions/export.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
@@ -45,7 +45,7 @@ export class ExportComponent implements OnInit, OnDestroy {
|
||||
protected i18nService: I18nService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected exportService: ExportService,
|
||||
protected eventService: EventService,
|
||||
protected eventCollectionService: EventCollectionService,
|
||||
private policyService: PolicyService,
|
||||
protected win: Window,
|
||||
private logService: LogService,
|
||||
@@ -180,7 +180,7 @@ export class ExportComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
protected async collectEvent(): Promise<void> {
|
||||
await this.eventService.collect(EventType.User_ClientExportedVault);
|
||||
await this.eventCollectionService.collect(EventType.User_ClientExportedVault);
|
||||
}
|
||||
|
||||
get format() {
|
||||
|
||||
@@ -70,13 +70,7 @@ export class GeneratorComponent implements OnInit {
|
||||
];
|
||||
this.subaddressOptions = [{ name: i18nService.t("random"), value: "random" }];
|
||||
this.catchallOptions = [{ name: i18nService.t("random"), value: "random" }];
|
||||
this.forwardOptions = [
|
||||
{ name: "SimpleLogin", value: "simplelogin" },
|
||||
{ name: "AnonAddy", value: "anonaddy" },
|
||||
{ name: "Firefox Relay", value: "firefoxrelay" },
|
||||
{ name: "Fastmail", value: "fastmail" },
|
||||
{ name: "DuckDuckGo", value: "duckduckgo" },
|
||||
];
|
||||
this.initForwardOptions();
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -239,4 +233,21 @@ export class GeneratorComponent implements OnInit {
|
||||
this.enforcedPasswordPolicyOptions
|
||||
);
|
||||
}
|
||||
|
||||
private async initForwardOptions() {
|
||||
this.forwardOptions = [
|
||||
{ name: "AnonAddy", value: "anonaddy" },
|
||||
{ name: "DuckDuckGo", value: "duckduckgo" },
|
||||
{ name: "Fastmail", value: "fastmail" },
|
||||
{ name: "Firefox Relay", value: "firefoxrelay" },
|
||||
{ name: "SimpleLogin", value: "simplelogin" },
|
||||
];
|
||||
|
||||
this.usernameOptions = await this.usernameGenerationService.getOptions();
|
||||
if (this.usernameOptions.forwardedService == null) {
|
||||
this.forwardOptions.push({ name: "", value: null });
|
||||
}
|
||||
|
||||
this.forwardOptions = this.forwardOptions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +1,11 @@
|
||||
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()
|
||||
export class CiphersComponent {
|
||||
export class VaultItemsComponent {
|
||||
@Input() activeCipherId: string = null;
|
||||
@Output() onCipherClicked = new EventEmitter<CipherView>();
|
||||
@Output() onCipherRightClicked = new EventEmitter<CipherView>();
|
||||
@@ -17,6 +18,8 @@ export class CiphersComponent {
|
||||
searchPlaceholder: string = null;
|
||||
filter: (cipher: CipherView) => boolean = null;
|
||||
deleted = false;
|
||||
organization: Organization;
|
||||
accessEvents = false;
|
||||
|
||||
protected searchPending = false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Directive, Input } from "@angular/core";
|
||||
|
||||
import { EventService } from "@bitwarden/common/abstractions/event.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums/eventType";
|
||||
import { FieldType } from "@bitwarden/common/enums/fieldType";
|
||||
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||
@@ -14,7 +14,7 @@ export class ViewCustomFieldsComponent {
|
||||
|
||||
fieldType = FieldType;
|
||||
|
||||
constructor(private eventService: EventService) {}
|
||||
constructor(private eventCollectionService: EventCollectionService) {}
|
||||
|
||||
async toggleFieldValue(field: FieldView) {
|
||||
if (!(await this.promptPassword())) {
|
||||
@@ -25,7 +25,10 @@ export class ViewCustomFieldsComponent {
|
||||
f.showValue = !f.showValue;
|
||||
f.showCount = false;
|
||||
if (f.showValue) {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledHiddenFieldVisible, this.cipher.id);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledHiddenFieldVisible,
|
||||
this.cipher.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
|
||||
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
|
||||
import { EventService } from "@bitwarden/common/abstractions/event.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service";
|
||||
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
@@ -80,7 +80,7 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
protected broadcasterService: BroadcasterService,
|
||||
protected ngZone: NgZone,
|
||||
protected changeDetectorRef: ChangeDetectorRef,
|
||||
protected eventService: EventService,
|
||||
protected eventCollectionService: EventCollectionService,
|
||||
protected apiService: ApiService,
|
||||
protected passwordRepromptService: PasswordRepromptService,
|
||||
private logService: LogService,
|
||||
@@ -138,7 +138,7 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
if (this.previousCipherId !== this.cipherId) {
|
||||
this.eventService.collect(EventType.Cipher_ClientViewed, this.cipherId);
|
||||
this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
|
||||
}
|
||||
this.previousCipherId = this.cipherId;
|
||||
}
|
||||
@@ -238,7 +238,10 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
this.showPassword = !this.showPassword;
|
||||
this.showPasswordCount = false;
|
||||
if (this.showPassword) {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledPasswordVisible, this.cipherId);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledPasswordVisible,
|
||||
this.cipherId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +260,10 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.showCardNumber = !this.showCardNumber;
|
||||
if (this.showCardNumber) {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledCardNumberVisible, this.cipherId);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledCardNumberVisible,
|
||||
this.cipherId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +274,10 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.showCardCode = !this.showCardCode;
|
||||
if (this.showCardCode) {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledCardCodeVisible,
|
||||
this.cipherId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,11 +337,14 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
);
|
||||
|
||||
if (typeI18nKey === "password") {
|
||||
this.eventService.collect(EventType.Cipher_ClientToggledHiddenFieldVisible, this.cipherId);
|
||||
this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledHiddenFieldVisible,
|
||||
this.cipherId
|
||||
);
|
||||
} else if (typeI18nKey === "securityCode") {
|
||||
this.eventService.collect(EventType.Cipher_ClientCopiedCardCode, this.cipherId);
|
||||
this.eventCollectionService.collect(EventType.Cipher_ClientCopiedCardCode, this.cipherId);
|
||||
} else if (aType === "H_Field") {
|
||||
this.eventService.collect(EventType.Cipher_ClientCopiedHiddenField, this.cipherId);
|
||||
this.eventCollectionService.collect(EventType.Cipher_ClientCopiedHiddenField, this.cipherId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/abs
|
||||
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service";
|
||||
import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service";
|
||||
import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/common/abstractions/environment.service";
|
||||
import { EventService as EventServiceAbstraction } from "@bitwarden/common/abstractions/event.service";
|
||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||
import { ExportService as ExportServiceAbstraction } from "@bitwarden/common/abstractions/export.service";
|
||||
import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/abstractions/fileUpload.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/abstractions/folder/folder-api.service.abstraction";
|
||||
@@ -35,7 +36,10 @@ import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/abstr
|
||||
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService as OrganizationServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
InternalOrganizationService,
|
||||
OrganizationService as OrganizationServiceAbstraction,
|
||||
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||
import { PasswordGenerationService as PasswordGenerationServiceAbstraction } from "@bitwarden/common/abstractions/passwordGeneration.service";
|
||||
import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@bitwarden/common/abstractions/passwordReprompt.service";
|
||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
@@ -82,7 +86,8 @@ import { CryptoService } from "@bitwarden/common/services/crypto.service";
|
||||
import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation";
|
||||
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/services/cryptography/multithread-encrypt.service.implementation";
|
||||
import { EnvironmentService } from "@bitwarden/common/services/environment.service";
|
||||
import { EventService } from "@bitwarden/common/services/event.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
|
||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||
import { ExportService } from "@bitwarden/common/services/export.service";
|
||||
import { FileUploadService } from "@bitwarden/common/services/fileUpload.service";
|
||||
import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service";
|
||||
@@ -356,7 +361,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
||||
StateServiceAbstraction,
|
||||
ProviderServiceAbstraction,
|
||||
FolderApiServiceAbstraction,
|
||||
SyncNotifierServiceAbstraction,
|
||||
OrganizationServiceAbstraction,
|
||||
LOGOUT_CALLBACK,
|
||||
],
|
||||
},
|
||||
@@ -454,14 +459,18 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
||||
deps: [CryptoFunctionServiceAbstraction, LogService, LOG_MAC_FAILURES],
|
||||
},
|
||||
{
|
||||
provide: EventServiceAbstraction,
|
||||
useClass: EventService,
|
||||
provide: EventUploadServiceAbstraction,
|
||||
useClass: EventUploadService,
|
||||
deps: [ApiServiceAbstraction, StateServiceAbstraction, LogService],
|
||||
},
|
||||
{
|
||||
provide: EventCollectionServiceAbstraction,
|
||||
useClass: EventCollectionService,
|
||||
deps: [
|
||||
ApiServiceAbstraction,
|
||||
CipherServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
LogService,
|
||||
OrganizationServiceAbstraction,
|
||||
EventUploadServiceAbstraction,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -506,6 +515,8 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
||||
LogService,
|
||||
OrganizationServiceAbstraction,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
SyncNotifierServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
LOGOUT_CALLBACK,
|
||||
],
|
||||
},
|
||||
@@ -522,7 +533,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
||||
{
|
||||
provide: OrganizationServiceAbstraction,
|
||||
useClass: OrganizationService,
|
||||
deps: [StateServiceAbstraction, SyncNotifierServiceAbstraction],
|
||||
deps: [StateServiceAbstraction],
|
||||
},
|
||||
{
|
||||
provide: InternalOrganizationService,
|
||||
useExisting: OrganizationServiceAbstraction,
|
||||
},
|
||||
{
|
||||
provide: ProviderServiceAbstraction,
|
||||
|
||||
@@ -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";
|
||||
@@ -41,7 +44,9 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { MockProxy, mock, any, mockClear, matches } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
|
||||
import { MockProxy, mock, any, mockClear } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { SyncNotifierService } from "@bitwarden/common/abstractions/sync/syncNotifier.service.abstraction";
|
||||
import { OrganizationData } from "@bitwarden/common/models/data/organization.data";
|
||||
import { SyncResponse } from "@bitwarden/common/models/response/sync.response";
|
||||
import { OrganizationService } from "@bitwarden/common/services/organization/organization.service";
|
||||
import { SyncEventArgs } from "@bitwarden/common/types/syncEventArgs";
|
||||
|
||||
describe("Organization Service", () => {
|
||||
let organizationService: OrganizationService;
|
||||
@@ -14,8 +11,6 @@ describe("Organization Service", () => {
|
||||
let stateService: MockProxy<StateService>;
|
||||
let activeAccount: BehaviorSubject<string>;
|
||||
let activeAccountUnlocked: BehaviorSubject<boolean>;
|
||||
let syncNotifierService: MockProxy<SyncNotifierService>;
|
||||
let sync: Subject<SyncEventArgs>;
|
||||
|
||||
const resetStateService = async (
|
||||
customizeStateService: (stateService: MockProxy<StateService>) => void
|
||||
@@ -25,7 +20,7 @@ describe("Organization Service", () => {
|
||||
stateService.activeAccount$ = activeAccount;
|
||||
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
|
||||
customizeStateService(stateService);
|
||||
organizationService = new OrganizationService(stateService, syncNotifierService);
|
||||
organizationService = new OrganizationService(stateService);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
};
|
||||
|
||||
@@ -41,12 +36,7 @@ describe("Organization Service", () => {
|
||||
"1": organizationData("1", "Test Org"),
|
||||
});
|
||||
|
||||
sync = new Subject<SyncEventArgs>();
|
||||
|
||||
syncNotifierService = mock<SyncNotifierService>();
|
||||
syncNotifierService.sync$ = sync;
|
||||
|
||||
organizationService = new OrganizationService(stateService, syncNotifierService);
|
||||
organizationService = new OrganizationService(stateService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -169,36 +159,6 @@ describe("Organization Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncEvent works", () => {
|
||||
it("Complete event updates data", async () => {
|
||||
sync.next({
|
||||
status: "Completed",
|
||||
successfully: true,
|
||||
data: new SyncResponse({
|
||||
profile: {
|
||||
organizations: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Updated Name",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
expect(stateService.setOrganizations).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(stateService.setOrganizations).toHaveBeenLastCalledWith(
|
||||
matches((organizationData: { [id: string]: OrganizationData }) => {
|
||||
const organization = organizationData["1"];
|
||||
return organization?.name === "Updated Name";
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function organizationData(id: string, name: string) {
|
||||
const data = new OrganizationData({} as any);
|
||||
data.id = id;
|
||||
|
||||
@@ -161,6 +161,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",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { EventType } from "../enums/eventType";
|
||||
|
||||
export abstract class EventService {
|
||||
collect: (
|
||||
eventType: EventType,
|
||||
cipherId?: string,
|
||||
uploadImmediately?: boolean,
|
||||
organizationId?: string
|
||||
) => Promise<any>;
|
||||
uploadEvents: (userId?: string) => Promise<any>;
|
||||
clearEvents: (userId?: string) => Promise<any>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EventType } from "../../enums/eventType";
|
||||
|
||||
export abstract class EventCollectionService {
|
||||
collect: (
|
||||
eventType: EventType,
|
||||
cipherId?: string,
|
||||
uploadImmediately?: boolean,
|
||||
organizationId?: string
|
||||
) => Promise<any>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export abstract class EventUploadService {
|
||||
uploadEvents: (userId?: string) => Promise<void>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
import { I18nService } from "../i18n.service";
|
||||
|
||||
@@ -55,6 +56,10 @@ export function canAccessAdmin(i18nService: I18nService) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isNotProviderUser(org: Organization): boolean {
|
||||
return !org.isProviderUser;
|
||||
}
|
||||
|
||||
export abstract class OrganizationService {
|
||||
organizations$: Observable<Organization[]>;
|
||||
|
||||
@@ -69,3 +74,7 @@ export abstract class OrganizationService {
|
||||
canManageSponsorships: () => Promise<boolean>;
|
||||
hasOrganizations: () => boolean;
|
||||
}
|
||||
|
||||
export abstract class InternalOrganizationService extends OrganizationService {
|
||||
replace: (organizations: { [id: string]: OrganizationData }) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -20,7 +20,9 @@ export class OrganizationData {
|
||||
useSso: boolean;
|
||||
useKeyConnector: boolean;
|
||||
useScim: boolean;
|
||||
useCustomPermissions: boolean;
|
||||
useResetPassword: boolean;
|
||||
useSecretsManager: boolean;
|
||||
selfHost: boolean;
|
||||
usersGetPremium: boolean;
|
||||
seats: number;
|
||||
@@ -60,7 +62,9 @@ export class OrganizationData {
|
||||
this.useSso = response.useSso;
|
||||
this.useKeyConnector = response.useKeyConnector;
|
||||
this.useScim = response.useScim;
|
||||
this.useCustomPermissions = response.useCustomPermissions;
|
||||
this.useResetPassword = response.useResetPassword;
|
||||
this.useSecretsManager = response.useSecretsManager;
|
||||
this.selfHost = response.selfHost;
|
||||
this.usersGetPremium = response.usersGetPremium;
|
||||
this.seats = response.seats;
|
||||
|
||||
@@ -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";
|
||||
@@ -20,7 +22,9 @@ export class Organization {
|
||||
useSso: boolean;
|
||||
useKeyConnector: boolean;
|
||||
useScim: boolean;
|
||||
useCustomPermissions: boolean;
|
||||
useResetPassword: boolean;
|
||||
useSecretsManager: boolean;
|
||||
selfHost: boolean;
|
||||
usersGetPremium: boolean;
|
||||
seats: number;
|
||||
@@ -64,7 +68,9 @@ export class Organization {
|
||||
this.useSso = obj.useSso;
|
||||
this.useKeyConnector = obj.useKeyConnector;
|
||||
this.useScim = obj.useScim;
|
||||
this.useCustomPermissions = obj.useCustomPermissions;
|
||||
this.useResetPassword = obj.useResetPassword;
|
||||
this.useSecretsManager = obj.useSecretsManager;
|
||||
this.selfHost = obj.selfHost;
|
||||
this.usersGetPremium = obj.usersGetPremium;
|
||||
this.seats = obj.seats;
|
||||
@@ -205,4 +211,19 @@ export class Organization {
|
||||
get hasProvider() {
|
||||
return this.providerId != null || this.providerName != null;
|
||||
}
|
||||
|
||||
get canAccessSecretsManager() {
|
||||
return this.useSecretsManager;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
useSso: boolean;
|
||||
useKeyConnector: boolean;
|
||||
useScim: boolean;
|
||||
useCustomPermissions: boolean;
|
||||
useResetPassword: boolean;
|
||||
useSecretsManager: boolean;
|
||||
selfHost: boolean;
|
||||
usersGetPremium: boolean;
|
||||
seats: number;
|
||||
@@ -59,7 +61,9 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
this.useSso = this.getResponseProperty("UseSso");
|
||||
this.useKeyConnector = this.getResponseProperty("UseKeyConnector") ?? false;
|
||||
this.useScim = this.getResponseProperty("UseScim") ?? false;
|
||||
this.useCustomPermissions = this.getResponseProperty("UseCustomPermissions") ?? false;
|
||||
this.useResetPassword = this.getResponseProperty("UseResetPassword");
|
||||
this.useSecretsManager = this.getResponseProperty("UseSecretsManager");
|
||||
this.selfHost = this.getResponseProperty("SelfHost");
|
||||
this.usersGetPremium = this.getResponseProperty("UsersGetPremium");
|
||||
this.seats = this.getResponseProperty("Seats");
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,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;
|
||||
@@ -2021,7 +2026,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);
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
import { CipherService } from "../abstractions/cipher.service";
|
||||
import { EventService as EventServiceAbstraction } from "../abstractions/event.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { OrganizationService } from "../abstractions/organization/organization.service.abstraction";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { EventType } from "../enums/eventType";
|
||||
import { EventData } from "../models/data/event.data";
|
||||
import { EventRequest } from "../models/request/event.request";
|
||||
|
||||
export class EventService implements EventServiceAbstraction {
|
||||
private inited = false;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private cipherService: CipherService,
|
||||
private stateService: StateService,
|
||||
private logService: LogService,
|
||||
private organizationService: OrganizationService
|
||||
) {}
|
||||
|
||||
init(checkOnInterval: boolean) {
|
||||
if (this.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inited = true;
|
||||
if (checkOnInterval) {
|
||||
this.uploadEvents();
|
||||
setInterval(() => this.uploadEvents(), 60 * 1000); // check every 60 seconds
|
||||
}
|
||||
}
|
||||
|
||||
async collect(
|
||||
eventType: EventType,
|
||||
cipherId: string = null,
|
||||
uploadImmediately = false,
|
||||
organizationId: string = null
|
||||
): Promise<any> {
|
||||
const authed = await this.stateService.getIsAuthenticated();
|
||||
if (!authed) {
|
||||
return;
|
||||
}
|
||||
const organizations = await this.organizationService.getAll();
|
||||
if (organizations == null) {
|
||||
return;
|
||||
}
|
||||
const orgIds = new Set<string>(organizations.filter((o) => o.useEvents).map((o) => o.id));
|
||||
if (orgIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (cipherId != null) {
|
||||
const cipher = await this.cipherService.get(cipherId);
|
||||
if (cipher == null || cipher.organizationId == null || !orgIds.has(cipher.organizationId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (organizationId != null) {
|
||||
if (!orgIds.has(organizationId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let eventCollection = await this.stateService.getEventCollection();
|
||||
if (eventCollection == null) {
|
||||
eventCollection = [];
|
||||
}
|
||||
const event = new EventData();
|
||||
event.type = eventType;
|
||||
event.cipherId = cipherId;
|
||||
event.date = new Date().toISOString();
|
||||
event.organizationId = organizationId;
|
||||
eventCollection.push(event);
|
||||
await this.stateService.setEventCollection(eventCollection);
|
||||
if (uploadImmediately) {
|
||||
await this.uploadEvents();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadEvents(userId?: string): Promise<any> {
|
||||
const authed = await this.stateService.getIsAuthenticated({ userId: userId });
|
||||
if (!authed) {
|
||||
return;
|
||||
}
|
||||
const eventCollection = await this.stateService.getEventCollection({ userId: userId });
|
||||
if (eventCollection == null || eventCollection.length === 0) {
|
||||
return;
|
||||
}
|
||||
const request = eventCollection.map((e) => {
|
||||
const req = new EventRequest();
|
||||
req.type = e.type;
|
||||
req.cipherId = e.cipherId;
|
||||
req.date = e.date;
|
||||
req.organizationId = e.organizationId;
|
||||
return req;
|
||||
});
|
||||
try {
|
||||
await this.apiService.postEventsCollect(request);
|
||||
this.clearEvents(userId);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async clearEvents(userId?: string): Promise<any> {
|
||||
await this.stateService.setEventCollection(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
61
libs/common/src/services/event/event-collection.service.ts
Normal file
61
libs/common/src/services/event/event-collection.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { CipherService } from "../../abstractions/cipher.service";
|
||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service";
|
||||
import { EventUploadService } from "../../abstractions/event/event-upload.service";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { EventType } from "../../enums/eventType";
|
||||
import { EventData } from "../../models/data/event.data";
|
||||
|
||||
export class EventCollectionService implements EventCollectionServiceAbstraction {
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private stateService: StateService,
|
||||
private organizationService: OrganizationService,
|
||||
private eventUploadService: EventUploadService
|
||||
) {}
|
||||
|
||||
async collect(
|
||||
eventType: EventType,
|
||||
cipherId: string = null,
|
||||
uploadImmediately = false,
|
||||
organizationId: string = null
|
||||
): Promise<any> {
|
||||
const authed = await this.stateService.getIsAuthenticated();
|
||||
if (!authed) {
|
||||
return;
|
||||
}
|
||||
const organizations = await this.organizationService.getAll();
|
||||
if (organizations == null) {
|
||||
return;
|
||||
}
|
||||
const orgIds = new Set<string>(organizations.filter((o) => o.useEvents).map((o) => o.id));
|
||||
if (orgIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (cipherId != null) {
|
||||
const cipher = await this.cipherService.get(cipherId);
|
||||
if (cipher == null || cipher.organizationId == null || !orgIds.has(cipher.organizationId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (organizationId != null) {
|
||||
if (!orgIds.has(organizationId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let eventCollection = await this.stateService.getEventCollection();
|
||||
if (eventCollection == null) {
|
||||
eventCollection = [];
|
||||
}
|
||||
const event = new EventData();
|
||||
event.type = eventType;
|
||||
event.cipherId = cipherId;
|
||||
event.date = new Date().toISOString();
|
||||
event.organizationId = organizationId;
|
||||
eventCollection.push(event);
|
||||
await this.stateService.setEventCollection(eventCollection);
|
||||
if (uploadImmediately) {
|
||||
await this.eventUploadService.uploadEvents();
|
||||
}
|
||||
}
|
||||
}
|
||||
55
libs/common/src/services/event/event-upload.service.ts
Normal file
55
libs/common/src/services/event/event-upload.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { EventUploadService as EventUploadServiceAbstraction } from "../../abstractions/event/event-upload.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { EventRequest } from "../../models/request/event.request";
|
||||
|
||||
export class EventUploadService implements EventUploadServiceAbstraction {
|
||||
private inited = false;
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private stateService: StateService,
|
||||
private logService: LogService
|
||||
) {}
|
||||
|
||||
init(checkOnInterval: boolean) {
|
||||
if (this.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inited = true;
|
||||
if (checkOnInterval) {
|
||||
this.uploadEvents();
|
||||
setInterval(() => this.uploadEvents(), 60 * 1000); // check every 60 seconds
|
||||
}
|
||||
}
|
||||
|
||||
async uploadEvents(userId?: string): Promise<void> {
|
||||
const authed = await this.stateService.getIsAuthenticated({ userId: userId });
|
||||
if (!authed) {
|
||||
return;
|
||||
}
|
||||
const eventCollection = await this.stateService.getEventCollection({ userId: userId });
|
||||
if (eventCollection == null || eventCollection.length === 0) {
|
||||
return;
|
||||
}
|
||||
const request = eventCollection.map((e) => {
|
||||
const req = new EventRequest();
|
||||
req.type = e.type;
|
||||
req.cipherId = e.cipherId;
|
||||
req.date = e.date;
|
||||
req.organizationId = e.organizationId;
|
||||
return req;
|
||||
});
|
||||
try {
|
||||
await this.apiService.postEventsCollect(request);
|
||||
this.clearEvents(userId);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async clearEvents(userId?: string): Promise<any> {
|
||||
await this.stateService.setEventCollection(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export class MemoryStorageService
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return this.get(key) != null;
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
save(key: string, obj: any): Promise<any> {
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { BehaviorSubject, concatMap, filter } from "rxjs";
|
||||
import { BehaviorSubject, concatMap } from "rxjs";
|
||||
|
||||
import { OrganizationService as OrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { InternalOrganizationService as InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { SyncNotifierService } from "../../abstractions/sync/syncNotifier.service.abstraction";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
import { isSuccessfullyCompleted } from "../../types/syncEventArgs";
|
||||
|
||||
export class OrganizationService implements OrganizationServiceAbstraction {
|
||||
private _organizations = new BehaviorSubject<Organization[]>([]);
|
||||
export class OrganizationService implements InternalOrganizationServiceAbstraction {
|
||||
protected _organizations = new BehaviorSubject<Organization[]>([]);
|
||||
|
||||
organizations$ = this._organizations.asObservable();
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private syncNotifierService: SyncNotifierService
|
||||
) {
|
||||
constructor(private stateService: StateService) {
|
||||
this.stateService.activeAccountUnlocked$
|
||||
.pipe(
|
||||
concatMap(async (unlocked) => {
|
||||
@@ -29,28 +24,6 @@ export class OrganizationService implements OrganizationServiceAbstraction {
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.syncNotifierService.sync$
|
||||
.pipe(
|
||||
filter(isSuccessfullyCompleted),
|
||||
concatMap(async ({ data }) => {
|
||||
const { profile } = data;
|
||||
const organizations: { [id: string]: OrganizationData } = {};
|
||||
profile.organizations.forEach((o) => {
|
||||
organizations[o.id] = new OrganizationData(o);
|
||||
});
|
||||
|
||||
profile.providerOrganizations.forEach((o) => {
|
||||
if (organizations[o.id] == null) {
|
||||
organizations[o.id] = new OrganizationData(o);
|
||||
organizations[o.id].isProviderUser = true;
|
||||
}
|
||||
});
|
||||
|
||||
await this.updateStateAndObservables(organizations);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async getAll(userId?: string): Promise<Organization[]> {
|
||||
@@ -78,7 +51,7 @@ export class OrganizationService implements OrganizationServiceAbstraction {
|
||||
|
||||
organizations[organization.id] = organization;
|
||||
|
||||
await this.updateStateAndObservables(organizations);
|
||||
await this.replace(organizations);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
@@ -92,7 +65,7 @@ export class OrganizationService implements OrganizationServiceAbstraction {
|
||||
}
|
||||
|
||||
delete organizations[id];
|
||||
await this.updateStateAndObservables(organizations);
|
||||
await this.replace(organizations);
|
||||
}
|
||||
|
||||
get(id: string): Organization {
|
||||
@@ -121,9 +94,9 @@ export class OrganizationService implements OrganizationServiceAbstraction {
|
||||
return organizations.find((organization) => organization.identifier === identifier);
|
||||
}
|
||||
|
||||
private async updateStateAndObservables(organizationsMap: { [id: string]: OrganizationData }) {
|
||||
await this.stateService.setOrganizations(organizationsMap);
|
||||
this.updateObservables(organizationsMap);
|
||||
async replace(organizations: { [id: string]: OrganizationData }) {
|
||||
await this.stateService.setOrganizations(organizations);
|
||||
this.updateObservables(organizations);
|
||||
}
|
||||
|
||||
private updateObservables(organizationsMap: { [id: string]: OrganizationData }) {
|
||||
|
||||
@@ -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,19 +67,22 @@ 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;
|
||||
private isRecoveredSession = false;
|
||||
|
||||
private accountDiskCache = new Map<string, TAccount>();
|
||||
protected accountDiskCache = new BehaviorSubject<Record<string, TAccount>>({});
|
||||
|
||||
// default account serializer, must be overridden by child class
|
||||
protected accountDeserializer = Account.fromJSON as (json: Jsonify<TAccount>) => TAccount;
|
||||
|
||||
constructor(
|
||||
protected storageService: 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())
|
||||
@@ -2378,7 +2383,7 @@ export class StateService<
|
||||
}
|
||||
|
||||
if (this.useAccountCache) {
|
||||
const cachedAccount = this.accountDiskCache.get(options.userId);
|
||||
const cachedAccount = this.accountDiskCache.value[options.userId];
|
||||
if (cachedAccount != null) {
|
||||
return cachedAccount;
|
||||
}
|
||||
@@ -2392,9 +2397,7 @@ export class StateService<
|
||||
))
|
||||
: await this.storageService.get<TAccount>(options.userId, options);
|
||||
|
||||
if (this.useAccountCache) {
|
||||
this.accountDiskCache.set(options.userId, account);
|
||||
}
|
||||
this.setDiskCache(options.userId, account);
|
||||
return account;
|
||||
}
|
||||
|
||||
@@ -2425,9 +2428,7 @@ export class StateService<
|
||||
|
||||
await storageLocation.save(`${options.userId}`, account, options);
|
||||
|
||||
if (this.useAccountCache) {
|
||||
this.accountDiskCache.delete(options.userId);
|
||||
}
|
||||
this.deleteDiskCache(options.userId);
|
||||
}
|
||||
|
||||
protected async saveAccountToMemory(account: TAccount): Promise<void> {
|
||||
@@ -2638,9 +2639,7 @@ export class StateService<
|
||||
userId = userId ?? state.activeUserId;
|
||||
delete state.accounts[userId];
|
||||
|
||||
if (this.useAccountCache) {
|
||||
this.accountDiskCache.delete(userId);
|
||||
}
|
||||
this.deleteDiskCache(userId);
|
||||
|
||||
return state;
|
||||
});
|
||||
@@ -2744,7 +2743,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;
|
||||
}
|
||||
@@ -2766,51 +2765,21 @@ export class StateService<
|
||||
});
|
||||
}
|
||||
|
||||
private mapToRecord<V>(map: Map<string, V>): Record<string, V> {
|
||||
return map == null ? null : Object.fromEntries(map);
|
||||
private setDiskCache(key: string, value: TAccount, options?: StorageOptions) {
|
||||
if (this.useAccountCache) {
|
||||
this.accountDiskCache.value[key] = value;
|
||||
this.accountDiskCache.next(this.accountDiskCache.value);
|
||||
}
|
||||
}
|
||||
|
||||
private recordToMap<V>(record: Record<string, V>): Map<string, V> {
|
||||
return record == null ? null : new Map(Object.entries(record));
|
||||
private deleteDiskCache(key: string) {
|
||||
if (this.useAccountCache) {
|
||||
delete this.accountDiskCache.value[key];
|
||||
this.accountDiskCache.next(this.accountDiskCache.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>(
|
||||
memberConstructor: new (...args: any[]) => T,
|
||||
memberConverter: (input: any) => T = (i) => i
|
||||
@@ -2847,7 +2816,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))
|
||||
);
|
||||
|
||||
@@ -7,17 +7,18 @@ import { InternalFolderService } from "../../abstractions/folder/folder.service.
|
||||
import { KeyConnectorService } from "../../abstractions/keyConnector.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { MessagingService } from "../../abstractions/messaging.service";
|
||||
import { InternalOrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { InternalPolicyService } from "../../abstractions/policy/policy.service.abstraction";
|
||||
import { ProviderService } from "../../abstractions/provider.service";
|
||||
import { SendService } from "../../abstractions/send.service";
|
||||
import { SettingsService } from "../../abstractions/settings.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { SyncService as SyncServiceAbstraction } from "../../abstractions/sync/sync.service.abstraction";
|
||||
import { SyncNotifierService } from "../../abstractions/sync/syncNotifier.service.abstraction";
|
||||
import { sequentialize } from "../../misc/sequentialize";
|
||||
import { CipherData } from "../../models/data/cipher.data";
|
||||
import { CollectionData } from "../../models/data/collection.data";
|
||||
import { FolderData } from "../../models/data/folder.data";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { ProviderData } from "../../models/data/provider.data";
|
||||
import { SendData } from "../../models/data/send.data";
|
||||
@@ -52,7 +53,7 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
private stateService: StateService,
|
||||
private providerService: ProviderService,
|
||||
private folderApiService: FolderApiServiceAbstraction,
|
||||
private syncNotifierService: SyncNotifierService,
|
||||
private organizationService: InternalOrganizationService,
|
||||
private logoutCallback: (expired: boolean) => Promise<void>
|
||||
) {}
|
||||
|
||||
@@ -76,10 +77,8 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
@sequentialize(() => "fullSync")
|
||||
async fullSync(forceSync: boolean, allowThrowOnError = false): Promise<boolean> {
|
||||
this.syncStarted();
|
||||
this.syncNotifierService.next({ status: "Started" });
|
||||
const isAuthenticated = await this.stateService.getIsAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
this.syncNotifierService.next({ status: "Completed", successfully: false });
|
||||
return this.syncCompleted(false);
|
||||
}
|
||||
|
||||
@@ -95,7 +94,6 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
|
||||
if (!needsSync) {
|
||||
await this.setLastSync(now);
|
||||
this.syncNotifierService.next({ status: "Completed", successfully: false });
|
||||
return this.syncCompleted(false);
|
||||
}
|
||||
|
||||
@@ -112,13 +110,11 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
await this.syncPolicies(response.policies);
|
||||
|
||||
await this.setLastSync(now);
|
||||
this.syncNotifierService.next({ status: "Completed", successfully: true, data: response });
|
||||
return this.syncCompleted(true);
|
||||
} catch (e) {
|
||||
if (allowThrowOnError) {
|
||||
throw e;
|
||||
} else {
|
||||
this.syncNotifierService.next({ status: "Completed", successfully: false });
|
||||
return this.syncCompleted(false);
|
||||
}
|
||||
}
|
||||
@@ -315,11 +311,24 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
await this.stateService.setForcePasswordReset(response.forcePasswordReset);
|
||||
await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector);
|
||||
|
||||
const organizations: { [id: string]: OrganizationData } = {};
|
||||
response.organizations.forEach((o) => {
|
||||
organizations[o.id] = new OrganizationData(o);
|
||||
});
|
||||
|
||||
const providers: { [id: string]: ProviderData } = {};
|
||||
response.providers.forEach((p) => {
|
||||
providers[p.id] = new ProviderData(p);
|
||||
});
|
||||
|
||||
response.providerOrganizations.forEach((o) => {
|
||||
if (organizations[o.id] == null) {
|
||||
organizations[o.id] = new OrganizationData(o);
|
||||
organizations[o.id].isProviderUser = true;
|
||||
}
|
||||
});
|
||||
|
||||
await this.organizationService.replace(organizations);
|
||||
await this.providerService.save(providers);
|
||||
|
||||
if (await this.keyConnectorService.userNeedsMigration()) {
|
||||
|
||||
@@ -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,7 @@
|
||||
import { Directive, HostListener, Input, OnDestroy, Optional } from "@angular/core";
|
||||
import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
|
||||
|
||||
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
|
||||
@@ -23,7 +24,8 @@ export class BitActionDirective implements OnDestroy {
|
||||
|
||||
constructor(
|
||||
private buttonComponent: ButtonLikeAbstraction,
|
||||
@Optional() private validationService?: ValidationService
|
||||
@Optional() private validationService?: ValidationService,
|
||||
@Optional() private logService?: LogService
|
||||
) {}
|
||||
|
||||
get loading() {
|
||||
@@ -44,7 +46,12 @@ export class BitActionDirective implements OnDestroy {
|
||||
this.loading = true;
|
||||
functionToObservable(this.handler)
|
||||
.pipe(
|
||||
tap({ error: (err: unknown) => this.validationService?.showError(err) }),
|
||||
tap({
|
||||
error: (err: unknown) => {
|
||||
this.logService?.error(`Async action exception: ${err}`);
|
||||
this.validationService?.showError(err);
|
||||
},
|
||||
}),
|
||||
finalize(() => (this.loading = false)),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Directive, Input, OnDestroy, OnInit, Optional } from "@angular/core";
|
||||
import { FormGroupDirective } from "@angular/forms";
|
||||
import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
|
||||
|
||||
import { FunctionReturningAwaitable, functionToObservable } from "../utils/function-to-observable";
|
||||
@@ -24,7 +25,8 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
|
||||
|
||||
constructor(
|
||||
private formGroupDirective: FormGroupDirective,
|
||||
@Optional() validationService?: ValidationService
|
||||
@Optional() validationService?: ValidationService,
|
||||
@Optional() logService?: LogService
|
||||
) {
|
||||
formGroupDirective.ngSubmit
|
||||
.pipe(
|
||||
@@ -39,6 +41,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
|
||||
|
||||
return awaitable.pipe(
|
||||
catchError((err: unknown) => {
|
||||
logService?.error(`Async submit exception: ${err}`);
|
||||
validationService?.showError(err);
|
||||
return of(undefined);
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { action } from "@storybook/addon-actions";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
import { delay, of } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
@@ -68,6 +69,12 @@ export default {
|
||||
showError: action("ValidationService.showError"),
|
||||
} as Partial<ValidationService>,
|
||||
},
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
error: action("LogService.error"),
|
||||
} as Partial<LogService>,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ type SizeTypes = "large" | "default" | "small";
|
||||
|
||||
const SizeClasses: Record<SizeTypes, string[]> = {
|
||||
large: ["tw-h-16", "tw-w-16"],
|
||||
default: ["tw-h-12", "tw-w-12"],
|
||||
default: ["tw-h-10", "tw-w-10"],
|
||||
small: ["tw-h-7", "tw-w-7"],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
111
libs/components/src/checkbox/checkbox.stories.ts
Normal file
111
libs/components/src/checkbox/checkbox.stories.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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",
|
||||
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",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const DefaultTemplate: Story<ExampleComponent> = (args: ExampleComponent) => ({
|
||||
props: args,
|
||||
template: `<app-example [checked]="checked" [disabled]="disabled"></app-example>`,
|
||||
});
|
||||
|
||||
export const Default = DefaultTemplate.bind({});
|
||||
Default.parameters = {
|
||||
docs: {
|
||||
source: {
|
||||
code: template,
|
||||
},
|
||||
},
|
||||
};
|
||||
Default.args = {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
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>
|
||||
`,
|
||||
});
|
||||
CustomTemplate.args = {};
|
||||
|
||||
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";
|
||||
@@ -9,7 +9,7 @@ export class DialogComponent {
|
||||
@Input() dialogSize: "small" | "default" | "large" = "default";
|
||||
|
||||
private _disablePadding: boolean;
|
||||
@Input() set disablePadding(value: boolean) {
|
||||
@Input() set disablePadding(value: boolean | string) {
|
||||
this._disablePadding = coerceBooleanProperty(value);
|
||||
}
|
||||
get disablePadding() {
|
||||
|
||||
@@ -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,12 @@
|
||||
export type InputTypes =
|
||||
| "text"
|
||||
| "password"
|
||||
| "number"
|
||||
| "datetime-local"
|
||||
| "email"
|
||||
| "checkbox"
|
||||
| "search";
|
||||
|
||||
export abstract class BitFormFieldControl {
|
||||
ariaDescribedBy: string;
|
||||
id: string;
|
||||
@@ -5,4 +14,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 }],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ export * from "./badge-list";
|
||||
export * from "./banner";
|
||||
export * from "./button";
|
||||
export * from "./callout";
|
||||
export * from "./checkbox";
|
||||
export * from "./color-password";
|
||||
export * from "./dialog";
|
||||
export * from "./form-field";
|
||||
export * from "./icon-button";
|
||||
@@ -12,8 +14,8 @@ export * from "./icon";
|
||||
export * from "./link";
|
||||
export * from "./menu";
|
||||
export * from "./multi-select";
|
||||
export * from "./tabs";
|
||||
export * from "./navigation";
|
||||
export * from "./table";
|
||||
export * from "./tabs";
|
||||
export * from "./toggle-group";
|
||||
export * from "./color-password";
|
||||
export * from "./utils/i18n-mock.service";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,49 +6,85 @@ const linkStyles: Record<LinkType, string[]> = {
|
||||
primary: [
|
||||
"!tw-text-primary-500",
|
||||
"hover:!tw-text-primary-500",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
"focus-visible:before:tw-ring-primary-700",
|
||||
"disabled:!tw-text-primary-500/60",
|
||||
],
|
||||
secondary: [
|
||||
"!tw-text-main",
|
||||
"hover:!tw-text-main",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
"focus-visible:before:tw-ring-primary-700",
|
||||
"disabled:!tw-text-muted/60",
|
||||
],
|
||||
contrast: [
|
||||
"!tw-text-contrast",
|
||||
"hover:!tw-text-contrast",
|
||||
"focus-visible:tw-ring-text-contrast",
|
||||
"focus-visible:before:tw-ring-text-contrast",
|
||||
"disabled:!tw-text-contrast/60",
|
||||
],
|
||||
};
|
||||
|
||||
@Directive({
|
||||
selector: "button[bitLink], a[bitLink]",
|
||||
})
|
||||
export class LinkDirective {
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-font-semibold",
|
||||
"tw-py-0.5",
|
||||
"tw-px-0",
|
||||
"tw-bg-transparent",
|
||||
"tw-border-0",
|
||||
"tw-border-none",
|
||||
"tw-rounded",
|
||||
"tw-transition",
|
||||
"hover:tw-underline",
|
||||
"hover:tw-decoration-1",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:tw-underline",
|
||||
"focus-visible:tw-decoration-1",
|
||||
"focus-visible:tw-ring-2",
|
||||
"focus-visible:tw-z-10",
|
||||
"disabled:tw-no-underline",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
].concat(linkStyles[this.linkType] ?? []);
|
||||
}
|
||||
const commonStyles = [
|
||||
"tw-leading-none",
|
||||
"tw-p-0",
|
||||
"tw-font-semibold",
|
||||
"tw-bg-transparent",
|
||||
"tw-border-0",
|
||||
"tw-border-none",
|
||||
"tw-rounded",
|
||||
"tw-transition",
|
||||
"hover:tw-underline",
|
||||
"hover:tw-decoration-1",
|
||||
"disabled:tw-no-underline",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:tw-underline",
|
||||
"focus-visible:tw-decoration-1",
|
||||
|
||||
// Workaround for html button tag not being able to be set to `display: inline`
|
||||
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
|
||||
// https://github.com/w3c/csswg-drafts/issues/3226
|
||||
// Add `tw-inline`, add `tw-py-0.5` and use regular `tw-ring` if issue is fixed.
|
||||
//
|
||||
// https://github.com/tailwindlabs/tailwindcss/issues/3595
|
||||
// Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better:
|
||||
// switch to `outline` with `outline-offset` when Safari supports border radius on outline.
|
||||
// Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred.
|
||||
"tw-relative",
|
||||
"before:tw-content-['']",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:-tw-inset-x-[0.1em]",
|
||||
"before:tw-rounded-md",
|
||||
"before:tw-transition",
|
||||
"before:tw-ring-2",
|
||||
"focus-visible:before:tw-ring-text-contrast",
|
||||
"focus-visible:tw-z-10",
|
||||
];
|
||||
|
||||
@Directive()
|
||||
abstract class LinkDirective {
|
||||
@Input()
|
||||
linkType: LinkType = "primary";
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: "a[bitLink]",
|
||||
})
|
||||
export class AnchorLinkDirective extends LinkDirective {
|
||||
@HostBinding("class") get classList() {
|
||||
return ["before:-tw-inset-y-[0.125rem]"]
|
||||
.concat(commonStyles)
|
||||
.concat(linkStyles[this.linkType] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: "button[bitLink]",
|
||||
})
|
||||
export class ButtonLinkDirective extends LinkDirective {
|
||||
@HostBinding("class") get classList() {
|
||||
return ["before:-tw-inset-y-[0.25rem]"]
|
||||
.concat(commonStyles)
|
||||
.concat(linkStyles[this.linkType] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { LinkDirective } from "./link.directive";
|
||||
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule],
|
||||
exports: [LinkDirective],
|
||||
declarations: [LinkDirective],
|
||||
exports: [AnchorLinkDirective, ButtonLinkDirective],
|
||||
declarations: [AnchorLinkDirective, ButtonLinkDirective],
|
||||
})
|
||||
export class LinkModule {}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Meta, Story } from "@storybook/angular";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { LinkDirective } from "./link.directive";
|
||||
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
|
||||
import { LinkModule } from "./link.module";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Link",
|
||||
component: LinkDirective,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [LinkModule],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
linkType: {
|
||||
options: ["primary", "secondary", "contrast"],
|
||||
@@ -19,25 +24,33 @@ export default {
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const ButtonTemplate: Story<LinkDirective> = (args: LinkDirective) => ({
|
||||
const ButtonTemplate: Story<ButtonLinkDirective> = (args: ButtonLinkDirective) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-500': linkType === 'contrast' }">
|
||||
<button bitLink [linkType]="linkType" class="tw-mb-2 tw-block">Button</button>
|
||||
<button bitLink [linkType]="linkType" class="tw-mb-2 tw-block">
|
||||
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
|
||||
Add Icon Button
|
||||
</button>
|
||||
<button bitLink [linkType]="linkType" class="tw-mb-2 tw-block">
|
||||
Chevron Icon Button
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button bitLink [linkType]="linkType" class="tw-text-sm tw-block">Small Button</button>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button bitLink [linkType]="linkType">Button</button>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button bitLink [linkType]="linkType">
|
||||
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
|
||||
Add Icon Button
|
||||
</button>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button bitLink [linkType]="linkType">
|
||||
Chevron Icon Button
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button bitLink [linkType]="linkType" class="tw-text-sm">Small Button</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
const AnchorTemplate: Story<LinkDirective> = (args: LinkDirective) => ({
|
||||
const AnchorTemplate: Story<AnchorLinkDirective> = (args: AnchorLinkDirective) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-500': linkType === 'contrast' }">
|
||||
@@ -73,6 +86,20 @@ Anchors.args = {
|
||||
linkType: "primary",
|
||||
};
|
||||
|
||||
const InlineTemplate: Story = (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<span class="tw-text-main">
|
||||
On the internet pargraphs often contain <a bitLink href="#">inline links</a>, but few know that <button bitLink>buttons</button> can be used for similar purposes.
|
||||
</span>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Inline = InlineTemplate.bind({});
|
||||
Inline.args = {
|
||||
linkType: "primary",
|
||||
};
|
||||
|
||||
const DisabledTemplate: Story = (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
|
||||
1
libs/components/src/navigation/index.ts
Normal file
1
libs/components/src/navigation/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./navigation.module";
|
||||
47
libs/components/src/navigation/nav-base.component.ts
Normal file
47
libs/components/src/navigation/nav-base.component.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Directive, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Base class used in `NavGroupComponent` and `NavItemComponent`
|
||||
*/
|
||||
@Directive()
|
||||
export abstract class NavBaseComponent {
|
||||
/**
|
||||
* Text to display in main content
|
||||
*/
|
||||
@Input() text: string;
|
||||
|
||||
/**
|
||||
* `aria-label` for main content
|
||||
*/
|
||||
@Input() ariaLabel: string;
|
||||
|
||||
/**
|
||||
* Optional icon, e.g. `"bwi-collection"`
|
||||
*/
|
||||
@Input() icon: string;
|
||||
|
||||
/**
|
||||
* Route to be passed to internal `routerLink`
|
||||
*/
|
||||
@Input() route: string | any[];
|
||||
|
||||
/**
|
||||
* If this item is used within a tree, set `variant` to `"tree"`
|
||||
*/
|
||||
@Input() variant: "default" | "tree" = "default";
|
||||
|
||||
/**
|
||||
* Depth level when nested inside of a `'tree'` variant
|
||||
*/
|
||||
@Input() treeDepth = 0;
|
||||
|
||||
/**
|
||||
* If `true`, do not change styles when nav item is active.
|
||||
*/
|
||||
@Input() hideActiveStyles = false;
|
||||
|
||||
/**
|
||||
* Fires when main content is clicked
|
||||
*/
|
||||
@Output() mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<div class="tw-h-px tw-w-full tw-bg-secondary-300"></div>
|
||||
7
libs/components/src/navigation/nav-divider.component.ts
Normal file
7
libs/components/src/navigation/nav-divider.component.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "bit-nav-divider",
|
||||
templateUrl: "./nav-divider.component.html",
|
||||
})
|
||||
export class NavDividerComponent {}
|
||||
46
libs/components/src/navigation/nav-group.component.html
Normal file
46
libs/components/src/navigation/nav-group.component.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!-- This a higher order component that composes `NavItemComponent` -->
|
||||
<bit-nav-item
|
||||
[text]="text"
|
||||
[icon]="icon"
|
||||
[route]="route"
|
||||
[variant]="variant"
|
||||
(mainContentClicked)="toggle()"
|
||||
[treeDepth]="treeDepth"
|
||||
(mainContentClicked)="mainContentClicked.emit()"
|
||||
[ariaLabel]="ariaLabel"
|
||||
>
|
||||
<ng-template #button>
|
||||
<button
|
||||
class="tw-ml-auto"
|
||||
[bitIconButton]="
|
||||
open ? 'bwi-chevron-up' : variant === 'tree' ? 'bwi-angle-right' : 'bwi-angle-down'
|
||||
"
|
||||
[buttonType]="'main'"
|
||||
(click)="toggle($event)"
|
||||
size="small"
|
||||
[title]="'toggleCollapse' | i18n"
|
||||
aria-haspopup="true"
|
||||
[attr.aria-expanded]="open.toString()"
|
||||
[attr.aria-controls]="contentId"
|
||||
[attr.aria-label]="['toggleCollapse' | i18n, text].join(' ')"
|
||||
></button>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show toggle to the left for trees otherwise to the right -->
|
||||
<ng-container slot-start *ngIf="variant === 'tree'">
|
||||
<ng-container *ngTemplateOutlet="button"></ng-container>
|
||||
</ng-container>
|
||||
<ng-container slot-end *ngIf="variant !== 'tree'">
|
||||
<ng-container *ngTemplateOutlet="button"></ng-container>
|
||||
</ng-container>
|
||||
</bit-nav-item>
|
||||
|
||||
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->
|
||||
<div
|
||||
*ngIf="open"
|
||||
[attr.id]="contentId"
|
||||
[attr.aria-label]="[text, 'submenu' | i18n].join(' ')"
|
||||
role="group"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
62
libs/components/src/navigation/nav-group.component.ts
Normal file
62
libs/components/src/navigation/nav-group.component.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
AfterContentInit,
|
||||
Component,
|
||||
ContentChildren,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
QueryList,
|
||||
} from "@angular/core";
|
||||
|
||||
import { NavBaseComponent } from "./nav-base.component";
|
||||
import { NavItemComponent } from "./nav-item.component";
|
||||
|
||||
@Component({
|
||||
selector: "bit-nav-group",
|
||||
templateUrl: "./nav-group.component.html",
|
||||
})
|
||||
export class NavGroupComponent extends NavBaseComponent implements AfterContentInit {
|
||||
@ContentChildren(NavGroupComponent, {
|
||||
descendants: true,
|
||||
})
|
||||
nestedGroups!: QueryList<NavGroupComponent>;
|
||||
|
||||
@ContentChildren(NavItemComponent, {
|
||||
descendants: true,
|
||||
})
|
||||
nestedItems!: QueryList<NavItemComponent>;
|
||||
|
||||
/**
|
||||
* UID for `[attr.aria-controls]`
|
||||
*/
|
||||
protected contentId = Math.random().toString(36).substring(2);
|
||||
|
||||
/**
|
||||
* Is `true` if the expanded content is visible
|
||||
*/
|
||||
@Input()
|
||||
open = false;
|
||||
@Output()
|
||||
openChange = new EventEmitter<boolean>();
|
||||
|
||||
protected toggle(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
this.open = !this.open;
|
||||
}
|
||||
|
||||
/**
|
||||
* - For any nested NavGroupComponents or NavItemComponents, increment the `treeDepth` by 1.
|
||||
*/
|
||||
private initNestedStyles() {
|
||||
if (this.variant !== "tree") {
|
||||
return;
|
||||
}
|
||||
[...this.nestedGroups, ...this.nestedItems].forEach((navGroupOrItem) => {
|
||||
navGroupOrItem.treeDepth += 1;
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
this.initNestedStyles();
|
||||
}
|
||||
}
|
||||
74
libs/components/src/navigation/nav-group.stories.ts
Normal file
74
libs/components/src/navigation/nav-group.stories.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { NavGroupComponent } from "./nav-group.component";
|
||||
import { NavigationModule } from "./navigation.module";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Nav/Nav Group",
|
||||
component: NavGroupComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [SharedModule, RouterTestingModule, NavigationModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
submenu: "submenu",
|
||||
toggleCollapse: "toggle collapse",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
export const Default: Story<NavGroupComponent> = (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-group text="Hello World (Anchor)" [route]="['']" icon="bwi-filter" [open]="true">
|
||||
<bit-nav-item text="Child A" route="#" icon="bwi-filter"></bit-nav-item>
|
||||
<bit-nav-item text="Child B" route="#"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" route="#" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group text="Lorem Ipsum (Button)" icon="bwi-filter">
|
||||
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
|
||||
<bit-nav-item text="Child B"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Tree: Story<NavGroupComponent> = (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-group text="Tree example" icon="bwi-collection" [open]="true">
|
||||
<bit-nav-group text="Level 1 - with children (empty)" route="#" icon="bwi-collection" variant="tree"></bit-nav-group>
|
||||
<bit-nav-item text="Level 1 - no childen" route="#" icon="bwi-collection" variant="tree"></bit-nav-item>
|
||||
<bit-nav-group text="Level 1 - with children" route="#" icon="bwi-collection" variant="tree" [open]="true">
|
||||
<bit-nav-group text="Level 2 - with children" route="#" icon="bwi-collection" variant="tree" [open]="true">
|
||||
<bit-nav-item text="Level 3 - no childen, no icon" route="#" variant="tree"></bit-nav-item>
|
||||
<bit-nav-group text="Level 3 - with children" route="#" icon="bwi-collection" variant="tree" [open]="true">
|
||||
<bit-nav-item text="Level 4 - no childen, no icon" route="#" variant="tree"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group text="Level 2 - with children (empty)" route="#" icon="bwi-collection" variant="tree" [open]="true"></bit-nav-group>
|
||||
<bit-nav-item text="Level 2 - no childen" route="#" icon="bwi-collection" variant="tree"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item text="Level 1 - no childen" route="#" icon="bwi-collection" variant="tree"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
`,
|
||||
});
|
||||
79
libs/components/src/navigation/nav-item.component.html
Normal file
79
libs/components/src/navigation/nav-item.component.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<div
|
||||
class="tw-relative"
|
||||
[ngClass]="[
|
||||
showActiveStyles ? 'tw-bg-background-alt4' : 'tw-bg-background-alt3',
|
||||
fvwStyles$ | async
|
||||
]"
|
||||
>
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'padding-left': (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem'
|
||||
}"
|
||||
class="tw-relative tw-flex tw-items-center tw-pr-4"
|
||||
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
|
||||
>
|
||||
<div
|
||||
#slotStart
|
||||
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*]:!tw-text-alt2 [&>*:hover]:!tw-border-text-alt2"
|
||||
>
|
||||
<ng-content select="[slot-start]"></ng-content>
|
||||
</div>
|
||||
<!-- Default content for #slotStart (for consistent sizing) -->
|
||||
<div
|
||||
*ngIf="slotStart.childElementCount === 0"
|
||||
[ngClass]="{
|
||||
'tw-w-0': variant !== 'tree'
|
||||
}"
|
||||
>
|
||||
<button
|
||||
class="tw-invisible"
|
||||
[bitIconButton]="'bwi-angle-down'"
|
||||
size="small"
|
||||
aria-hidden="true"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="route; then isAnchor; else isButton"></ng-container>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<i class="bwi bwi-fw tw-text-alt2 tw-mx-1 {{ icon }}"></i
|
||||
><span [ngClass]="showActiveStyles ? 'tw-font-bold' : 'tw-font-semibold'">{{ text }}</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if a value was passed to `this.to` -->
|
||||
<ng-template #isAnchor>
|
||||
<!-- The `fvw` class passes focus to `this.focusVisibleWithin$` -->
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
<a
|
||||
class="fvw tw-w-full tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&>:not(.bwi)]:hover:tw-underline"
|
||||
[routerLink]="route"
|
||||
[attr.aria-label]="ariaLabel || text"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="rlaOptions"
|
||||
[ariaCurrentWhenActive]="'page'"
|
||||
(isActiveChange)="setActive($event)"
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if `this.to` is falsy -->
|
||||
<ng-template #isButton>
|
||||
<!-- Class field should match `#isAnchor` class field above -->
|
||||
<button
|
||||
class="fvw tw-w-full tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&>:not(.bwi)]:hover:tw-underline"
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<div
|
||||
class="tw-flex tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*]:!tw-text-alt2 [&>*:hover]:!tw-border-text-alt2"
|
||||
>
|
||||
<ng-content select="[slot-end]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
48
libs/components/src/navigation/nav-item.component.ts
Normal file
48
libs/components/src/navigation/nav-item.component.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Component, HostListener } from "@angular/core";
|
||||
import { IsActiveMatchOptions } from "@angular/router";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
|
||||
import { NavBaseComponent } from "./nav-base.component";
|
||||
|
||||
@Component({
|
||||
selector: "bit-nav-item",
|
||||
templateUrl: "./nav-item.component.html",
|
||||
})
|
||||
export class NavItemComponent extends NavBaseComponent {
|
||||
/**
|
||||
* Is `true` if `to` matches the current route
|
||||
*/
|
||||
private _active = false;
|
||||
protected setActive(isActive: boolean) {
|
||||
this._active = isActive;
|
||||
}
|
||||
protected get showActiveStyles() {
|
||||
return this._active && !this.hideActiveStyles;
|
||||
}
|
||||
protected readonly rlaOptions: IsActiveMatchOptions = {
|
||||
paths: "exact",
|
||||
queryParams: "exact",
|
||||
fragment: "ignored",
|
||||
matrixParams: "ignored",
|
||||
};
|
||||
|
||||
/**
|
||||
* The design spec calls for the an outline to wrap the entire element when the template's anchor/button has :focus-visible.
|
||||
* Usually, we would use :focus-within for this. However, that matches when a child element has :focus instead of :focus-visible.
|
||||
*
|
||||
* Currently, the browser does not have a pseudo selector that combines these two, e.g. :focus-visible-within (WICG/focus-visible#151)
|
||||
* To make our own :focus-visible-within functionality, we use event delegation on the host and manually check if the focus target (denoted with the .fvw class) matches :focus-visible. We then map that state to some styles, so the entire component can have an outline.
|
||||
*/
|
||||
protected focusVisibleWithin$ = new BehaviorSubject(false);
|
||||
protected fvwStyles$ = this.focusVisibleWithin$.pipe(
|
||||
map((value) => (value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-text-alt2" : ""))
|
||||
);
|
||||
@HostListener("focusin", ["$event.target"])
|
||||
onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin$.next(target.matches(".fvw:focus-visible"));
|
||||
}
|
||||
@HostListener("focusout")
|
||||
onFocusOut() {
|
||||
this.focusVisibleWithin$.next(false);
|
||||
}
|
||||
}
|
||||
93
libs/components/src/navigation/nav-item.stories.ts
Normal file
93
libs/components/src/navigation/nav-item.stories.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
|
||||
import { NavItemComponent } from "./nav-item.component";
|
||||
import { NavigationModule } from "./navigation.module";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Nav/Nav Item",
|
||||
component: NavItemComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [],
|
||||
imports: [RouterTestingModule, IconButtonModule, NavigationModule],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<NavItemComponent> = (args: NavItemComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-item text="${args.text}" [route]="['']" icon="${args.icon}"></bit-nav-item>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
text: "Hello World",
|
||||
icon: "bwi-filter",
|
||||
};
|
||||
|
||||
export const WithoutIcon = Template.bind({});
|
||||
WithoutIcon.args = {
|
||||
text: "Hello World",
|
||||
icon: "",
|
||||
};
|
||||
|
||||
export const WithoutRoute: Story<NavItemComponent> = (args: NavItemComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
|
||||
`,
|
||||
});
|
||||
|
||||
export const WithChildButtons: Story<NavItemComponent> = (args: NavItemComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-item text="Hello World" [route]="['']" icon="bwi-collection">
|
||||
<button
|
||||
slot-start
|
||||
class="tw-ml-auto"
|
||||
[bitIconButton]="'bwi-clone'"
|
||||
[buttonType]="'contrast'"
|
||||
size="small"
|
||||
aria-label="option 1"
|
||||
></button>
|
||||
<button
|
||||
slot-end
|
||||
class="tw-ml-auto"
|
||||
[bitIconButton]="'bwi-pencil-square'"
|
||||
[buttonType]="'contrast'"
|
||||
size="small"
|
||||
aria-label="option 2"
|
||||
></button>
|
||||
<button
|
||||
slot-end
|
||||
class="tw-ml-auto"
|
||||
[bitIconButton]="'bwi-check'"
|
||||
[buttonType]="'contrast'"
|
||||
size="small"
|
||||
aria-label="option 3"
|
||||
></button>
|
||||
</bit-nav-item>
|
||||
`,
|
||||
});
|
||||
|
||||
export const MultipleItemsWithDivider: Story<NavItemComponent> = (args: NavItemComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection"></bit-nav-item>
|
||||
`,
|
||||
});
|
||||
18
libs/components/src/navigation/navigation.module.ts
Normal file
18
libs/components/src/navigation/navigation.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { IconButtonModule } from "../icon-button/icon-button.module";
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
|
||||
import { NavDividerComponent } from "./nav-divider.component";
|
||||
import { NavGroupComponent } from "./nav-group.component";
|
||||
import { NavItemComponent } from "./nav-item.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, SharedModule, IconButtonModule, OverlayModule, RouterModule],
|
||||
declarations: [NavDividerComponent, NavGroupComponent, NavItemComponent],
|
||||
exports: [NavDividerComponent, NavGroupComponent, NavItemComponent],
|
||||
})
|
||||
export class NavigationModule {}
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user