1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-21 10:43:35 +00:00

Merge branch 'master' into feature/org-admin-refresh

This commit is contained in:
Shane Melton
2022-09-28 11:48:53 -07:00
152 changed files with 1501 additions and 980 deletions

View File

@@ -9,7 +9,7 @@ import { FolderService } from "@bitwarden/common/abstractions/folder/folder.serv
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PasswordRepromptService } from "@bitwarden/common/abstractions/passwordReprompt.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";

View File

@@ -1,6 +1,6 @@
import { Component, Input, OnInit } from "@angular/core";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { StateService } from "@bitwarden/common/abstractions/state.service";
@Component({
@@ -23,7 +23,7 @@ export class ExportScopeCalloutComponent implements OnInit {
) {}
async ngOnInit(): Promise<void> {
if (!(await this.organizationService.hasOrganizations())) {
if (!this.organizationService.hasOrganizations()) {
return;
}
this.scopeConfig =
@@ -31,7 +31,7 @@ export class ExportScopeCalloutComponent implements OnInit {
? {
title: "exportingOrganizationVaultTitle",
description: "exportingOrganizationVaultDescription",
scopeIdentifier: (await this.organizationService.get(this.organizationId)).name,
scopeIdentifier: this.organizationService.get(this.organizationId).name,
}
: {
title: "exportingPersonalVaultTitle",

View File

@@ -1,7 +1,6 @@
import { Directive, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { KeyConnectorService } from "@bitwarden/common/abstractions/keyConnector.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction";
@@ -23,7 +22,6 @@ export class RemovePasswordComponent implements OnInit {
constructor(
private router: Router,
private stateService: StateService,
private apiService: ApiService,
private syncService: SyncService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
@@ -70,9 +68,7 @@ export class RemovePasswordComponent implements OnInit {
try {
this.leaving = true;
this.actionPromise = this.organizationApiService.leave(this.organization.id).then(() => {
return this.syncService.fullSync(true);
});
this.actionPromise = this.organizationApiService.leave(this.organization.id);
await this.actionPromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("leftOrganization"));
await this.keyConnectorService.removeConvertAccountRequired();

View File

@@ -1,29 +1,33 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
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.service";
import { 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";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { CipherView } from "@bitwarden/common/models/view/cipherView";
import { CollectionView } from "@bitwarden/common/models/view/collectionView";
import { Checkable, isChecked } from "@bitwarden/common/types/checkable";
@Directive()
export class ShareComponent implements OnInit {
export class ShareComponent implements OnInit, OnDestroy {
@Input() cipherId: string;
@Input() organizationId: string;
@Output() onSharedCipher = new EventEmitter();
formPromise: Promise<any>;
formPromise: Promise<void>;
cipher: CipherView;
collections: CollectionView[] = [];
organizations: Organization[] = [];
collections: Checkable<CollectionView>[] = [];
organizations$: Observable<Organization[]>;
protected writeableCollections: CollectionView[] = [];
protected writeableCollections: Checkable<CollectionView>[] = [];
private _destroy = new Subject<void>();
constructor(
protected collectionService: CollectionService,
@@ -38,24 +42,37 @@ export class ShareComponent implements OnInit {
await this.load();
}
ngOnDestroy(): void {
this._destroy.next();
this._destroy.complete();
}
async load() {
const allCollections = await this.collectionService.getAllDecrypted();
this.writeableCollections = allCollections.map((c) => c).filter((c) => !c.readOnly);
const orgs = await this.organizationService.getAll();
this.organizations = orgs
.sort(Utils.getSortFunction(this.i18nService, "name"))
.filter((o) => o.enabled && o.status === OrganizationUserStatusType.Confirmed);
this.organizations$ = this.organizationService.organizations$.pipe(
map((orgs) => {
return orgs
.filter((o) => o.enabled && o.status === OrganizationUserStatusType.Confirmed)
.sort(Utils.getSortFunction(this.i18nService, "name"));
})
);
this.organizations$.pipe(takeUntil(this._destroy)).subscribe((orgs) => {
if (this.organizationId == null && orgs.length > 0) {
this.organizationId = orgs[0].id;
}
});
const cipherDomain = await this.cipherService.get(this.cipherId);
this.cipher = await cipherDomain.decrypt();
if (this.organizationId == null && this.organizations.length > 0) {
this.organizationId = this.organizations[0].id;
}
this.filterCollections();
}
filterCollections() {
this.writeableCollections.forEach((c) => ((c as any).checked = false));
this.writeableCollections.forEach((c) => (c.checked = false));
if (this.organizationId == null || this.writeableCollections.length === 0) {
this.collections = [];
} else {
@@ -66,9 +83,7 @@ export class ShareComponent implements OnInit {
}
async submit(): Promise<boolean> {
const selectedCollectionIds = this.collections
.filter((c) => !!(c as any).checked)
.map((c) => c.id);
const selectedCollectionIds = this.collections.filter(isChecked).map((c) => c.id);
if (selectedCollectionIds.length === 0) {
this.platformUtilsService.showToast(
"error",
@@ -80,9 +95,9 @@ export class ShareComponent implements OnInit {
const cipherDomain = await this.cipherService.get(this.cipherId);
const cipherView = await cipherDomain.decrypt();
const orgs = await firstValueFrom(this.organizations$);
const orgName =
this.organizations.find((o) => o.id === this.organizationId)?.name ??
this.i18nService.t("organization");
orgs.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization");
try {
this.formPromise = this.cipherService
@@ -106,7 +121,7 @@ export class ShareComponent implements OnInit {
get canSave() {
if (this.collections != null) {
for (let i = 0; i < this.collections.length; i++) {
if ((this.collections[i] as any).checked) {
if (this.collections[i].checked) {
return true;
}
}

View File

@@ -32,8 +32,8 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarde
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/abstractions/messaging.service";
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
import { OrganizationService as OrganizationServiceAbstraction } from "@bitwarden/common/abstractions/organization.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 { 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";
@@ -50,6 +50,7 @@ import { StateService as StateServiceAbstraction } from "@bitwarden/common/abstr
import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/abstractions/stateMigration.service";
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/abstractions/sync/sync.service.abstraction";
import { SyncNotifierService as SyncNotifierServiceAbstraction } from "@bitwarden/common/abstractions/sync/syncNotifier.service.abstraction";
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/abstractions/token.service";
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/abstractions/totp.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/abstractions/twoFactor.service";
@@ -84,8 +85,8 @@ import { FolderService } from "@bitwarden/common/services/folder/folder.service"
import { FormValidationErrorsService } from "@bitwarden/common/services/formValidationErrors.service";
import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
import { OrganizationService } from "@bitwarden/common/services/organization.service";
import { OrganizationApiService } from "@bitwarden/common/services/organization/organization-api.service";
import { OrganizationService } from "@bitwarden/common/services/organization/organization.service";
import { PasswordGenerationService } from "@bitwarden/common/services/passwordGeneration.service";
import { PolicyApiService } from "@bitwarden/common/services/policy/policy-api.service";
import { PolicyService } from "@bitwarden/common/services/policy/policy.service";
@@ -96,6 +97,7 @@ import { SettingsService } from "@bitwarden/common/services/settings.service";
import { StateService } from "@bitwarden/common/services/state.service";
import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service";
import { SyncService } from "@bitwarden/common/services/sync/sync.service";
import { SyncNotifierService } from "@bitwarden/common/services/sync/syncNotifier.service";
import { TokenService } from "@bitwarden/common/services/token.service";
import { TotpService } from "@bitwarden/common/services/totp.service";
import { TwoFactorService } from "@bitwarden/common/services/twoFactor.service";
@@ -338,9 +340,9 @@ import { ValidationService } from "./validation.service";
LogService,
KeyConnectorServiceAbstraction,
StateServiceAbstraction,
OrganizationServiceAbstraction,
ProviderServiceAbstraction,
FolderApiServiceAbstraction,
SyncNotifierServiceAbstraction,
LOGOUT_CALLBACK,
],
},
@@ -506,7 +508,7 @@ import { ValidationService } from "./validation.service";
{
provide: OrganizationServiceAbstraction,
useClass: OrganizationService,
deps: [StateServiceAbstraction],
deps: [StateServiceAbstraction, SyncNotifierServiceAbstraction],
},
{
provide: ProviderServiceAbstraction,
@@ -534,7 +536,15 @@ import { ValidationService } from "./validation.service";
{
provide: OrganizationApiServiceAbstraction,
useClass: OrganizationApiService,
deps: [ApiServiceAbstraction],
// This is a slightly odd dependency tree for a specialized api service
// it depends on SyncService so that new data can be retrieved through the sync
// rather than updating the OrganizationService directly. Instead OrganizationService
// subscribes to sync notifications and will update itself based on that.
deps: [ApiServiceAbstraction, SyncServiceAbstraction],
},
{
provide: SyncNotifierServiceAbstraction,
useClass: SyncNotifierService,
},
{
provide: ConfigServiceAbstraction,

View File

@@ -4,7 +4,7 @@ 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.service";
import { 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";
@@ -37,8 +37,8 @@ export class VaultFilterService {
return new Set(await this.stateService.getCollapsedGroupings());
}
async buildOrganizations(): Promise<Organization[]> {
return await this.organizationService.getAll();
buildOrganizations(): Promise<Organization[]> {
return this.organizationService.getAll();
}
buildNestedFolders(organizationId?: string): Observable<DynamicTreeNode<FolderView>> {

View File

@@ -1,4 +1,4 @@
import Substitute, { Arg, SubstituteOf } from "@fluffy-spoon/substitute";
import { Substitute, Arg, SubstituteOf } from "@fluffy-spoon/substitute";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";

View File

@@ -1,4 +1,4 @@
import Substitute, { Arg } from "@fluffy-spoon/substitute";
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { CipherType } from "@bitwarden/common/enums/cipherType";

View File

@@ -1,4 +1,4 @@
import Substitute, { Arg } from "@fluffy-spoon/substitute";
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { mock, MockProxy } from "jest-mock-extended";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";

View File

@@ -1,4 +1,4 @@
import Substitute, { Arg } from "@fluffy-spoon/substitute";
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { UriMatchType } from "@bitwarden/common/enums/uriMatchType";
import { LoginData } from "@bitwarden/common/models/data/loginData";

View File

@@ -1,4 +1,4 @@
import Substitute, { Arg, SubstituteOf } from "@fluffy-spoon/substitute";
import { Substitute, Arg, SubstituteOf } from "@fluffy-spoon/substitute";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";

View File

@@ -1,4 +1,4 @@
import Substitute, { Arg } from "@fluffy-spoon/substitute";
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { SendType } from "@bitwarden/common/enums/sendType";
import { SendAccess } from "@bitwarden/common/models/domain/sendAccess";

View File

@@ -1,4 +1,4 @@
import Substitute, { SubstituteOf } from "@fluffy-spoon/substitute";
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";

View File

@@ -0,0 +1,210 @@
import { MockProxy, mock, any, mockClear, matches } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, Subject } 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/organizationData";
import { SyncResponse } from "@bitwarden/common/models/response/syncResponse";
import { OrganizationService } from "@bitwarden/common/services/organization/organization.service";
import { SyncEventArgs } from "@bitwarden/common/types/syncEventArgs";
describe("Organization Service", () => {
let organizationService: OrganizationService;
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
) => {
mockClear(stateService);
stateService = mock<StateService>();
stateService.activeAccount$ = activeAccount;
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
customizeStateService(stateService);
organizationService = new OrganizationService(stateService, syncNotifierService);
await new Promise((r) => setTimeout(r, 50));
};
beforeEach(() => {
activeAccount = new BehaviorSubject("123");
activeAccountUnlocked = new BehaviorSubject(true);
stateService = mock<StateService>();
stateService.activeAccount$ = activeAccount;
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
stateService.getOrganizations.calledWith(any()).mockResolvedValue({
"1": organizationData("1", "Test Org"),
});
sync = new Subject<SyncEventArgs>();
syncNotifierService = mock<SyncNotifierService>();
syncNotifierService.sync$ = sync;
organizationService = new OrganizationService(stateService, syncNotifierService);
});
afterEach(() => {
activeAccount.complete();
activeAccountUnlocked.complete();
});
it("getAll", async () => {
const orgs = await organizationService.getAll();
expect(orgs).toHaveLength(1);
const org = orgs[0];
expect(org).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
});
});
describe("canManageSponsorships", () => {
it("can because one is available", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Org"), familySponsorshipAvailable: true },
});
});
const result = await organizationService.canManageSponsorships();
expect(result).toBe(true);
});
it("can because one is used", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Test Org"), familySponsorshipFriendlyName: "Something" },
});
});
const result = await organizationService.canManageSponsorships();
expect(result).toBe(true);
});
it("can not because one isn't available or taken", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Org"), familySponsorshipFriendlyName: null },
});
});
const result = await organizationService.canManageSponsorships();
expect(result).toBe(false);
});
});
describe("get", () => {
it("exists", async () => {
const result = organizationService.get("1");
expect(result).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
});
});
it("does not exist", async () => {
const result = organizationService.get("2");
expect(result).toBe(undefined);
});
});
it("upsert", async () => {
await organizationService.upsert(organizationData("2", "Test 2"));
expect(await firstValueFrom(organizationService.organizations$)).toEqual([
{
id: "1",
name: "Test Org",
identifier: "test",
},
{
id: "2",
name: "Test 2",
identifier: "test",
},
]);
});
describe("getByIdentifier", () => {
it("exists", async () => {
const result = organizationService.getByIdentifier("test");
expect(result).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
});
});
it("does not exist", async () => {
const result = organizationService.getByIdentifier("blah");
expect(result).toBeUndefined();
});
});
describe("delete", () => {
it("exists", async () => {
await organizationService.delete("1");
expect(stateService.getOrganizations).toHaveBeenCalledTimes(2);
expect(stateService.setOrganizations).toHaveBeenCalledTimes(1);
});
it("does not exist", async () => {
organizationService.delete("1");
expect(stateService.getOrganizations).toHaveBeenCalledTimes(2);
});
});
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;
data.name = name;
data.identifier = "test";
return data;
}
});

View File

@@ -1,4 +1,4 @@
import Substitute, { Arg } from "@fluffy-spoon/substitute";
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { EncString } from "@bitwarden/common/models/domain/encString";

View File

@@ -1,4 +1,4 @@
import Substitute from "@fluffy-spoon/substitute";
import { Substitute } from "@fluffy-spoon/substitute";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Utils } from "@bitwarden/common/misc/utils";

View File

@@ -1,11 +0,0 @@
import { OrganizationData } from "../models/data/organizationData";
import { Organization } from "../models/domain/organization";
export abstract class OrganizationService {
get: (id: string) => Promise<Organization>;
getByIdentifier: (identifier: string) => Promise<Organization>;
getAll: (userId?: string) => Promise<Organization[]>;
save: (orgs: { [id: string]: OrganizationData }) => Promise<any>;
canManageSponsorships: () => Promise<boolean>;
hasOrganizations: (userId?: string) => Promise<boolean>;
}

View File

@@ -0,0 +1,55 @@
import { map, Observable } from "rxjs";
import { Utils } from "../../misc/utils";
import { Organization } from "../../models/domain/organization";
import { I18nService } from "../i18n.service";
export function canAccessSettingsTab(org: Organization): boolean {
return org.isOwner;
}
export function canAccessMembersTab(org: Organization): boolean {
return org.canManageUsers || org.canManageUsersPassword;
}
export function canAccessGroupsTab(org: Organization): boolean {
return org.canManageGroups;
}
export function canAccessReportingTab(org: Organization): boolean {
return org.canAccessReports || org.canAccessEventLogs;
}
export function canAccessBillingTab(org: Organization): boolean {
return org.canManageBilling;
}
export function canAccessOrgAdmin(org: Organization): boolean {
return (
canAccessMembersTab(org) ||
canAccessGroupsTab(org) ||
canAccessReportingTab(org) ||
canAccessBillingTab(org) ||
canAccessSettingsTab(org)
);
}
export function getOrganizationById(id: string) {
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
}
export function canAccessAdmin(i18nService: I18nService) {
return map<Organization[], Organization[]>((orgs) =>
orgs.filter(canAccessOrgAdmin).sort(Utils.getSortFunction(i18nService, "name"))
);
}
export abstract class OrganizationService {
organizations$: Observable<Organization[]>;
get: (id: string) => Organization;
getByIdentifier: (identifier: string) => Organization;
getAll: (userId?: string) => Promise<Organization[]>;
canManageSponsorships: () => Promise<boolean>;
hasOrganizations: () => boolean;
}

View File

@@ -273,7 +273,13 @@ export abstract class StateService<T extends Account = Account> {
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use OrganizationService
*/
getOrganizations: (options?: StorageOptions) => Promise<{ [id: string]: OrganizationData }>;
/**
* @deprecated Do not call this directly, use OrganizationService
*/
setOrganizations: (
value: { [id: string]: OrganizationData },
options?: StorageOptions

View File

@@ -1,17 +1,12 @@
import { Observable } from "rxjs";
import {
SyncCipherNotification,
SyncFolderNotification,
SyncSendNotification,
} from "../../models/response/notificationResponse";
import { SyncEventArgs } from "../../types/syncEventArgs";
export abstract class SyncService {
syncInProgress: boolean;
sync$: Observable<SyncEventArgs>;
getLastSync: () => Promise<Date>;
setLastSync: (date: Date, userId?: string) => Promise<any>;
fullSync: (forceSync: boolean, allowThrowOnError?: boolean) => Promise<boolean>;

View File

@@ -0,0 +1,8 @@
import { Observable } from "rxjs";
import { SyncEventArgs } from "../../types/syncEventArgs";
export abstract class SyncNotifierService {
sync$: Observable<SyncEventArgs>;
next: (event: SyncEventArgs) => void;
}

View File

@@ -310,8 +310,11 @@ export class Utils {
return map;
}
static getSortFunction(i18nService: I18nService, prop: string) {
return (a: any, b: any) => {
static getSortFunction<T>(
i18nService: I18nService,
prop: { [K in keyof T]: T[K] extends string ? K : never }[keyof T]
): (a: T, b: T) => number {
return (a, b) => {
if (a[prop] == null && b[prop] != null) {
return -1;
}
@@ -322,9 +325,10 @@ export class Utils {
return 0;
}
// The `as unknown as string` here is unfortunate because typescript doesn't property understand that the return of T[prop] will be a string
return i18nService.collator
? i18nService.collator.compare(a[prop], b[prop])
: a[prop].localeCompare(b[prop]);
? i18nService.collator.compare(a[prop] as unknown as string, b[prop] as unknown as string)
: (a[prop] as unknown as string).localeCompare(b[prop] as unknown as string);
};
}

View File

@@ -48,14 +48,16 @@ export class ConfigService implements ConfigServiceAbstraction {
}
private async fetchServerConfig(): Promise<ServerConfig> {
const response = await this.configApiService.get();
const data = new ServerConfigData(response);
try {
const response = await this.configApiService.get();
if (data != null) {
await this.stateService.setServerConfig(data);
return new ServerConfig(data);
if (response != null) {
const data = new ServerConfigData(response);
await this.stateService.setServerConfig(data);
return new ServerConfig(data);
}
} catch {
return null;
}
return null;
}
}

View File

@@ -2,7 +2,7 @@ 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.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/eventData";

View File

@@ -3,7 +3,7 @@ import { CryptoService } from "../abstractions/crypto.service";
import { CryptoFunctionService } from "../abstractions/cryptoFunction.service";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/keyConnector.service";
import { LogService } from "../abstractions/log.service";
import { OrganizationService } from "../abstractions/organization.service";
import { OrganizationService } from "../abstractions/organization/organization.service.abstraction";
import { StateService } from "../abstractions/state.service";
import { TokenService } from "../abstractions/token.service";
import { OrganizationUserType } from "../enums/organizationUserType";

View File

@@ -1,56 +0,0 @@
import { OrganizationService as OrganizationServiceAbstraction } from "../abstractions/organization.service";
import { StateService } from "../abstractions/state.service";
import { OrganizationData } from "../models/data/organizationData";
import { Organization } from "../models/domain/organization";
export class OrganizationService implements OrganizationServiceAbstraction {
constructor(private stateService: StateService) {}
async get(id: string): Promise<Organization> {
const organizations = await this.stateService.getOrganizations();
// eslint-disable-next-line
if (organizations == null || !organizations.hasOwnProperty(id)) {
return null;
}
return new Organization(organizations[id]);
}
async getByIdentifier(identifier: string): Promise<Organization> {
const organizations = await this.getAll();
if (organizations == null || organizations.length === 0) {
return null;
}
return organizations.find((o) => o.identifier === identifier);
}
async getAll(userId?: string): Promise<Organization[]> {
const organizations = await this.stateService.getOrganizations({ userId: userId });
const response: Organization[] = [];
for (const id in organizations) {
// eslint-disable-next-line
if (organizations.hasOwnProperty(id) && !organizations[id].isProviderUser) {
response.push(new Organization(organizations[id]));
}
}
const sortedResponse = response.sort((a, b) => a.name.localeCompare(b.name));
return sortedResponse;
}
async save(organizations: { [id: string]: OrganizationData }) {
return await this.stateService.setOrganizations(organizations);
}
async canManageSponsorships(): Promise<boolean> {
const orgs = await this.getAll();
return orgs.some(
(o) => o.familySponsorshipAvailable || o.familySponsorshipFriendlyName !== null
);
}
async hasOrganizations(userId?: string): Promise<boolean> {
const organizations = await this.getAll(userId);
return organizations.length > 0;
}
}

View File

@@ -1,5 +1,6 @@
import { ApiService } from "../../abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction";
import { SyncService } from "../../abstractions/sync/sync.service.abstraction";
import { OrganizationApiKeyType } from "../../enums/organizationApiKeyType";
import { ImportDirectoryRequest } from "../../models/request/importDirectoryRequest";
import { OrganizationSsoRequest } from "../../models/request/organization/organizationSsoRequest";
@@ -28,7 +29,7 @@ import { PaymentResponse } from "../../models/response/paymentResponse";
import { TaxInfoResponse } from "../../models/response/taxInfoResponse";
export class OrganizationApiService implements OrganizationApiServiceAbstraction {
constructor(private apiService: ApiService) {}
constructor(private apiService: ApiService, private syncService: SyncService) {}
async get(id: string): Promise<OrganizationResponse> {
const r = await this.apiService.send("GET", "/organizations/" + id, null, true, true);
@@ -80,6 +81,8 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
async create(request: OrganizationCreateRequest): Promise<OrganizationResponse> {
const r = await this.apiService.send("POST", "/organizations", request, true, true);
// Forcing a sync will notify organization service that they need to repull
await this.syncService.fullSync(true);
return new OrganizationResponse(r);
}
@@ -90,7 +93,9 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
async save(id: string, request: OrganizationUpdateRequest): Promise<OrganizationResponse> {
const r = await this.apiService.send("PUT", "/organizations/" + id, request, true, true);
return new OrganizationResponse(r);
const data = new OrganizationResponse(r);
await this.syncService.fullSync(true);
return data;
}
async updatePayment(id: string, request: PaymentRequest): Promise<void> {
@@ -144,7 +149,7 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
}
async verifyBank(id: string, request: VerifyBankRequest): Promise<void> {
return this.apiService.send(
await this.apiService.send(
"POST",
"/organizations/" + id + "/verify-bank",
request,
@@ -162,15 +167,17 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
}
async leave(id: string): Promise<void> {
return this.apiService.send("POST", "/organizations/" + id + "/leave", null, true, false);
await this.apiService.send("POST", "/organizations/" + id + "/leave", null, true, false);
await this.syncService.fullSync(true);
}
async delete(id: string, request: SecretVerificationRequest): Promise<void> {
return this.apiService.send("DELETE", "/organizations/" + id, request, true, false);
await this.apiService.send("DELETE", "/organizations/" + id, request, true, false);
await this.syncService.fullSync(true);
}
async updateLicense(id: string, data: FormData): Promise<void> {
return this.apiService.send("POST", "/organizations/" + id + "/license", data, true, false);
await this.apiService.send("POST", "/organizations/" + id + "/license", data, true, false);
}
async importDirectory(organizationId: string, request: ImportDirectoryRequest): Promise<void> {
@@ -223,6 +230,7 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
}
async updateTaxInfo(id: string, request: OrganizationTaxInfoUpdateRequest): Promise<void> {
// Can't broadcast anything because the response doesn't have content
return this.apiService.send("PUT", "/organizations/" + id + "/tax", request, true, false);
}
@@ -242,6 +250,7 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
true,
true
);
// Not broadcasting anything because data on this response doesn't correspond to `Organization`
return new OrganizationKeysResponse(r);
}
@@ -258,6 +267,7 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
true,
true
);
// Not broadcasting anything because data on this response doesn't correspond to `Organization`
return new OrganizationSsoResponse(r);
}
}

View File

@@ -0,0 +1,119 @@
import { BehaviorSubject, concatMap, filter } from "rxjs";
import { OrganizationService as OrganizationServiceAbstraction } 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/organizationData";
import { Organization } from "../../models/domain/organization";
import { isSuccessfullyCompleted } from "../../types/syncEventArgs";
export class OrganizationService implements OrganizationServiceAbstraction {
private _organizations = new BehaviorSubject<Organization[]>([]);
organizations$ = this._organizations.asObservable();
constructor(
private stateService: StateService,
private syncNotifierService: SyncNotifierService
) {
this.stateService.activeAccountUnlocked$
.pipe(
concatMap(async (unlocked) => {
if (!unlocked) {
this._organizations.next([]);
return;
}
const data = await this.stateService.getOrganizations();
this.updateObservables(data);
})
)
.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[]> {
const organizationsMap = await this.stateService.getOrganizations({ userId: userId });
return Object.values(organizationsMap || {}).map((o) => new Organization(o));
}
async canManageSponsorships(): Promise<boolean> {
const organizations = this._organizations.getValue();
return organizations.some(
(o) => o.familySponsorshipAvailable || o.familySponsorshipFriendlyName !== null
);
}
hasOrganizations(): boolean {
const organizations = this._organizations.getValue();
return organizations.length > 0;
}
async upsert(organization: OrganizationData): Promise<void> {
let organizations = await this.stateService.getOrganizations();
if (organizations == null) {
organizations = {};
}
organizations[organization.id] = organization;
await this.updateStateAndObservables(organizations);
}
async delete(id: string): Promise<void> {
const organizations = await this.stateService.getOrganizations();
if (organizations == null) {
return;
}
if (organizations[id] == null) {
return;
}
delete organizations[id];
await this.updateStateAndObservables(organizations);
}
get(id: string): Organization {
const organizations = this._organizations.getValue();
return organizations.find((organization) => organization.id === id);
}
getByIdentifier(identifier: string): Organization {
const organizations = this._organizations.getValue();
return organizations.find((organization) => organization.identifier === identifier);
}
private async updateStateAndObservables(organizationsMap: { [id: string]: OrganizationData }) {
await this.stateService.setOrganizations(organizationsMap);
this.updateObservables(organizationsMap);
}
private updateObservables(organizationsMap: { [id: string]: OrganizationData }) {
const organizations = Object.values(organizationsMap || {}).map((o) => new Organization(o));
this._organizations.next(organizations);
}
}

View File

@@ -1,5 +1,5 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { StateService } from "@bitwarden/common/abstractions/state.service";

View File

@@ -1,4 +1,4 @@
import { OrganizationService } from "../../abstractions/organization.service";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction";
import { StateService } from "../../abstractions/state.service";
import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType";

View File

@@ -1927,12 +1927,18 @@ export class StateService<
);
}
/**
* @deprecated Do not call this directly, use OrganizationService
*/
async getOrganizations(options?: StorageOptions): Promise<{ [id: string]: OrganizationData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.data?.organizations;
}
/**
* @deprecated Do not call this directly, use OrganizationService
*/
async setOrganizations(
value: { [id: string]: OrganizationData },
options?: StorageOptions

View File

@@ -1,5 +1,3 @@
import { Subject } from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { CipherService } from "../../abstractions/cipher.service";
import { CollectionService } from "../../abstractions/collection.service";
@@ -9,18 +7,17 @@ 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 { OrganizationService } from "../../abstractions/organization.service";
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/cipherData";
import { CollectionData } from "../../models/data/collectionData";
import { FolderData } from "../../models/data/folderData";
import { OrganizationData } from "../../models/data/organizationData";
import { PolicyData } from "../../models/data/policyData";
import { ProviderData } from "../../models/data/providerData";
import { SendData } from "../../models/data/sendData";
@@ -36,15 +33,10 @@ import {
import { PolicyResponse } from "../../models/response/policyResponse";
import { ProfileResponse } from "../../models/response/profileResponse";
import { SendResponse } from "../../models/response/sendResponse";
import { SyncEventArgs } from "../../types/syncEventArgs";
export class SyncService implements SyncServiceAbstraction {
syncInProgress = false;
private _sync = new Subject<SyncEventArgs>();
sync$ = this._sync.asObservable();
constructor(
private apiService: ApiService,
private settingsService: SettingsService,
@@ -58,9 +50,9 @@ export class SyncService implements SyncServiceAbstraction {
private logService: LogService,
private keyConnectorService: KeyConnectorService,
private stateService: StateService,
private organizationService: OrganizationService,
private providerService: ProviderService,
private folderApiService: FolderApiServiceAbstraction,
private syncNotifierService: SyncNotifierService,
private logoutCallback: (expired: boolean) => Promise<void>
) {}
@@ -84,8 +76,10 @@ 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);
}
@@ -101,6 +95,7 @@ export class SyncService implements SyncServiceAbstraction {
if (!needsSync) {
await this.setLastSync(now);
this.syncNotifierService.next({ status: "Completed", successfully: false });
return this.syncCompleted(false);
}
@@ -117,11 +112,13 @@ 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);
}
}
@@ -272,13 +269,11 @@ export class SyncService implements SyncServiceAbstraction {
private syncStarted() {
this.syncInProgress = true;
this.messagingService.send("syncStarted");
this._sync.next({ status: "Started" });
}
private syncCompleted(successfully: boolean): boolean {
this.syncInProgress = false;
this.messagingService.send("syncCompleted", { successfully: successfully });
this._sync.next({ status: successfully ? "SuccessfullyCompleted" : "UnsuccessfullyCompleted" });
return successfully;
}
@@ -320,24 +315,11 @@ 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.save(organizations);
await this.providerService.save(providers);
if (await this.keyConnectorService.userNeedsMigration()) {

View File

@@ -0,0 +1,18 @@
import { Subject } from "rxjs";
import { SyncNotifierService as SyncNotifierServiceAbstraction } from "../../abstractions/sync/syncNotifier.service.abstraction";
import { SyncEventArgs } from "../../types/syncEventArgs";
/**
* This class should most likely have 0 dependencies because it will hopefully
* be rolled into SyncService once upon a time.
*/
export class SyncNotifierService implements SyncNotifierServiceAbstraction {
private _sync = new Subject<SyncEventArgs>();
sync$ = this._sync.asObservable();
next(event: SyncEventArgs): void {
this._sync.next(event);
}
}

View File

@@ -0,0 +1,9 @@
type CheckableBase = {
checked?: boolean;
};
export type Checkable<T> = T & CheckableBase;
export function isChecked(item: CheckableBase): boolean {
return !!item.checked;
}

View File

@@ -1,15 +1,38 @@
import { filter } from "rxjs";
import { SyncResponse } from "../models/response/syncResponse";
export type SyncStatus = "Started" | "SuccessfullyCompleted" | "UnsuccessfullyCompleted";
type SyncStatus = "Started" | "Completed";
export type SyncEventArgs = {
status: SyncStatus;
type SyncEventArgsBase<T extends SyncStatus> = {
status: T;
};
type SyncCompletedEventArgsBase<T extends boolean> = SyncEventArgsBase<"Completed"> & {
successfully: T;
};
type SyncSuccessfullyCompletedEventArgs = SyncCompletedEventArgsBase<true> & {
data: SyncResponse;
};
export type SyncEventArgs =
| SyncSuccessfullyCompletedEventArgs
| SyncCompletedEventArgsBase<false>
| SyncEventArgsBase<"Started">;
/**
* Helper function to filter only on successfully completed syncs
* @returns a function that can be used in a `.pipe()` from an observable
* @returns a function that can be used in a `.pipe(filter(...))` from an observable
* @example
* ```
* of<SyncEventArgs>({ status: "Completed", successfully: true, data: new SyncResponse() })
* .pipe(filter(isSuccessfullyCompleted))
* .subscribe(event => {
* console.log(event.data);
* });
* ```
*/
export function onlySuccessfullyCompleted() {
return filter<SyncEventArgs>((syncEvent) => syncEvent.status === "SuccessfullyCompleted");
export function isSuccessfullyCompleted(
syncEvent: SyncEventArgs
): syncEvent is SyncSuccessfullyCompletedEventArgs {
return syncEvent.status === "Completed" && syncEvent.successfully;
}

View File

@@ -0,0 +1,8 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<ng-content></ng-content>
</span>
<span class="tw-absolute tw-inset-0" [ngClass]="{ 'tw-invisible': !loading }">
<i class="bwi bwi-spinner bwi-lg bwi-spin tw-align-baseline" aria-hidden="true"></i>
</span>
</span>

View File

@@ -8,6 +8,7 @@ describe("Button", () => {
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let buttonDebugElement: DebugElement;
let disabledButtonDebugElement: DebugElement;
let linkDebugElement: DebugElement;
beforeEach(waitForAsync(() => {
@@ -20,6 +21,7 @@ describe("Button", () => {
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;
buttonDebugElement = fixture.debugElement.query(By.css("button"));
disabledButtonDebugElement = fixture.debugElement.query(By.css("button#disabled"));
linkDebugElement = fixture.debugElement.query(By.css("a"));
}));
@@ -60,16 +62,67 @@ describe("Button", () => {
expect(buttonDebugElement.nativeElement.classList.contains("tw-block")).toBe(false);
expect(linkDebugElement.nativeElement.classList.contains("tw-block")).toBe(false);
});
it("should not be disabled when loading and disabled are false", () => {
testAppComponent.loading = false;
testAppComponent.disabled = false;
fixture.detectChanges();
expect(buttonDebugElement.attributes["loading"]).toBeFalsy();
expect(linkDebugElement.attributes["loading"]).toBeFalsy();
expect(buttonDebugElement.nativeElement.disabled).toBeFalsy();
});
it("should be disabled when disabled is true", () => {
testAppComponent.disabled = true;
fixture.detectChanges();
expect(buttonDebugElement.nativeElement.disabled).toBeTruthy();
// Anchor tags cannot be disabled.
});
it("should be disabled when attribute disabled is true", () => {
expect(disabledButtonDebugElement.nativeElement.disabled).toBeTruthy();
});
it("should be disabled when loading is true", () => {
testAppComponent.loading = true;
fixture.detectChanges();
expect(buttonDebugElement.nativeElement.disabled).toBeTruthy();
});
});
@Component({
selector: "test-app",
template: `
<button type="button" bitButton [buttonType]="buttonType" [block]="block">Button</button>
<a href="#" bitButton [buttonType]="buttonType" [block]="block"> Link </a>
<button
type="button"
bitButton
[buttonType]="buttonType"
[block]="block"
[disabled]="disabled"
[loading]="loading"
>
Button
</button>
<a
href="#"
bitButton
[buttonType]="buttonType"
[block]="block"
[disabled]="disabled"
[loading]="loading"
>
Link
</a>
<button id="disabled" type="button" bitButton disabled>Button</button>
`,
})
class TestApp {
buttonType: string;
block: boolean;
disabled: boolean;
loading: boolean;
}

View File

@@ -1,4 +1,4 @@
import { Input, HostBinding, Directive } from "@angular/core";
import { Input, HostBinding, Component } from "@angular/core";
export type ButtonTypes = "primary" | "secondary" | "danger";
@@ -38,10 +38,11 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
],
};
@Directive({
@Component({
selector: "button[bitButton], a[bitButton]",
templateUrl: "button.component.html",
})
export class ButtonDirective {
export class ButtonComponent {
@HostBinding("class") get classList() {
return [
"tw-font-semibold",
@@ -65,6 +66,14 @@ export class ButtonDirective {
.concat(buttonStyles[this.buttonType ?? "secondary"]);
}
@HostBinding("attr.disabled")
get disabledAttr() {
const disabled = this.disabled != null && this.disabled !== false;
return disabled || this.loading ? true : null;
}
@Input() buttonType: ButtonTypes = null;
@Input() block?: boolean;
@Input() loading = false;
@Input() disabled = false;
}

View File

@@ -1,11 +1,11 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { ButtonDirective } from "./button.directive";
import { ButtonComponent } from "./button.component";
@NgModule({
imports: [CommonModule],
exports: [ButtonDirective],
declarations: [ButtonDirective],
exports: [ButtonComponent],
declarations: [ButtonComponent],
})
export class ButtonModule {}

View File

@@ -1,12 +1,14 @@
import { Meta, Story } from "@storybook/angular";
import { ButtonDirective } from "./button.directive";
import { ButtonComponent } from "./button.component";
export default {
title: "Component Library/Button",
component: ButtonDirective,
component: ButtonComponent,
args: {
buttonType: "primary",
disabled: false,
loading: false,
},
parameters: {
design: {
@@ -16,11 +18,11 @@ export default {
},
} as Meta;
const Template: Story<ButtonDirective> = (args: ButtonDirective) => ({
const Template: Story<ButtonComponent> = (args: ButtonComponent) => ({
props: args,
template: `
<button bitButton [buttonType]="buttonType" [block]="block">Button</button>
<a bitButton [buttonType]="buttonType" [block]="block" href="#" class="tw-ml-2">Link</a>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block">Button</button>
<a bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" href="#" class="tw-ml-2">Link</a>
`,
});
@@ -39,21 +41,50 @@ Danger.args = {
buttonType: "danger",
};
const DisabledTemplate: Story = (args) => ({
const AllStylesTemplate: Story = (args) => ({
props: args,
template: `
<button bitButton disabled buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton disabled buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton disabled buttonType="danger" class="tw-mr-2">Danger</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
`,
});
export const Disabled = DisabledTemplate.bind({});
Disabled.args = {
size: "small",
export const Loading = AllStylesTemplate.bind({});
Loading.args = {
disabled: false,
loading: true,
};
const BlockTemplate: Story<ButtonDirective> = (args: ButtonDirective) => ({
export const Disabled = AllStylesTemplate.bind({});
Disabled.args = {
disabled: true,
loading: false,
};
const DisabledWithAttributeTemplate: Story = (args) => ({
props: args,
template: `
<ng-container *ngIf="disabled">
<button bitButton disabled [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
</ng-container>
<ng-container *ngIf="!disabled">
<button bitButton [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
</ng-container>
`,
});
export const DisabledWithAttribute = DisabledWithAttributeTemplate.bind({});
DisabledWithAttribute.args = {
disabled: true,
loading: false,
};
const BlockTemplate: Story<ButtonComponent> = (args: ButtonComponent) => ({
props: args,
template: `
<span class="tw-flex">

View File

@@ -1,2 +1,2 @@
export * from "./button.directive";
export * from "./button.component";
export * from "./button.module";

View File

@@ -19,7 +19,7 @@
></button>
</div>
<div class="tw-overflow-y-auto tw-p-4 tw-pb-8">
<div class="tw-overflow-y-auto tw-pb-8" [ngClass]="{ 'tw-p-4': !disablePadding }">
<ng-content select="[bitDialogContent]"></ng-content>
</div>

View File

@@ -1,3 +1,4 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, Input } from "@angular/core";
@Component({
@@ -7,6 +8,14 @@ import { Component, Input } from "@angular/core";
export class DialogComponent {
@Input() dialogSize: "small" | "default" | "large" = "default";
private _disablePadding: boolean;
@Input() set disablePadding(value: boolean) {
this._disablePadding = coerceBooleanProperty(value);
}
get disablePadding() {
return this._disablePadding;
}
get width() {
switch (this.dialogSize) {
case "small": {

View File

@@ -5,6 +5,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { ButtonModule } from "../../button";
import { IconButtonModule } from "../../icon-button";
import { SharedModule } from "../../shared";
import { TabsModule } from "../../tabs";
import { I18nMockService } from "../../utils/i18n-mock.service";
import { DialogCloseDirective } from "../directives/dialog-close.directive";
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
@@ -16,7 +17,7 @@ export default {
component: DialogComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule, SharedModule, IconButtonModule],
imports: [ButtonModule, SharedModule, IconButtonModule, TabsModule],
declarations: [DialogTitleContainerDirective, DialogCloseDirective],
providers: [
{
@@ -33,6 +34,13 @@ export default {
args: {
dialogSize: "small",
},
argTypes: {
_disablePadding: {
table: {
disable: true,
},
},
},
parameters: {
design: {
type: "figma",
@@ -44,7 +52,7 @@ export default {
const Template: Story<DialogComponent> = (args: DialogComponent) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize">
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogTitle>{{title}}</span>
<span bitDialogContent>Dialog body text goes here.</span>
<div bitDialogFooter class="tw-flex tw-items-center tw-flex-row tw-gap-2">
@@ -83,7 +91,7 @@ Large.args = {
const TemplateScrolling: Story<DialogComponent> = (args: DialogComponent) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize">
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogTitle>Scrolling Example</span>
<span bitDialogContent>
Dialog body text goes here.<br>
@@ -104,3 +112,37 @@ export const ScrollingContent = TemplateScrolling.bind({});
ScrollingContent.args = {
dialogSize: "small",
};
const TemplateTabbed: Story<DialogComponent> = (args: DialogComponent) => ({
props: args,
template: `
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogTitle>Tab Content Example</span>
<span bitDialogContent>
<bit-tab-group>
<bit-tab label="First Tab">First Tab Content</bit-tab>
<bit-tab label="Second Tab">Second Tab Content</bit-tab>
<bit-tab label="Third Tab">Third Tab Content</bit-tab>
</bit-tab-group>
</span>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button bitButton buttonType="primary">Save</button>
<button bitButton buttonType="secondary">Cancel</button>
</div>
</bit-dialog>
`,
});
export const TabContent = TemplateTabbed.bind({});
TabContent.args = {
dialogSize: "large",
disablePadding: true,
};
TabContent.story = {
parameters: {
docs: {
storyDescription: `An example of using the \`bitTabGroup\` component within the Dialog. The content padding should be
disabled (via \`disablePadding\`) so that the tabs are flush against the dialog title.`,
},
},
};

View File

@@ -72,7 +72,7 @@ const sizes: Record<IconButtonSize, string[]> = {
@Component({
selector: "button[bitIconButton]",
template: `<i class="bwi" [ngClass]="icon" aria-hidden="true"></i>`,
template: `<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>`,
})
export class BitIconButtonComponent {
@Input("bitIconButton") icon: string;
@@ -106,10 +106,15 @@ export class BitIconButtonComponent {
"before:tw-rounded-md",
"before:tw-transition",
"before:tw-ring",
"before:tw-ring-transparent",
"focus-visible:before:tw-ring-text-contrast",
"focus-visible:tw-z-10",
]
.concat(styles[this.buttonType])
.concat(sizes[this.size]);
}
get iconClass() {
return [this.icon, "!tw-m-0"];
}
}

View File

@@ -7,7 +7,6 @@ export * from "./icon";
export * from "./icon-button";
export * from "./menu";
export * from "./dialog";
export * from "./submit-button";
export * from "./link";
export * from "./tabs";
export * from "./toggle-group";

View File

@@ -1 +0,0 @@
export * from "./submit-button.module";

View File

@@ -1,16 +0,0 @@
<button
bitButton
type="submit"
[block]="block"
[buttonType]="buttonType"
[disabled]="loading || disabled"
>
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<ng-content></ng-content>
</span>
<span class="tw-absolute tw-inset-0" [ngClass]="{ 'tw-invisible': !loading }">
<i class="bwi bwi-spinner bwi-lg bwi-spin tw-align-baseline" aria-hidden="true"></i>
</span>
</span>
</button>

View File

@@ -1,19 +0,0 @@
import { Component, HostBinding, Input } from "@angular/core";
import { ButtonTypes } from "../button";
@Component({
selector: "bit-submit-button",
templateUrl: "./submit-button.component.html",
})
export class SubmitButtonComponent {
@Input() buttonType: ButtonTypes = "primary";
@Input() disabled = false;
@Input() loading: boolean;
@Input() block?: boolean;
@HostBinding("class") get classList() {
return this.block == null || this.block === false ? [] : ["tw-w-full", "tw-block"];
}
}

View File

@@ -1,13 +0,0 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { ButtonModule } from "../button";
import { SubmitButtonComponent } from "./submit-button.component";
@NgModule({
imports: [CommonModule, ButtonModule],
exports: [SubmitButtonComponent],
declarations: [SubmitButtonComponent],
})
export class SubmitButtonModule {}

View File

@@ -1,45 +0,0 @@
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { SubmitButtonComponent } from "./submit-button.component";
import { SubmitButtonModule } from "./submit-button.module";
export default {
title: "Component Library/Submit Button",
component: SubmitButtonComponent,
decorators: [
moduleMetadata({
imports: [SubmitButtonModule],
}),
],
args: {
buttonType: "primary",
loading: false,
block: false,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A16733",
},
},
} as Meta;
const Template: Story<SubmitButtonComponent> = (args: SubmitButtonComponent) => ({
props: args,
template: `<bit-submit-button [buttonType]="buttonType" [loading]="loading" [disabled]="disabled" [block]="block">
Submit
</bit-submit-button>`,
});
export const Primary = Template.bind({});
Primary.args = {};
export const Loading = Template.bind({});
Loading.args = {
loading: true,
};
export const Disabled = Template.bind({});
Disabled.args = {
disabled: true,
};