mirror of
https://github.com/bitwarden/browser
synced 2025-12-25 20:53:22 +00:00
Merge branch 'master' into feature/org-admin-refresh
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import { Directive, Input, NgZone, OnInit } from "@angular/core";
|
||||
import { Directive, NgZone, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { take } from "rxjs/operators";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
|
||||
import {
|
||||
AllValidationErrors,
|
||||
FormValidationErrorsService,
|
||||
} from "@bitwarden/common/abstractions/formValidationErrors.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
|
||||
@@ -18,16 +23,19 @@ import { CaptchaProtectedComponent } from "./captchaProtected.component";
|
||||
|
||||
@Directive()
|
||||
export class LoginComponent extends CaptchaProtectedComponent implements OnInit {
|
||||
@Input() email = "";
|
||||
@Input() rememberEmail = true;
|
||||
|
||||
masterPassword = "";
|
||||
showPassword = false;
|
||||
formPromise: Promise<AuthResult>;
|
||||
onSuccessfulLogin: () => Promise<any>;
|
||||
onSuccessfulLoginNavigate: () => Promise<any>;
|
||||
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
|
||||
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
|
||||
selfHosted = false;
|
||||
|
||||
formGroup = this.formBuilder.group({
|
||||
email: ["", [Validators.required, Validators.email]],
|
||||
masterPassword: ["", [Validators.required, Validators.minLength(8)]],
|
||||
rememberEmail: [true],
|
||||
});
|
||||
|
||||
protected twoFactorRoute = "2fa";
|
||||
protected successRoute = "vault";
|
||||
@@ -44,9 +52,12 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
|
||||
protected passwordGenerationService: PasswordGenerationService,
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
protected ngZone: NgZone
|
||||
protected ngZone: NgZone,
|
||||
protected formBuilder: FormBuilder,
|
||||
protected formValidationErrorService: FormValidationErrorsService
|
||||
) {
|
||||
super(environmentService, i18nService, platformUtilsService);
|
||||
this.selfHosted = platformUtilsService.isSelfHost();
|
||||
}
|
||||
|
||||
get selfHostedDomain() {
|
||||
@@ -54,59 +65,53 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
if (this.email == null || this.email === "") {
|
||||
this.email = await this.stateService.getRememberedEmail();
|
||||
if (this.email == null) {
|
||||
this.email = "";
|
||||
let email = this.formGroup.get("email")?.value;
|
||||
if (email == null || email === "") {
|
||||
email = await this.stateService.getRememberedEmail();
|
||||
this.formGroup.get("email")?.setValue(email);
|
||||
|
||||
if (email == null) {
|
||||
this.formGroup.get("email")?.setValue("");
|
||||
}
|
||||
}
|
||||
if (!this.alwaysRememberEmail) {
|
||||
this.rememberEmail = (await this.stateService.getRememberedEmail()) != null;
|
||||
}
|
||||
if (Utils.isBrowser && !Utils.isNode) {
|
||||
this.focusInput();
|
||||
const rememberEmail = (await this.stateService.getRememberedEmail()) != null;
|
||||
this.formGroup.get("rememberEmail")?.setValue(rememberEmail);
|
||||
}
|
||||
}
|
||||
|
||||
async submit() {
|
||||
async submit(showToast = true) {
|
||||
const email = this.formGroup.get("email")?.value;
|
||||
const masterPassword = this.formGroup.get("masterPassword")?.value;
|
||||
const rememberEmail = this.formGroup.get("rememberEmail")?.value;
|
||||
|
||||
await this.setupCaptcha();
|
||||
|
||||
if (this.email == null || this.email === "") {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
this.i18nService.t("emailRequired")
|
||||
);
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
//web
|
||||
if (this.formGroup.invalid && !showToast) {
|
||||
return;
|
||||
}
|
||||
if (this.email.indexOf("@") === -1) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
this.i18nService.t("invalidEmail")
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.masterPassword == null || this.masterPassword === "") {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
this.i18nService.t("masterPasswordRequired")
|
||||
);
|
||||
|
||||
//desktop, browser; This should be removed once all clients use reactive forms
|
||||
if (this.formGroup.invalid && showToast) {
|
||||
const errorText = this.getErrorToastMessage();
|
||||
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = new PasswordLogInCredentials(
|
||||
this.email,
|
||||
this.masterPassword,
|
||||
email,
|
||||
masterPassword,
|
||||
this.captchaToken,
|
||||
null
|
||||
);
|
||||
this.formPromise = this.authService.logIn(credentials);
|
||||
const response = await this.formPromise;
|
||||
if (this.rememberEmail || this.alwaysRememberEmail) {
|
||||
await this.stateService.setRememberedEmail(this.email);
|
||||
if (rememberEmail || this.alwaysRememberEmail) {
|
||||
await this.stateService.setRememberedEmail(email);
|
||||
} else {
|
||||
await this.stateService.setRememberedEmail(null);
|
||||
}
|
||||
@@ -188,9 +193,30 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
|
||||
);
|
||||
}
|
||||
|
||||
private getErrorToastMessage() {
|
||||
const error: AllValidationErrors = this.formValidationErrorService
|
||||
.getFormValidationErrors(this.formGroup.controls)
|
||||
.shift();
|
||||
|
||||
if (error) {
|
||||
switch (error.errorName) {
|
||||
case "email":
|
||||
return this.i18nService.t("invalidEmail");
|
||||
default:
|
||||
return this.i18nService.t(this.errorTag(error));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private errorTag(error: AllValidationErrors): string {
|
||||
const name = error.errorName.charAt(0).toUpperCase() + error.errorName.slice(1);
|
||||
return `${error.controlName}${name}`;
|
||||
}
|
||||
|
||||
protected focusInput() {
|
||||
document
|
||||
.getElementById(this.email == null || this.email === "" ? "email" : "masterPassword")
|
||||
.focus();
|
||||
const email = this.formGroup.get("email")?.value;
|
||||
document.getElementById(email == null || email === "" ? "email" : "masterPassword").focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AbstractThemingService } from "@bitwarden/angular/services/theming/them
|
||||
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
|
||||
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/abstractions/account/account-api.service.abstraction";
|
||||
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/abstractions/account/account.service.abstraction";
|
||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/abstractions/anonymousHub.service";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/abstractions/appId.service";
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
@@ -62,6 +63,7 @@ import { Account } from "@bitwarden/common/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
||||
import { AccountApiService } from "@bitwarden/common/services/account/account-api.service";
|
||||
import { AccountService } from "@bitwarden/common/services/account/account.service";
|
||||
import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service";
|
||||
import { ApiService } from "@bitwarden/common/services/api.service";
|
||||
import { AppIdService } from "@bitwarden/common/services/appId.service";
|
||||
import { AuditService } from "@bitwarden/common/services/audit.service";
|
||||
@@ -544,6 +546,11 @@ import { ValidationService } from "./validation.service";
|
||||
useClass: ConfigApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
},
|
||||
{
|
||||
provide: AnonymousHubServiceAbstraction,
|
||||
useClass: AnonymousHubService,
|
||||
deps: [EnvironmentServiceAbstraction, AuthServiceAbstraction, LogService],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class JslibServicesModule {}
|
||||
|
||||
@@ -70,4 +70,10 @@ describe("Utils Service", () => {
|
||||
expect(Utils.newGuid()).toMatch(validGuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromByteStringToArray", () => {
|
||||
it("should handle null", () => {
|
||||
expect(Utils.fromByteStringToArray(null)).toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||
import { Account } from "@bitwarden/common/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
||||
import { State } from "@bitwarden/common/models/domain/state";
|
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
|
||||
import { StateService } from "@bitwarden/common/services/state.service";
|
||||
import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service";
|
||||
|
||||
describe("Browser State Service backed by chrome.storage api", () => {
|
||||
let secureStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let diskStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let memoryStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let logService: SubstituteOf<LogService>;
|
||||
let stateMigrationService: SubstituteOf<StateMigrationService>;
|
||||
let stateFactory: SubstituteOf<StateFactory<GlobalState, Account>>;
|
||||
let useAccountCache: boolean;
|
||||
|
||||
let state: State<GlobalState, Account>;
|
||||
const userId = "userId";
|
||||
|
||||
let sut: StateService;
|
||||
|
||||
beforeEach(() => {
|
||||
secureStorageService = Substitute.for();
|
||||
diskStorageService = Substitute.for();
|
||||
memoryStorageService = Substitute.for();
|
||||
logService = Substitute.for();
|
||||
stateMigrationService = Substitute.for();
|
||||
stateFactory = Substitute.for();
|
||||
useAccountCache = true;
|
||||
|
||||
state = new State(new GlobalState());
|
||||
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state)));
|
||||
memoryStorageService.get("state").mimicks(stateGetter);
|
||||
memoryStorageService
|
||||
.save("state", Arg.any(), Arg.any())
|
||||
.mimicks((key: string, obj: any, options: StorageOptions) => {
|
||||
return new Promise(() => {
|
||||
state = obj;
|
||||
});
|
||||
});
|
||||
|
||||
sut = new StateService(
|
||||
diskStorageService,
|
||||
secureStorageService,
|
||||
memoryStorageService,
|
||||
logService,
|
||||
stateMigrationService,
|
||||
stateFactory,
|
||||
useAccountCache
|
||||
);
|
||||
});
|
||||
|
||||
describe("account state getters", () => {
|
||||
beforeEach(() => {
|
||||
state.accounts[userId] = createAccount(userId);
|
||||
state.activeUserId = userId;
|
||||
});
|
||||
|
||||
describe("getCryptoMasterKey", () => {
|
||||
it("should return the stored SymmetricCryptoKey", async () => {
|
||||
const key = new SymmetricCryptoKey(new Uint8Array(32).buffer);
|
||||
state.accounts[userId].keys.cryptoMasterKey = key;
|
||||
|
||||
const actual = await sut.getCryptoMasterKey();
|
||||
expect(actual).toBeInstanceOf(SymmetricCryptoKey);
|
||||
expect(actual).toMatchObject(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createAccount(userId: string): Account {
|
||||
return new Account({
|
||||
profile: { userId: userId },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -116,8 +116,8 @@ describe("State Migration Service", () => {
|
||||
key: "orgThreeEncKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5(
|
||||
|
||||
4
libs/common/src/abstractions/anonymousHub.service.ts
Normal file
4
libs/common/src/abstractions/anonymousHub.service.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export abstract class AnonymousHubService {
|
||||
createHubConnection: (token: string) => void;
|
||||
stopHubConnection: () => void;
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { OrganizationUserUpdateGroupsRequest } from "../models/request/organizat
|
||||
import { OrganizationUserUpdateRequest } from "../models/request/organizationUserUpdateRequest";
|
||||
import { PasswordHintRequest } from "../models/request/passwordHintRequest";
|
||||
import { PasswordRequest } from "../models/request/passwordRequest";
|
||||
import { PasswordlessCreateAuthRequest } from "../models/request/passwordlessCreateAuthRequest";
|
||||
import { PaymentRequest } from "../models/request/paymentRequest";
|
||||
import { PreloginRequest } from "../models/request/preloginRequest";
|
||||
import { ProviderAddOrganizationRequest } from "../models/request/provider/providerAddOrganizationRequest";
|
||||
@@ -84,6 +85,7 @@ import { VerifyEmailRequest } from "../models/request/verifyEmailRequest";
|
||||
import { ApiKeyResponse } from "../models/response/apiKeyResponse";
|
||||
import { AttachmentResponse } from "../models/response/attachmentResponse";
|
||||
import { AttachmentUploadDataResponse } from "../models/response/attachmentUploadDataResponse";
|
||||
import { AuthRequestResponse } from "../models/response/authRequestResponse";
|
||||
import { RegisterResponse } from "../models/response/authentication/registerResponse";
|
||||
import { BillingHistoryResponse } from "../models/response/billingHistoryResponse";
|
||||
import { BillingPaymentResponse } from "../models/response/billingPaymentResponse";
|
||||
@@ -210,6 +212,9 @@ export abstract class ApiService {
|
||||
postUserRotateApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
|
||||
putUpdateTempPassword: (request: UpdateTempPasswordRequest) => Promise<any>;
|
||||
postConvertToKeyConnector: () => Promise<void>;
|
||||
//passwordless
|
||||
postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
|
||||
|
||||
getUserBillingHistory: () => Promise<BillingHistoryResponse>;
|
||||
getUserBillingPayment: () => Promise<BillingPaymentResponse>;
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { AuthenticationStatus } from "../enums/authenticationStatus";
|
||||
import { AuthResult } from "../models/domain/authResult";
|
||||
import {
|
||||
ApiLogInCredentials,
|
||||
PasswordLogInCredentials,
|
||||
SsoLogInCredentials,
|
||||
PasswordlessLogInCredentials,
|
||||
} from "../models/domain/logInCredentials";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
|
||||
import { TokenRequestTwoFactor } from "../models/request/identityToken/tokenRequestTwoFactor";
|
||||
import { AuthRequestPushNotification } from "../models/response/notificationResponse";
|
||||
|
||||
export abstract class AuthService {
|
||||
masterPasswordHash: string;
|
||||
email: string;
|
||||
logIn: (
|
||||
credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials
|
||||
credentials:
|
||||
| ApiLogInCredentials
|
||||
| PasswordLogInCredentials
|
||||
| SsoLogInCredentials
|
||||
| PasswordlessLogInCredentials
|
||||
) => Promise<AuthResult>;
|
||||
logInTwoFactor: (
|
||||
twoFactor: TokenRequestTwoFactor,
|
||||
@@ -24,4 +32,7 @@ export abstract class AuthService {
|
||||
authingWithSso: () => boolean;
|
||||
authingWithPassword: () => boolean;
|
||||
getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
|
||||
authResponsePushNotifiction: (notification: AuthRequestPushNotification) => Promise<any>;
|
||||
|
||||
getPushNotifcationObs$: () => Observable<any>;
|
||||
}
|
||||
|
||||
@@ -78,8 +78,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
getCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<string>;
|
||||
hasCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<boolean>;
|
||||
setCryptoMasterKeyBiometric: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getDecodedToken: (options?: StorageOptions) => Promise<any>;
|
||||
setDecodedToken: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
|
||||
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCollections: (options?: StorageOptions) => Promise<CollectionView[]>;
|
||||
@@ -141,6 +139,8 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setDontShowCardsCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDontShowIdentitiesCurrentTab: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDontShowIdentitiesCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
|
||||
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEmail: (options?: StorageOptions) => Promise<string>;
|
||||
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
|
||||
@@ -160,6 +160,11 @@ export abstract class StateService<T extends Account = Account> {
|
||||
) => Promise<void>;
|
||||
getEnableCloseToTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableCloseToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableDuckDuckGoBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableDuckDuckGoBrowserIntegration: (
|
||||
value: boolean,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEnableFullWidth: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableFullWidth: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableGravitars: (options?: StorageOptions) => Promise<boolean>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StorageOptions } from "../models/domain/storageOptions";
|
||||
import { MemoryStorageOptions, StorageOptions } from "../models/domain/storageOptions";
|
||||
|
||||
export abstract class AbstractStorageService {
|
||||
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
|
||||
@@ -8,5 +8,9 @@ export abstract class AbstractStorageService {
|
||||
}
|
||||
|
||||
export abstract class AbstractCachedStorageService extends AbstractStorageService {
|
||||
abstract getBypassCache<T>(key: string, options?: StorageOptions): Promise<T>;
|
||||
abstract getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export interface MemoryStorageServiceInterface {
|
||||
get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T>;
|
||||
}
|
||||
|
||||
4
libs/common/src/enums/authRequestType.ts
Normal file
4
libs/common/src/enums/authRequestType.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum AuthRequestType {
|
||||
AuthenticateAndUnlock = 0,
|
||||
Unlock = 1,
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export enum AuthenticationType {
|
||||
Password = 0,
|
||||
Sso = 1,
|
||||
Api = 2,
|
||||
Passwordless = 3,
|
||||
}
|
||||
|
||||
4
libs/common/src/enums/nativeMessagingVersion.ts
Normal file
4
libs/common/src/enums/nativeMessagingVersion.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum NativeMessagingVersion {
|
||||
One = 1, // Original implementation
|
||||
Latest = One,
|
||||
}
|
||||
@@ -17,4 +17,7 @@ export enum NotificationType {
|
||||
SyncSendCreate = 12,
|
||||
SyncSendUpdate = 13,
|
||||
SyncSendDelete = 14,
|
||||
|
||||
AuthRequest = 15,
|
||||
AuthRequestResponse = 16,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ApiLogInCredentials,
|
||||
PasswordLogInCredentials,
|
||||
SsoLogInCredentials,
|
||||
PasswordlessLogInCredentials,
|
||||
} from "../../models/domain/logInCredentials";
|
||||
import { DeviceRequest } from "../../models/request/deviceRequest";
|
||||
import { ApiTokenRequest } from "../../models/request/identityToken/apiTokenRequest";
|
||||
@@ -42,7 +43,11 @@ export abstract class LogInStrategy {
|
||||
) {}
|
||||
|
||||
abstract logIn(
|
||||
credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials
|
||||
credentials:
|
||||
| ApiLogInCredentials
|
||||
| PasswordLogInCredentials
|
||||
| SsoLogInCredentials
|
||||
| PasswordlessLogInCredentials
|
||||
): Promise<AuthResult>;
|
||||
|
||||
async logInTwoFactor(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { AppIdService } from "../../abstractions/appId.service";
|
||||
import { AuthService } from "../../abstractions/auth.service";
|
||||
import { CryptoService } from "../../abstractions/crypto.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { MessagingService } from "../../abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../../abstractions/platformUtils.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { TokenService } from "../../abstractions/token.service";
|
||||
import { TwoFactorService } from "../../abstractions/twoFactor.service";
|
||||
import { AuthResult } from "../../models/domain/authResult";
|
||||
import { PasswordlessLogInCredentials } from "../../models/domain/logInCredentials";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey";
|
||||
import { PasswordTokenRequest } from "../../models/request/identityToken/passwordTokenRequest";
|
||||
import { TokenRequestTwoFactor } from "../../models/request/identityToken/tokenRequestTwoFactor";
|
||||
|
||||
import { LogInStrategy } from "./logIn.strategy";
|
||||
|
||||
export class PasswordlessLogInStrategy extends LogInStrategy {
|
||||
get email() {
|
||||
return this.tokenRequest.email;
|
||||
}
|
||||
|
||||
get masterPasswordHash() {
|
||||
return this.tokenRequest.masterPasswordHash;
|
||||
}
|
||||
|
||||
tokenRequest: PasswordTokenRequest;
|
||||
|
||||
private localHashedPassword: string;
|
||||
private key: SymmetricCryptoKey;
|
||||
|
||||
constructor(
|
||||
cryptoService: CryptoService,
|
||||
apiService: ApiService,
|
||||
tokenService: TokenService,
|
||||
appIdService: AppIdService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
messagingService: MessagingService,
|
||||
logService: LogService,
|
||||
stateService: StateService,
|
||||
twoFactorService: TwoFactorService,
|
||||
private authService: AuthService
|
||||
) {
|
||||
super(
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
appIdService,
|
||||
platformUtilsService,
|
||||
messagingService,
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService
|
||||
);
|
||||
}
|
||||
|
||||
async onSuccessfulLogin() {
|
||||
await this.cryptoService.setKey(this.key);
|
||||
await this.cryptoService.setKeyHash(this.localHashedPassword);
|
||||
}
|
||||
|
||||
async logInTwoFactor(
|
||||
twoFactor: TokenRequestTwoFactor,
|
||||
captchaResponse: string
|
||||
): Promise<AuthResult> {
|
||||
this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken;
|
||||
return super.logInTwoFactor(twoFactor);
|
||||
}
|
||||
|
||||
async logIn(credentials: PasswordlessLogInCredentials) {
|
||||
this.localHashedPassword = credentials.localPasswordHash;
|
||||
this.key = credentials.decKey;
|
||||
|
||||
this.tokenRequest = new PasswordTokenRequest(
|
||||
credentials.email,
|
||||
credentials.accessCode,
|
||||
null,
|
||||
await this.buildTwoFactor(credentials.twoFactor),
|
||||
await this.buildDeviceRequest()
|
||||
);
|
||||
|
||||
this.tokenRequest.setPasswordlessAccessCode(credentials.authRequestId);
|
||||
return this.startLogIn();
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,9 @@ export class Utils {
|
||||
}
|
||||
|
||||
static fromByteStringToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
const arr = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
arr[i] = str.charCodeAt(i);
|
||||
|
||||
55
libs/common/src/models/data/server-config.data.spec.ts
Normal file
55
libs/common/src/models/data/server-config.data.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
EnvironmentServerConfigData,
|
||||
ServerConfigData,
|
||||
ThirdPartyServerConfigData,
|
||||
} from "./server-config.data";
|
||||
|
||||
describe("ServerConfigData", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should create a ServerConfigData from a JSON object", () => {
|
||||
const serverConfigData = ServerConfigData.fromJSON({
|
||||
version: "1.0.0",
|
||||
gitHash: "1234567890",
|
||||
server: {
|
||||
name: "test",
|
||||
url: "https://test.com",
|
||||
},
|
||||
environment: {
|
||||
vault: "https://vault.com",
|
||||
api: "https://api.com",
|
||||
identity: "https://identity.com",
|
||||
notifications: "https://notifications.com",
|
||||
sso: "https://sso.com",
|
||||
},
|
||||
utcDate: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(serverConfigData.version).toEqual("1.0.0");
|
||||
expect(serverConfigData.gitHash).toEqual("1234567890");
|
||||
expect(serverConfigData.server.name).toEqual("test");
|
||||
expect(serverConfigData.server.url).toEqual("https://test.com");
|
||||
expect(serverConfigData.environment.vault).toEqual("https://vault.com");
|
||||
expect(serverConfigData.environment.api).toEqual("https://api.com");
|
||||
expect(serverConfigData.environment.identity).toEqual("https://identity.com");
|
||||
expect(serverConfigData.environment.notifications).toEqual("https://notifications.com");
|
||||
expect(serverConfigData.environment.sso).toEqual("https://sso.com");
|
||||
expect(serverConfigData.utcDate).toEqual("2020-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should be an instance of ServerConfigData", () => {
|
||||
const serverConfigData = ServerConfigData.fromJSON({} as any);
|
||||
|
||||
expect(serverConfigData).toBeInstanceOf(ServerConfigData);
|
||||
});
|
||||
|
||||
it("should deserialize sub objects", () => {
|
||||
const serverConfigData = ServerConfigData.fromJSON({
|
||||
server: {},
|
||||
environment: {},
|
||||
} as any);
|
||||
|
||||
expect(serverConfigData.server).toBeInstanceOf(ThirdPartyServerConfigData);
|
||||
expect(serverConfigData.environment).toBeInstanceOf(EnvironmentServerConfigData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
ServerConfigResponse,
|
||||
ThirdPartyServerConfigResponse,
|
||||
@@ -11,27 +13,38 @@ export class ServerConfigData {
|
||||
environment?: EnvironmentServerConfigData;
|
||||
utcDate: string;
|
||||
|
||||
constructor(serverConfigReponse: ServerConfigResponse) {
|
||||
this.version = serverConfigReponse?.version;
|
||||
this.gitHash = serverConfigReponse?.gitHash;
|
||||
this.server = serverConfigReponse?.server
|
||||
? new ThirdPartyServerConfigData(serverConfigReponse.server)
|
||||
constructor(serverConfigResponse: Partial<ServerConfigResponse>) {
|
||||
this.version = serverConfigResponse?.version;
|
||||
this.gitHash = serverConfigResponse?.gitHash;
|
||||
this.server = serverConfigResponse?.server
|
||||
? new ThirdPartyServerConfigData(serverConfigResponse.server)
|
||||
: null;
|
||||
this.utcDate = new Date().toISOString();
|
||||
this.environment = serverConfigReponse?.environment
|
||||
? new EnvironmentServerConfigData(serverConfigReponse.environment)
|
||||
this.environment = serverConfigResponse?.environment
|
||||
? new EnvironmentServerConfigData(serverConfigResponse.environment)
|
||||
: null;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<ServerConfigData>): ServerConfigData {
|
||||
return Object.assign(new ServerConfigData({}), obj, {
|
||||
server: obj?.server ? ThirdPartyServerConfigData.fromJSON(obj.server) : null,
|
||||
environment: obj?.environment ? EnvironmentServerConfigData.fromJSON(obj.environment) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ThirdPartyServerConfigData {
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
constructor(response: ThirdPartyServerConfigResponse) {
|
||||
constructor(response: Partial<ThirdPartyServerConfigResponse>) {
|
||||
this.name = response.name;
|
||||
this.url = response.url;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<ThirdPartyServerConfigData>): ThirdPartyServerConfigData {
|
||||
return Object.assign(new ThirdPartyServerConfigData({}), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export class EnvironmentServerConfigData {
|
||||
@@ -41,11 +54,15 @@ export class EnvironmentServerConfigData {
|
||||
notifications: string;
|
||||
sso: string;
|
||||
|
||||
constructor(response: EnvironmentServerConfigResponse) {
|
||||
constructor(response: Partial<EnvironmentServerConfigResponse>) {
|
||||
this.vault = response.vault;
|
||||
this.api = response.api;
|
||||
this.identity = response.identity;
|
||||
this.notifications = response.notifications;
|
||||
this.sso = response.sso;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<EnvironmentServerConfigData>): EnvironmentServerConfigData {
|
||||
return Object.assign(new EnvironmentServerConfigData({}), obj);
|
||||
}
|
||||
}
|
||||
|
||||
62
libs/common/src/models/domain/account-keys.spec.ts
Normal file
62
libs/common/src/models/domain/account-keys.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
|
||||
import { makeStaticByteArray } from "../../../spec/utils";
|
||||
|
||||
import { AccountKeys, EncryptionPair } from "./account";
|
||||
import { SymmetricCryptoKey } from "./symmetricCryptoKey";
|
||||
|
||||
describe("AccountKeys", () => {
|
||||
describe("toJSON", () => {
|
||||
it("should serialize itself", () => {
|
||||
const keys = new AccountKeys();
|
||||
const buffer = makeStaticByteArray(64).buffer;
|
||||
keys.publicKey = buffer;
|
||||
|
||||
const bufferSpy = jest.spyOn(Utils, "fromBufferToByteString");
|
||||
keys.toJSON();
|
||||
expect(bufferSpy).toHaveBeenCalledWith(buffer);
|
||||
});
|
||||
|
||||
it("should serialize public key as a string", () => {
|
||||
const keys = new AccountKeys();
|
||||
keys.publicKey = Utils.fromByteStringToArray("hello").buffer;
|
||||
const json = JSON.stringify(keys);
|
||||
expect(json).toContain('"publicKey":"hello"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize public key to a buffer", () => {
|
||||
const keys = AccountKeys.fromJSON({
|
||||
publicKey: "hello",
|
||||
});
|
||||
expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello").buffer);
|
||||
});
|
||||
|
||||
it("should deserialize cryptoMasterKey", () => {
|
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
AccountKeys.fromJSON({} as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize organizationKeys", () => {
|
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
AccountKeys.fromJSON({ organizationKeys: [{ orgId: "keyJSON" }] } as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize providerKeys", () => {
|
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
AccountKeys.fromJSON({ providerKeys: [{ providerId: "keyJSON" }] } as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize privateKey", () => {
|
||||
const spy = jest.spyOn(EncryptionPair, "fromJSON");
|
||||
AccountKeys.fromJSON({
|
||||
privateKey: { encrypted: "encrypted", decrypted: "decrypted" },
|
||||
} as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
9
libs/common/src/models/domain/account-profile.spec.ts
Normal file
9
libs/common/src/models/domain/account-profile.spec.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AccountProfile } from "./account";
|
||||
|
||||
describe("AccountProfile", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(AccountProfile.fromJSON({})).toBeInstanceOf(AccountProfile);
|
||||
});
|
||||
});
|
||||
});
|
||||
24
libs/common/src/models/domain/account-settings.spec.ts
Normal file
24
libs/common/src/models/domain/account-settings.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AccountSettings, EncryptionPair } from "./account";
|
||||
import { EncString } from "./encString";
|
||||
|
||||
describe("AccountSettings", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(AccountSettings.fromJSON(JSON.parse("{}"))).toBeInstanceOf(AccountSettings);
|
||||
});
|
||||
|
||||
it("should deserialize pinProtected", () => {
|
||||
const accountSettings = new AccountSettings();
|
||||
accountSettings.pinProtected = EncryptionPair.fromJSON<string, EncString>({
|
||||
encrypted: "encrypted",
|
||||
decrypted: "3.data",
|
||||
});
|
||||
const jsonObj = JSON.parse(JSON.stringify(accountSettings));
|
||||
const actual = AccountSettings.fromJSON(jsonObj);
|
||||
|
||||
expect(actual.pinProtected).toBeInstanceOf(EncryptionPair);
|
||||
expect(actual.pinProtected.encrypted).toEqual("encrypted");
|
||||
expect(actual.pinProtected.decrypted.encryptedString).toEqual("3.data");
|
||||
});
|
||||
});
|
||||
});
|
||||
9
libs/common/src/models/domain/account-tokens.spec.ts
Normal file
9
libs/common/src/models/domain/account-tokens.spec.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AccountTokens } from "./account";
|
||||
|
||||
describe("AccountTokens", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(AccountTokens.fromJSON({})).toBeInstanceOf(AccountTokens);
|
||||
});
|
||||
});
|
||||
});
|
||||
23
libs/common/src/models/domain/account.spec.ts
Normal file
23
libs/common/src/models/domain/account.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Account, AccountKeys, AccountProfile, AccountSettings, AccountTokens } from "./account";
|
||||
|
||||
describe("Account", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(Account.fromJSON({})).toBeInstanceOf(Account);
|
||||
});
|
||||
|
||||
it("should call all the sub-fromJSONs", () => {
|
||||
const keysSpy = jest.spyOn(AccountKeys, "fromJSON");
|
||||
const profileSpy = jest.spyOn(AccountProfile, "fromJSON");
|
||||
const settingsSpy = jest.spyOn(AccountSettings, "fromJSON");
|
||||
const tokensSpy = jest.spyOn(AccountTokens, "fromJSON");
|
||||
|
||||
Account.fromJSON({});
|
||||
|
||||
expect(keysSpy).toHaveBeenCalled();
|
||||
expect(profileSpy).toHaveBeenCalled();
|
||||
expect(settingsSpy).toHaveBeenCalled();
|
||||
expect(tokensSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
import { Except, Jsonify } from "type-fest";
|
||||
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify";
|
||||
|
||||
import { AuthenticationStatus } from "../../enums/authenticationStatus";
|
||||
import { KdfType } from "../../enums/kdfType";
|
||||
import { UriMatchType } from "../../enums/uriMatchType";
|
||||
@@ -24,7 +29,39 @@ import { SymmetricCryptoKey } from "./symmetricCryptoKey";
|
||||
export class EncryptionPair<TEncrypted, TDecrypted> {
|
||||
encrypted?: TEncrypted;
|
||||
decrypted?: TDecrypted;
|
||||
decryptedSerialized?: string;
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
encrypted: this.encrypted,
|
||||
decrypted:
|
||||
this.decrypted instanceof ArrayBuffer
|
||||
? Utils.fromBufferToByteString(this.decrypted)
|
||||
: this.decrypted,
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON<TEncrypted, TDecrypted>(
|
||||
obj: Jsonify<EncryptionPair<Jsonify<TEncrypted>, Jsonify<TDecrypted>>>,
|
||||
decryptedFromJson?: (decObj: Jsonify<TDecrypted> | string) => TDecrypted,
|
||||
encryptedFromJson?: (encObj: Jsonify<TEncrypted>) => TEncrypted
|
||||
) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pair = new EncryptionPair<TEncrypted, TDecrypted>();
|
||||
if (obj?.encrypted != null) {
|
||||
pair.encrypted = encryptedFromJson
|
||||
? encryptedFromJson(obj.encrypted)
|
||||
: (obj.encrypted as TEncrypted);
|
||||
}
|
||||
if (obj?.decrypted != null) {
|
||||
pair.decrypted = decryptedFromJson
|
||||
? decryptedFromJson(obj.decrypted)
|
||||
: (obj.decrypted as TDecrypted);
|
||||
}
|
||||
return pair;
|
||||
}
|
||||
}
|
||||
|
||||
export class DataEncryptionPair<TEncrypted, TDecrypted> {
|
||||
@@ -73,19 +110,66 @@ export class AccountKeys {
|
||||
>();
|
||||
organizationKeys?: EncryptionPair<
|
||||
{ [orgId: string]: EncryptedOrganizationKeyData },
|
||||
Map<string, SymmetricCryptoKey>
|
||||
Record<string, SymmetricCryptoKey>
|
||||
> = new EncryptionPair<
|
||||
{ [orgId: string]: EncryptedOrganizationKeyData },
|
||||
Map<string, SymmetricCryptoKey>
|
||||
Record<string, SymmetricCryptoKey>
|
||||
>();
|
||||
providerKeys?: EncryptionPair<any, Map<string, SymmetricCryptoKey>> = new EncryptionPair<
|
||||
providerKeys?: EncryptionPair<any, Record<string, SymmetricCryptoKey>> = new EncryptionPair<
|
||||
any,
|
||||
Map<string, SymmetricCryptoKey>
|
||||
Record<string, SymmetricCryptoKey>
|
||||
>();
|
||||
privateKey?: EncryptionPair<string, ArrayBuffer> = new EncryptionPair<string, ArrayBuffer>();
|
||||
publicKey?: ArrayBuffer;
|
||||
publicKeySerialized?: string;
|
||||
apiKeyClientSecret?: string;
|
||||
|
||||
toJSON() {
|
||||
return Object.assign(this as Except<AccountKeys, "publicKey">, {
|
||||
publicKey: Utils.fromBufferToByteString(this.publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
static fromJSON(obj: DeepJsonify<AccountKeys>): AccountKeys {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
new AccountKeys(),
|
||||
{ cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey) },
|
||||
{
|
||||
cryptoSymmetricKey: EncryptionPair.fromJSON(
|
||||
obj?.cryptoSymmetricKey,
|
||||
SymmetricCryptoKey.fromJSON
|
||||
),
|
||||
},
|
||||
{ organizationKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.organizationKeys) },
|
||||
{ providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys) },
|
||||
{
|
||||
privateKey: EncryptionPair.fromJSON<string, ArrayBuffer>(
|
||||
obj?.privateKey,
|
||||
(decObj: string) => Utils.fromByteStringToArray(decObj).buffer
|
||||
),
|
||||
},
|
||||
{
|
||||
publicKey: Utils.fromByteStringToArray(obj?.publicKey)?.buffer,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static initRecordEncryptionPairsFromJSON(obj: any) {
|
||||
return EncryptionPair.fromJSON(obj, (decObj: any) => {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record: Record<string, SymmetricCryptoKey> = {};
|
||||
for (const id in decObj) {
|
||||
record[id] = SymmetricCryptoKey.fromJSON(decObj[id]);
|
||||
}
|
||||
return record;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountProfile {
|
||||
@@ -106,6 +190,14 @@ export class AccountProfile {
|
||||
keyHash?: string;
|
||||
kdfIterations?: number;
|
||||
kdfType?: KdfType;
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountProfile>): AccountProfile {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new AccountProfile(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountSettings {
|
||||
@@ -142,6 +234,21 @@ export class AccountSettings {
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string = "lock";
|
||||
serverConfig?: ServerConfigData;
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new AccountSettings(), obj, {
|
||||
environmentUrls: EnvironmentUrls.fromJSON(obj?.environmentUrls),
|
||||
pinProtected: EncryptionPair.fromJSON<string, EncString>(
|
||||
obj?.pinProtected,
|
||||
EncString.fromJSON
|
||||
),
|
||||
serverConfig: ServerConfigData.fromJSON(obj?.serverConfig),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountSettingsSettings = {
|
||||
@@ -150,9 +257,16 @@ export type AccountSettingsSettings = {
|
||||
|
||||
export class AccountTokens {
|
||||
accessToken?: string;
|
||||
decodedToken?: any;
|
||||
refreshToken?: string;
|
||||
securityStamp?: string;
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountTokens>): AccountTokens {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new AccountTokens(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export class Account {
|
||||
@@ -186,4 +300,17 @@ export class Account {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<Account>): Account {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new Account({}), json, {
|
||||
keys: AccountKeys.fromJSON(json?.keys),
|
||||
profile: AccountProfile.fromJSON(json?.profile),
|
||||
settings: AccountSettings.fromJSON(json?.settings),
|
||||
tokens: AccountTokens.fromJSON(json?.tokens),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
34
libs/common/src/models/domain/encryption-pair.spec.ts
Normal file
34
libs/common/src/models/domain/encryption-pair.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
|
||||
import { EncryptionPair } from "./account";
|
||||
|
||||
describe("EncryptionPair", () => {
|
||||
describe("toJSON", () => {
|
||||
it("should populate decryptedSerialized for buffer arrays", () => {
|
||||
const pair = new EncryptionPair<string, ArrayBuffer>();
|
||||
pair.decrypted = Utils.fromByteStringToArray("hello").buffer;
|
||||
const json = pair.toJSON();
|
||||
expect(json.decrypted).toEqual("hello");
|
||||
});
|
||||
|
||||
it("should serialize encrypted and decrypted", () => {
|
||||
const pair = new EncryptionPair<string, string>();
|
||||
pair.encrypted = "hello";
|
||||
pair.decrypted = "world";
|
||||
const json = pair.toJSON();
|
||||
expect(json.encrypted).toEqual("hello");
|
||||
expect(json.decrypted).toEqual("world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize encrypted and decrypted", () => {
|
||||
const pair = EncryptionPair.fromJSON({
|
||||
encrypted: "hello",
|
||||
decrypted: "world",
|
||||
});
|
||||
expect(pair.encrypted).toEqual("hello");
|
||||
expect(pair.decrypted).toEqual("world");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
export class EnvironmentUrls {
|
||||
base: string = null;
|
||||
api: string = null;
|
||||
@@ -7,4 +9,8 @@ export class EnvironmentUrls {
|
||||
events: string = null;
|
||||
webVault: string = null;
|
||||
keyConnector: string = null;
|
||||
|
||||
static fromJSON(obj: Jsonify<EnvironmentUrls>): EnvironmentUrls {
|
||||
return Object.assign(new EnvironmentUrls(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,4 +37,5 @@ export class GlobalState {
|
||||
alwaysShowDock?: boolean;
|
||||
enableBrowserIntegration?: boolean;
|
||||
enableBrowserIntegrationFingerprint?: boolean;
|
||||
enableDuckDuckGoBrowserIntegration?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
|
||||
|
||||
import { AuthenticationType } from "../../enums/authenticationType";
|
||||
import { TokenRequestTwoFactor } from "../request/identityToken/tokenRequestTwoFactor";
|
||||
|
||||
@@ -29,3 +31,16 @@ export class ApiLogInCredentials {
|
||||
|
||||
constructor(public clientId: string, public clientSecret: string) {}
|
||||
}
|
||||
|
||||
export class PasswordlessLogInCredentials {
|
||||
readonly type = AuthenticationType.Passwordless;
|
||||
|
||||
constructor(
|
||||
public email: string,
|
||||
public accessCode: string,
|
||||
public authRequestId: string,
|
||||
public decKey: SymmetricCryptoKey,
|
||||
public localPasswordHash: string,
|
||||
public twoFactor?: TokenRequestTwoFactor
|
||||
) {}
|
||||
}
|
||||
|
||||
28
libs/common/src/models/domain/state.spec.ts
Normal file
28
libs/common/src/models/domain/state.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Account } from "./account";
|
||||
import { State } from "./state";
|
||||
|
||||
describe("state", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(State.fromJSON({})).toBeInstanceOf(State);
|
||||
});
|
||||
|
||||
it("should always assign an object to accounts", () => {
|
||||
const state = State.fromJSON({});
|
||||
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: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.accounts["userId"]).toBeInstanceOf(Account);
|
||||
expect(accountsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Account } from "./account";
|
||||
import { GlobalState } from "./globalState";
|
||||
|
||||
@@ -14,4 +16,30 @@ export class State<
|
||||
constructor(globals: TGlobalState) {
|
||||
this.globals = globals;
|
||||
}
|
||||
|
||||
// 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
|
||||
): State<TGlobalState, TAccount> {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new State(null), obj, {
|
||||
accounts: State.buildAccountMapFromJSON(obj?.accounts),
|
||||
});
|
||||
}
|
||||
|
||||
private static buildAccountMapFromJSON(
|
||||
jsonAccounts: Jsonify<{ [userId: string]: Jsonify<Account> }>
|
||||
) {
|
||||
if (!jsonAccounts) {
|
||||
return {};
|
||||
}
|
||||
const accounts: { [userId: string]: Account } = {};
|
||||
for (const userId in jsonAccounts) {
|
||||
accounts[userId] = Account.fromJSON(jsonAccounts[userId]);
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { HtmlStorageLocation } from "../../enums/htmlStorageLocation";
|
||||
import { StorageLocation } from "../../enums/storageLocation";
|
||||
|
||||
@@ -8,3 +10,5 @@ export type StorageOptions = {
|
||||
htmlStorageLocation?: HtmlStorageLocation;
|
||||
keySuffix?: string;
|
||||
};
|
||||
|
||||
export type MemoryStorageOptions<T> = StorageOptions & { deserializer?: (obj: Jsonify<T>) => T };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TokenRequestTwoFactor } from "./tokenRequestTwoFactor";
|
||||
|
||||
export abstract class TokenRequest {
|
||||
protected device?: DeviceRequest;
|
||||
protected passwordlessAuthRequest: string;
|
||||
|
||||
constructor(protected twoFactor: TokenRequestTwoFactor, device?: DeviceRequest) {
|
||||
this.device = device != null ? device : null;
|
||||
@@ -18,6 +19,10 @@ export abstract class TokenRequest {
|
||||
this.twoFactor = twoFactor;
|
||||
}
|
||||
|
||||
setPasswordlessAccessCode(accessCode: string) {
|
||||
this.passwordlessAuthRequest = accessCode;
|
||||
}
|
||||
|
||||
protected toIdentityToken(clientId: string) {
|
||||
const obj: any = {
|
||||
scope: "api offline_access",
|
||||
@@ -32,6 +37,11 @@ export abstract class TokenRequest {
|
||||
// obj.devicePushToken = this.device.pushToken;
|
||||
}
|
||||
|
||||
//passswordless login
|
||||
if (this.passwordlessAuthRequest) {
|
||||
obj.authRequest = this.passwordlessAuthRequest;
|
||||
}
|
||||
|
||||
if (this.twoFactor.token && this.twoFactor.provider != null) {
|
||||
obj.twoFactorToken = this.twoFactor.token;
|
||||
obj.twoFactorProvider = this.twoFactor.provider;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AuthRequestType } from "../../enums/authRequestType";
|
||||
|
||||
export class PasswordlessCreateAuthRequest {
|
||||
constructor(
|
||||
readonly email: string,
|
||||
readonly deviceIdentifier: string,
|
||||
readonly publicKey: string,
|
||||
readonly type: AuthRequestType,
|
||||
readonly accessCode: string,
|
||||
readonly fingerprintPhrase: string
|
||||
) {}
|
||||
}
|
||||
26
libs/common/src/models/response/authRequestResponse.ts
Normal file
26
libs/common/src/models/response/authRequestResponse.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DeviceType } from "@bitwarden/common/enums/deviceType";
|
||||
|
||||
import { BaseResponse } from "./baseResponse";
|
||||
|
||||
export class AuthRequestResponse extends BaseResponse {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
requestDeviceType: DeviceType;
|
||||
requestIpAddress: string;
|
||||
key: string;
|
||||
masterPasswordHash: string;
|
||||
creationDate: string;
|
||||
requestApproved: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.publicKey = this.getResponseProperty("PublicKey");
|
||||
this.requestDeviceType = this.getResponseProperty("RequestDeviceType");
|
||||
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
|
||||
this.key = this.getResponseProperty("Key");
|
||||
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.requestApproved = this.getResponseProperty("RequestApproved");
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ export class NotificationResponse extends BaseResponse {
|
||||
case NotificationType.SyncSendDelete:
|
||||
this.payload = new SyncSendNotification(payload);
|
||||
break;
|
||||
case NotificationType.AuthRequest:
|
||||
case NotificationType.AuthRequestResponse:
|
||||
this.payload = new AuthRequestPushNotification(payload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -96,3 +100,14 @@ export class SyncSendNotification extends BaseResponse {
|
||||
this.revisionDate = new Date(this.getResponseProperty("RevisionDate"));
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthRequestPushNotification extends BaseResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.userId = this.getResponseProperty("UserId");
|
||||
}
|
||||
}
|
||||
|
||||
60
libs/common/src/services/anonymousHub.service.ts
Normal file
60
libs/common/src/services/anonymousHub.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
HttpTransportType,
|
||||
HubConnection,
|
||||
HubConnectionBuilder,
|
||||
IHubProtocol,
|
||||
} from "@microsoft/signalr";
|
||||
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
|
||||
|
||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymousHub.service";
|
||||
import { AuthService } from "../abstractions/auth.service";
|
||||
import { EnvironmentService } from "../abstractions/environment.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
|
||||
import {
|
||||
AuthRequestPushNotification,
|
||||
NotificationResponse,
|
||||
} from "./../models/response/notificationResponse";
|
||||
|
||||
@Injectable()
|
||||
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
private anonHubConnection: HubConnection;
|
||||
private url: string;
|
||||
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private authService: AuthService,
|
||||
private logService: LogService
|
||||
) {}
|
||||
|
||||
async createHubConnection(token: string) {
|
||||
this.url = this.environmentService.getNotificationsUrl();
|
||||
|
||||
this.anonHubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.url + "/anonymousHub?Token=" + token, {
|
||||
skipNegotiation: true,
|
||||
transport: HttpTransportType.WebSockets,
|
||||
})
|
||||
.withHubProtocol(new MessagePackHubProtocol() as IHubProtocol)
|
||||
.build();
|
||||
|
||||
this.anonHubConnection.start().catch((error) => this.logService.error(error));
|
||||
|
||||
this.anonHubConnection.on("AuthRequestResponseRecieved", (data: any) => {
|
||||
this.ProcessNotification(new NotificationResponse(data));
|
||||
});
|
||||
}
|
||||
|
||||
stopHubConnection() {
|
||||
if (this.anonHubConnection) {
|
||||
this.anonHubConnection.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async ProcessNotification(notification: NotificationResponse) {
|
||||
await this.authService.authResponsePushNotifiction(
|
||||
notification.payload as AuthRequestPushNotification
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { OrganizationUserUpdateGroupsRequest } from "../models/request/organizat
|
||||
import { OrganizationUserUpdateRequest } from "../models/request/organizationUserUpdateRequest";
|
||||
import { PasswordHintRequest } from "../models/request/passwordHintRequest";
|
||||
import { PasswordRequest } from "../models/request/passwordRequest";
|
||||
import { PasswordlessCreateAuthRequest } from "../models/request/passwordlessCreateAuthRequest";
|
||||
import { PaymentRequest } from "../models/request/paymentRequest";
|
||||
import { PreloginRequest } from "../models/request/preloginRequest";
|
||||
import { ProviderAddOrganizationRequest } from "../models/request/provider/providerAddOrganizationRequest";
|
||||
@@ -92,6 +93,7 @@ import { VerifyEmailRequest } from "../models/request/verifyEmailRequest";
|
||||
import { ApiKeyResponse } from "../models/response/apiKeyResponse";
|
||||
import { AttachmentResponse } from "../models/response/attachmentResponse";
|
||||
import { AttachmentUploadDataResponse } from "../models/response/attachmentUploadDataResponse";
|
||||
import { AuthRequestResponse } from "../models/response/authRequestResponse";
|
||||
import { RegisterResponse } from "../models/response/authentication/registerResponse";
|
||||
import { BillingHistoryResponse } from "../models/response/billingHistoryResponse";
|
||||
import { BillingPaymentResponse } from "../models/response/billingPaymentResponse";
|
||||
@@ -265,6 +267,17 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async postAuthRequest(request: PasswordlessCreateAuthRequest): Promise<AuthRequestResponse> {
|
||||
const r = await this.send("POST", "/auth-requests/", request, false, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async getAuthResponse(id: string, accessCode: string): Promise<AuthRequestResponse> {
|
||||
const path = `/auth-requests/${id}/response?code=${accessCode}`;
|
||||
const r = await this.send("GET", path, null, false, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
// Account APIs
|
||||
|
||||
async getProfile(): Promise<ProfileResponse> {
|
||||
@@ -2323,7 +2336,9 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
requestInit.headers = headers;
|
||||
const response = await this.fetch(new Request(requestUrl, requestInit));
|
||||
|
||||
if (hasResponse && response.status === 200) {
|
||||
const responseType = response.headers.get("content-type");
|
||||
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
|
||||
if (hasResponse && response.status === 200 && responseIsJson) {
|
||||
const responseJson = await response.json();
|
||||
return responseJson;
|
||||
} else if (response.status !== 200) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Observable, Subject } from "rxjs";
|
||||
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
import { AppIdService } from "../abstractions/appId.service";
|
||||
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
|
||||
@@ -17,17 +19,20 @@ import { KdfType } from "../enums/kdfType";
|
||||
import { KeySuffixOptions } from "../enums/keySuffixOptions";
|
||||
import { ApiLogInStrategy } from "../misc/logInStrategies/apiLogin.strategy";
|
||||
import { PasswordLogInStrategy } from "../misc/logInStrategies/passwordLogin.strategy";
|
||||
import { PasswordlessLogInStrategy } from "../misc/logInStrategies/passwordlessLogin.strategy";
|
||||
import { SsoLogInStrategy } from "../misc/logInStrategies/ssoLogin.strategy";
|
||||
import { AuthResult } from "../models/domain/authResult";
|
||||
import {
|
||||
ApiLogInCredentials,
|
||||
PasswordLogInCredentials,
|
||||
SsoLogInCredentials,
|
||||
PasswordlessLogInCredentials,
|
||||
} from "../models/domain/logInCredentials";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
|
||||
import { TokenRequestTwoFactor } from "../models/request/identityToken/tokenRequestTwoFactor";
|
||||
import { PreloginRequest } from "../models/request/preloginRequest";
|
||||
import { ErrorResponse } from "../models/response/errorResponse";
|
||||
import { AuthRequestPushNotification } from "../models/response/notificationResponse";
|
||||
|
||||
const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
@@ -42,9 +47,15 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
: null;
|
||||
}
|
||||
|
||||
private logInStrategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy;
|
||||
private logInStrategy:
|
||||
| ApiLogInStrategy
|
||||
| PasswordLogInStrategy
|
||||
| SsoLogInStrategy
|
||||
| PasswordlessLogInStrategy;
|
||||
private sessionTimeout: any;
|
||||
|
||||
private pushNotificationSubject = new Subject<string>();
|
||||
|
||||
constructor(
|
||||
protected cryptoService: CryptoService,
|
||||
protected apiService: ApiService,
|
||||
@@ -61,52 +72,78 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
) {}
|
||||
|
||||
async logIn(
|
||||
credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials
|
||||
credentials:
|
||||
| ApiLogInCredentials
|
||||
| PasswordLogInCredentials
|
||||
| SsoLogInCredentials
|
||||
| PasswordlessLogInCredentials
|
||||
): Promise<AuthResult> {
|
||||
this.clearState();
|
||||
|
||||
let strategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy;
|
||||
let strategy:
|
||||
| ApiLogInStrategy
|
||||
| PasswordLogInStrategy
|
||||
| SsoLogInStrategy
|
||||
| PasswordlessLogInStrategy;
|
||||
|
||||
if (credentials.type === AuthenticationType.Password) {
|
||||
strategy = new PasswordLogInStrategy(
|
||||
this.cryptoService,
|
||||
this.apiService,
|
||||
this.tokenService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this
|
||||
);
|
||||
} else if (credentials.type === AuthenticationType.Sso) {
|
||||
strategy = new SsoLogInStrategy(
|
||||
this.cryptoService,
|
||||
this.apiService,
|
||||
this.tokenService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.keyConnectorService
|
||||
);
|
||||
} else if (credentials.type === AuthenticationType.Api) {
|
||||
strategy = new ApiLogInStrategy(
|
||||
this.cryptoService,
|
||||
this.apiService,
|
||||
this.tokenService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.environmentService,
|
||||
this.keyConnectorService
|
||||
);
|
||||
switch (credentials.type) {
|
||||
case AuthenticationType.Password:
|
||||
strategy = new PasswordLogInStrategy(
|
||||
this.cryptoService,
|
||||
this.apiService,
|
||||
this.tokenService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this
|
||||
);
|
||||
break;
|
||||
case AuthenticationType.Sso:
|
||||
strategy = new SsoLogInStrategy(
|
||||
this.cryptoService,
|
||||
this.apiService,
|
||||
this.tokenService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.keyConnectorService
|
||||
);
|
||||
break;
|
||||
case AuthenticationType.Api:
|
||||
strategy = new ApiLogInStrategy(
|
||||
this.cryptoService,
|
||||
this.apiService,
|
||||
this.tokenService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.environmentService,
|
||||
this.keyConnectorService
|
||||
);
|
||||
break;
|
||||
case AuthenticationType.Passwordless:
|
||||
strategy = new PasswordlessLogInStrategy(
|
||||
this.cryptoService,
|
||||
this.apiService,
|
||||
this.tokenService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await strategy.logIn(credentials as any);
|
||||
@@ -202,7 +239,21 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
return this.cryptoService.makeKey(masterPassword, email, kdf, kdfIterations);
|
||||
}
|
||||
|
||||
private saveState(strategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy) {
|
||||
async authResponsePushNotifiction(notification: AuthRequestPushNotification): Promise<any> {
|
||||
this.pushNotificationSubject.next(notification.id);
|
||||
}
|
||||
|
||||
getPushNotifcationObs$(): Observable<any> {
|
||||
return this.pushNotificationSubject.asObservable();
|
||||
}
|
||||
|
||||
private saveState(
|
||||
strategy:
|
||||
| ApiLogInStrategy
|
||||
| PasswordLogInStrategy
|
||||
| SsoLogInStrategy
|
||||
| PasswordlessLogInStrategy
|
||||
) {
|
||||
this.logInStrategy = strategy;
|
||||
this.startSessionTimeout();
|
||||
}
|
||||
|
||||
@@ -393,7 +393,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);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export class EncryptService implements AbstractEncryptService {
|
||||
}
|
||||
}
|
||||
|
||||
return this.cryptoFunctionService.aesDecryptFast(fastParams);
|
||||
return await this.cryptoFunctionService.aesDecryptFast(fastParams);
|
||||
}
|
||||
|
||||
async decryptToBytes(encThing: IEncrypted, key: SymmetricCryptoKey): Promise<ArrayBuffer> {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
MemoryStorageServiceInterface,
|
||||
} from "@bitwarden/common/abstractions/storage.service";
|
||||
|
||||
export class MemoryStorageService implements AbstractStorageService {
|
||||
export class MemoryStorageService
|
||||
extends AbstractStorageService
|
||||
implements MemoryStorageServiceInterface
|
||||
{
|
||||
private store = new Map<string, any>();
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
|
||||
@@ -3,14 +3,16 @@ import { BehaviorSubject, concatMap } from "rxjs";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
|
||||
import { StateMigrationService } from "../abstractions/stateMigration.service";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
import {
|
||||
MemoryStorageServiceInterface,
|
||||
AbstractStorageService,
|
||||
} from "../abstractions/storage.service";
|
||||
import { HtmlStorageLocation } from "../enums/htmlStorageLocation";
|
||||
import { KdfType } from "../enums/kdfType";
|
||||
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/cipherData";
|
||||
import { CollectionData } from "../models/data/collectionData";
|
||||
import { EncryptedOrganizationKeyData } from "../models/data/encryptedOrganizationKeyData";
|
||||
@@ -56,6 +58,8 @@ const partialKeys = {
|
||||
masterKey: "_masterkey",
|
||||
};
|
||||
|
||||
const DDG_SHARED_KEY = "DuckDuckGoSharedKey";
|
||||
|
||||
export class StateService<
|
||||
TGlobalState extends GlobalState = GlobalState,
|
||||
TAccount extends Account = Account
|
||||
@@ -76,7 +80,7 @@ export class StateService<
|
||||
constructor(
|
||||
protected storageService: AbstractStorageService,
|
||||
protected secureStorageService: AbstractStorageService,
|
||||
protected memoryStorageService: AbstractStorageService,
|
||||
protected memoryStorageService: AbstractStorageService & MemoryStorageServiceInterface,
|
||||
protected logService: LogService,
|
||||
protected stateMigrationService: StateMigrationService,
|
||||
protected stateFactory: StateFactory<TGlobalState, TAccount>,
|
||||
@@ -150,6 +154,9 @@ export class StateService<
|
||||
return;
|
||||
}
|
||||
await this.updateState(async (state) => {
|
||||
if (state.accounts == null) {
|
||||
state.accounts = {};
|
||||
}
|
||||
state.accounts[userId] = this.createAccount();
|
||||
const diskAccount = await this.getAccountFromDisk({ userId: userId });
|
||||
state.accounts[userId].profile = diskAccount.profile;
|
||||
@@ -494,11 +501,11 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON)
|
||||
async getCryptoMasterKey(options?: StorageOptions): Promise<SymmetricCryptoKey> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.keys?.cryptoMasterKey;
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
return account?.keys?.cryptoMasterKey;
|
||||
}
|
||||
|
||||
async setCryptoMasterKey(value: SymmetricCryptoKey, options?: StorageOptions): Promise<void> {
|
||||
@@ -604,23 +611,6 @@ export class StateService<
|
||||
await this.saveSecureStorageKey(partialKeys.biometricKey, value, options);
|
||||
}
|
||||
|
||||
async getDecodedToken(options?: StorageOptions): Promise<any> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.tokens?.decodedToken;
|
||||
}
|
||||
|
||||
async setDecodedToken(value: any, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.tokens.decodedToken = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForArrayMembers(CipherView, CipherView.fromJSON)
|
||||
async getDecryptedCiphers(options?: StorageOptions): Promise<CipherView[]> {
|
||||
return (
|
||||
@@ -657,11 +647,11 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON)
|
||||
async getDecryptedCryptoSymmetricKey(options?: StorageOptions): Promise<SymmetricCryptoKey> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.keys?.cryptoSymmetricKey?.decrypted;
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
return account?.keys?.cryptoSymmetricKey?.decrypted;
|
||||
}
|
||||
|
||||
async setDecryptedCryptoSymmetricKey(
|
||||
@@ -678,14 +668,13 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForMap(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON)
|
||||
async getDecryptedOrganizationKeys(
|
||||
options?: StorageOptions
|
||||
): Promise<Map<string, SymmetricCryptoKey>> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
return account?.keys?.organizationKeys?.decrypted;
|
||||
return this.recordToMap(account?.keys?.organizationKeys?.decrypted);
|
||||
}
|
||||
|
||||
async setDecryptedOrganizationKeys(
|
||||
@@ -695,7 +684,7 @@ export class StateService<
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.keys.organizationKeys.decrypted = value;
|
||||
account.keys.organizationKeys.decrypted = this.mapToRecord(value);
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
@@ -725,7 +714,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototype(EncString)
|
||||
async getDecryptedPinProtected(options?: StorageOptions): Promise<EncString> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
@@ -762,14 +750,9 @@ export class StateService<
|
||||
}
|
||||
|
||||
async getDecryptedPrivateKey(options?: StorageOptions): Promise<ArrayBuffer> {
|
||||
const privateKey = (
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.keys?.privateKey;
|
||||
let result = privateKey?.decrypted;
|
||||
if (result == null && privateKey?.decryptedSerialized != null) {
|
||||
result = Utils.fromByteStringToArray(privateKey.decryptedSerialized);
|
||||
}
|
||||
return result;
|
||||
)?.keys?.privateKey.decrypted;
|
||||
}
|
||||
|
||||
async setDecryptedPrivateKey(value: ArrayBuffer, options?: StorageOptions): Promise<void> {
|
||||
@@ -777,21 +760,19 @@ export class StateService<
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.keys.privateKey.decrypted = value;
|
||||
account.keys.privateKey.decryptedSerialized =
|
||||
value == null ? null : Utils.fromBufferToByteString(value);
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForMap(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON)
|
||||
async getDecryptedProviderKeys(
|
||||
options?: StorageOptions
|
||||
): Promise<Map<string, SymmetricCryptoKey>> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.keys?.providerKeys?.decrypted;
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
return this.recordToMap(account?.keys?.providerKeys?.decrypted);
|
||||
}
|
||||
|
||||
async setDecryptedProviderKeys(
|
||||
@@ -801,7 +782,7 @@ export class StateService<
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.keys.providerKeys.decrypted = value;
|
||||
account.keys.providerKeys.decrypted = this.mapToRecord(value);
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
@@ -1029,6 +1010,24 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
return null;
|
||||
}
|
||||
return await this.secureStorageService.get<string>(DDG_SHARED_KEY, options);
|
||||
}
|
||||
|
||||
async setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
return;
|
||||
}
|
||||
value == null
|
||||
? await this.secureStorageService.remove(DDG_SHARED_KEY, options)
|
||||
: await this.secureStorageService.save(DDG_SHARED_KEY, value, options);
|
||||
}
|
||||
|
||||
async getEmail(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
@@ -1187,6 +1186,27 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableDuckDuckGoBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.enableDuckDuckGoBrowserIntegration ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEnableDuckDuckGoBrowserIntegration(
|
||||
value: boolean,
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
globals.enableDuckDuckGoBrowserIntegration = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableFullWidth(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(
|
||||
@@ -1538,7 +1558,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototype(EnvironmentUrls)
|
||||
async getEnvironmentUrls(options?: StorageOptions): Promise<EnvironmentUrls> {
|
||||
if ((await this.state())?.activeUserId == null) {
|
||||
return await this.getGlobalEnvironmentUrls(options);
|
||||
@@ -2021,11 +2040,7 @@ export class StateService<
|
||||
const keys = (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.keys;
|
||||
let result = keys?.publicKey;
|
||||
if (result == null && keys?.publicKeySerialized != null) {
|
||||
result = Utils.fromByteStringToArray(keys.publicKeySerialized);
|
||||
}
|
||||
return result;
|
||||
return keys?.publicKey;
|
||||
}
|
||||
|
||||
async setPublicKey(value: ArrayBuffer, options?: StorageOptions): Promise<void> {
|
||||
@@ -2033,7 +2048,6 @@ export class StateService<
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.keys.publicKey = value;
|
||||
account.keys.publicKeySerialized = value == null ? null : Utils.fromBufferToByteString(value);
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
@@ -2741,8 +2755,11 @@ export class StateService<
|
||||
: await this.secureStorageService.save(`${options.userId}${key}`, value, options);
|
||||
}
|
||||
|
||||
protected state(): Promise<State<TGlobalState, TAccount>> {
|
||||
return this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state);
|
||||
protected async state(): Promise<State<TGlobalState, TAccount>> {
|
||||
const state = await this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state, {
|
||||
deserializer: (s) => State.fromJSON(s),
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
private async setState(state: State<TGlobalState, TAccount>): Promise<void> {
|
||||
@@ -2761,6 +2778,14 @@ export class StateService<
|
||||
await this.setState(updatedState);
|
||||
});
|
||||
}
|
||||
|
||||
private mapToRecord<V>(map: Map<string, V>): Record<string, V> {
|
||||
return map == null ? null : Object.fromEntries(map);
|
||||
}
|
||||
|
||||
private recordToMap<V>(record: Record<string, V>): Map<string, V> {
|
||||
return record == null ? null : new Map(Object.entries(record));
|
||||
}
|
||||
}
|
||||
|
||||
export function withPrototype<T>(
|
||||
@@ -2893,52 +2918,3 @@ function withPrototypeForObjectValues<T>(
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function withPrototypeForMap<T>(
|
||||
valuesConstructor: new (...args: any[]) => T,
|
||||
valuesConverter: (input: any) => T = (i) => i
|
||||
): (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
) => { value: (...args: any[]) => Promise<Map<string, T>> } {
|
||||
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
return {
|
||||
value: function (...args: any[]) {
|
||||
const originalResult: Promise<any> = 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) => {
|
||||
if (result == null) {
|
||||
return null;
|
||||
} else if (result instanceof Map) {
|
||||
return result;
|
||||
} else {
|
||||
for (const key in Object.keys(result)) {
|
||||
result[key] =
|
||||
result[key] == null ||
|
||||
result[key].constructor.name === valuesConstructor.prototype.constructor.name
|
||||
? valuesConverter(result[key])
|
||||
: valuesConverter(
|
||||
Object.create(
|
||||
valuesConstructor.prototype,
|
||||
Object.getOwnPropertyDescriptors(result[key])
|
||||
)
|
||||
);
|
||||
}
|
||||
return new Map<string, T>(Object.entries(result));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,13 @@ import { OrganizationData } from "../models/data/organizationData";
|
||||
import { PolicyData } from "../models/data/policyData";
|
||||
import { ProviderData } from "../models/data/providerData";
|
||||
import { SendData } from "../models/data/sendData";
|
||||
import { Account, AccountSettings, AccountSettingsSettings } from "../models/domain/account";
|
||||
import {
|
||||
Account,
|
||||
AccountSettings,
|
||||
AccountSettingsSettings,
|
||||
EncryptionPair,
|
||||
} from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/encString";
|
||||
import { EnvironmentUrls } from "../models/domain/environmentUrls";
|
||||
import { GeneratedPasswordHistory } from "../models/domain/generatedPasswordHistory";
|
||||
import { GlobalState } from "../models/domain/globalState";
|
||||
@@ -314,10 +320,10 @@ export class StateMigrationService<
|
||||
passwordGenerationOptions:
|
||||
(await this.get<any>(v1Keys.passwordGenerationOptions)) ??
|
||||
defaultAccount.settings.passwordGenerationOptions,
|
||||
pinProtected: {
|
||||
pinProtected: Object.assign(new EncryptionPair<string, EncString>(), {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<string>(v1Keys.pinProtected),
|
||||
},
|
||||
}),
|
||||
protectedPin: await this.get<string>(v1Keys.protectedPin),
|
||||
settings:
|
||||
userId == null
|
||||
|
||||
@@ -93,11 +93,6 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js
|
||||
|
||||
async decodeToken(token?: string): Promise<any> {
|
||||
const storedToken = await this.stateService.getDecodedToken();
|
||||
if (token === null && storedToken != null) {
|
||||
return storedToken;
|
||||
}
|
||||
|
||||
token = token ?? (await this.stateService.getAccessToken());
|
||||
|
||||
if (token == null) {
|
||||
|
||||
44
libs/common/src/types/deep-jsonify.ts
Normal file
44
libs/common/src/types/deep-jsonify.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
PositiveInfinity,
|
||||
NegativeInfinity,
|
||||
JsonPrimitive,
|
||||
TypedArray,
|
||||
JsonValue,
|
||||
} from "type-fest";
|
||||
import { NotJsonable } from "type-fest/source/jsonify";
|
||||
|
||||
/**
|
||||
* Extracted from type-fest and extended with Jsonification of objects returned from `toJSON` methods.
|
||||
*/
|
||||
export type DeepJsonify<T> =
|
||||
// Check if there are any non-JSONable types represented in the union.
|
||||
// Note: The use of tuples in this first condition side-steps distributive conditional types
|
||||
// (see https://github.com/microsoft/TypeScript/issues/29368#issuecomment-453529532)
|
||||
[Extract<T, NotJsonable | bigint>] extends [never]
|
||||
? T extends PositiveInfinity | NegativeInfinity ? null
|
||||
: T extends JsonPrimitive
|
||||
? T // Primitive is acceptable
|
||||
: T extends number ? number
|
||||
: T extends string ? string
|
||||
: T extends boolean ? boolean
|
||||
: T extends Map<any, any> | Set<any> ? Record<string, unknown> // {}
|
||||
: T extends TypedArray ? Record<string, number>
|
||||
: T extends Array<infer U>
|
||||
? Array<DeepJsonify<U extends NotJsonable ? null : U>> // It's an array: recursive call for its children
|
||||
: T extends object
|
||||
? T extends { toJSON(): infer J }
|
||||
? (() => J) extends () => JsonValue // Is J assignable to JsonValue?
|
||||
? J // Then T is Jsonable and its Jsonable value is J
|
||||
: {[P in keyof J as P extends symbol
|
||||
? never
|
||||
: J[P] extends NotJsonable
|
||||
? never
|
||||
: P]: DeepJsonify<Required<J>[P]>;
|
||||
} // Not Jsonable because its toJSON() method does not return JsonValue
|
||||
: {[P in keyof T as P extends symbol
|
||||
? never
|
||||
: T[P] extends NotJsonable
|
||||
? never
|
||||
: P]: DeepJsonify<Required<T>[P]>} // It's an object: recursive call for its children
|
||||
: never // Otherwise any other non-object is removed
|
||||
: never; // Otherwise non-JSONable type union was found not empty
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./tabs.module";
|
||||
export * from "./tab-group.component";
|
||||
export * from "./tab-item.component";
|
||||
export * from "./tab-group/tab-group.component";
|
||||
export * from "./tab-group/tab.component";
|
||||
export * from "./tab-nav-bar/tab-nav-bar.component";
|
||||
export * from "./tab-nav-bar/tab-link.component";
|
||||
|
||||
14
libs/components/src/tabs/shared/tab-header.component.ts
Normal file
14
libs/components/src/tabs/shared/tab-header.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Component used for styling the tab header/background for both content and navigation tabs
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-tab-header",
|
||||
host: {
|
||||
class:
|
||||
"tw-h-16 tw-pl-4 tw-bg-background-alt tw-flex tw-items-end tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300",
|
||||
},
|
||||
template: `<ng-content></ng-content>`,
|
||||
})
|
||||
export class TabHeaderComponent {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Directive } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Directive used for styling the container for bit tab labels
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitTabListContainer]",
|
||||
host: {
|
||||
class: "tw-inline-flex tw-flex-wrap tw-leading-5",
|
||||
},
|
||||
})
|
||||
export class TabListContainerDirective {}
|
||||
85
libs/components/src/tabs/shared/tab-list-item.directive.ts
Normal file
85
libs/components/src/tabs/shared/tab-list-item.directive.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { FocusableOption } from "@angular/cdk/a11y";
|
||||
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Directive used for styling tab header items for both nav links (anchor tags)
|
||||
* and content tabs (button tags)
|
||||
*/
|
||||
@Directive({ selector: "[bitTabListItem]" })
|
||||
export class TabListItemDirective implements FocusableOption {
|
||||
@Input() active: boolean;
|
||||
@Input() disabled: boolean;
|
||||
|
||||
@HostBinding("attr.disabled")
|
||||
get disabledAttr() {
|
||||
return this.disabled || null; // native disabled attr must be null when false
|
||||
}
|
||||
|
||||
constructor(private elementRef: ElementRef) {}
|
||||
|
||||
focus() {
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
|
||||
click() {
|
||||
this.elementRef.nativeElement.click();
|
||||
}
|
||||
|
||||
@HostBinding("class")
|
||||
get classList(): string[] {
|
||||
return this.baseClassList
|
||||
.concat(this.active ? this.activeClassList : ["!tw-text-main"])
|
||||
.concat(this.disabled ? this.disabledClassList : []);
|
||||
}
|
||||
|
||||
get baseClassList(): string[] {
|
||||
return [
|
||||
"tw-block",
|
||||
"tw-relative",
|
||||
"tw-py-2",
|
||||
"tw-px-4",
|
||||
"tw-font-semibold",
|
||||
"tw-transition",
|
||||
"tw-rounded-t",
|
||||
"tw-border-0",
|
||||
"tw-border-x",
|
||||
"tw-border-t-4",
|
||||
"tw-border-transparent",
|
||||
"tw-border-solid",
|
||||
"tw-bg-transparent",
|
||||
"tw-text-main",
|
||||
"hover:tw-underline",
|
||||
"hover:tw-text-main",
|
||||
"focus-visible:tw-z-10",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:tw-ring-2",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
];
|
||||
}
|
||||
|
||||
get disabledClassList(): string[] {
|
||||
return [
|
||||
"!tw-bg-secondary-100",
|
||||
"!tw-text-muted",
|
||||
"hover:!tw-text-muted",
|
||||
"!tw-no-underline",
|
||||
"tw-cursor-not-allowed",
|
||||
];
|
||||
}
|
||||
|
||||
get activeClassList(): string[] {
|
||||
return [
|
||||
"tw--mb-px",
|
||||
"tw-border-x-secondary-300",
|
||||
"tw-border-t-primary-500",
|
||||
"tw-border-b",
|
||||
"tw-border-b-background",
|
||||
"tw-bg-background",
|
||||
"!tw-text-primary-500",
|
||||
"hover:tw-border-t-primary-700",
|
||||
"hover:!tw-text-primary-700",
|
||||
"focus-visible:tw-border-t-primary-700",
|
||||
"focus-visible:!tw-text-primary-700",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<div
|
||||
role="tablist"
|
||||
class="tw-inline-flex tw-flex-wrap tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-leading-5"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-group",
|
||||
templateUrl: "./tab-group.component.html",
|
||||
})
|
||||
export class TabGroupComponent {}
|
||||
@@ -0,0 +1 @@
|
||||
<ng-template [cdkPortalOutlet]="tabContent"></ng-template>
|
||||
45
libs/components/src/tabs/tab-group/tab-body.component.ts
Normal file
45
libs/components/src/tabs/tab-group/tab-body.component.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-body",
|
||||
templateUrl: "tab-body.component.html",
|
||||
})
|
||||
export class TabBodyComponent {
|
||||
private _firstRender: boolean;
|
||||
|
||||
@Input() content: TemplatePortal;
|
||||
@Input() preserveContent = false;
|
||||
|
||||
@HostBinding("attr.hidden") get hidden() {
|
||||
return !this.active || null;
|
||||
}
|
||||
|
||||
@Input()
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
set active(value: boolean) {
|
||||
this._active = value;
|
||||
if (this._active) {
|
||||
this._firstRender = true;
|
||||
}
|
||||
}
|
||||
private _active: boolean;
|
||||
|
||||
/**
|
||||
* The tab content to render.
|
||||
* Inactive tabs that have never been rendered/active do not have their
|
||||
* content rendered by default for performance. If `preserveContent` is `true`
|
||||
* then the content persists after the first time content is rendered.
|
||||
*/
|
||||
get tabContent() {
|
||||
if (this.active) {
|
||||
return this.content;
|
||||
}
|
||||
if (this.preserveContent && this._firstRender) {
|
||||
return this.content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
44
libs/components/src/tabs/tab-group/tab-group.component.html
Normal file
44
libs/components/src/tabs/tab-group/tab-group.component.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<bit-tab-header>
|
||||
<div
|
||||
bitTabListContainer
|
||||
role="tablist"
|
||||
[attr.aria-label]="label"
|
||||
(keydown)="keyManager.onKeydown($event)"
|
||||
>
|
||||
<button
|
||||
bitTabListItem
|
||||
*ngFor="let tab of tabs; let i = index"
|
||||
type="button"
|
||||
role="tab"
|
||||
[id]="getTabLabelId(i)"
|
||||
[active]="tab.isActive"
|
||||
[disabled]="tab.disabled"
|
||||
[attr.aria-selected]="selectedIndex === i"
|
||||
[attr.tabindex]="selectedIndex === i ? 0 : -1"
|
||||
(click)="selectTab(i)"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="content"></ng-container>
|
||||
|
||||
<ng-template #content>
|
||||
<ng-template [ngIf]="tab.templateLabel" [ngIfElse]="tabTextLabel">
|
||||
<ng-container [ngTemplateOutlet]="tab.templateLabel.templateRef"></ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #tabTextLabel>{{ tab.textLabel }}</ng-template>
|
||||
</ng-template>
|
||||
</button>
|
||||
</div>
|
||||
</bit-tab-header>
|
||||
<div class="tw-px-4 tw-pt-5">
|
||||
<bit-tab-body
|
||||
role="tabpanel"
|
||||
*ngFor="let tab of tabs; let i = index"
|
||||
[id]="getTabContentId(i)"
|
||||
[attr.tabindex]="selectedIndex === i ? 0 : -1"
|
||||
[attr.labeledby]="getTabLabelId(i)"
|
||||
[active]="tab.isActive"
|
||||
[content]="tab.content"
|
||||
[preserveContent]="preserveContent"
|
||||
>
|
||||
</bit-tab-body>
|
||||
</div>
|
||||
187
libs/components/src/tabs/tab-group/tab-group.component.ts
Normal file
187
libs/components/src/tabs/tab-group/tab-group.component.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { FocusKeyManager } from "@angular/cdk/a11y";
|
||||
import { coerceNumberProperty } from "@angular/cdk/coercion";
|
||||
import {
|
||||
AfterContentChecked,
|
||||
AfterContentInit,
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ContentChildren,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
Output,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
} from "@angular/core";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { TabListItemDirective } from "../shared/tab-list-item.directive";
|
||||
|
||||
import { TabComponent } from "./tab.component";
|
||||
|
||||
/** Used to generate unique ID's for each tab component */
|
||||
let nextId = 0;
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-group",
|
||||
templateUrl: "./tab-group.component.html",
|
||||
})
|
||||
export class TabGroupComponent
|
||||
implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy
|
||||
{
|
||||
private readonly _groupId: number;
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private _indexToSelect: number | null = 0;
|
||||
|
||||
/**
|
||||
* Aria label for the tab list menu
|
||||
*/
|
||||
@Input() label = "";
|
||||
|
||||
/**
|
||||
* Keep the content of off-screen tabs in the DOM.
|
||||
* Useful for keeping <audio> or <video> elements from re-initializing
|
||||
* after navigating between tabs.
|
||||
*/
|
||||
@Input() preserveContent = false;
|
||||
|
||||
@ContentChildren(TabComponent) tabs: QueryList<TabComponent>;
|
||||
@ViewChildren(TabListItemDirective) tabLabels: QueryList<TabListItemDirective>;
|
||||
|
||||
/** The index of the active tab. */
|
||||
@Input()
|
||||
get selectedIndex(): number | null {
|
||||
return this._selectedIndex;
|
||||
}
|
||||
set selectedIndex(value: number) {
|
||||
this._indexToSelect = coerceNumberProperty(value, null);
|
||||
}
|
||||
private _selectedIndex: number | null = null;
|
||||
|
||||
/** Output to enable support for two-way binding on `[(selectedIndex)]` */
|
||||
@Output() readonly selectedIndexChange: EventEmitter<number> = new EventEmitter<number>();
|
||||
|
||||
/** Event emitted when the tab selection has changed. */
|
||||
@Output() readonly selectedTabChange: EventEmitter<BitTabChangeEvent> =
|
||||
new EventEmitter<BitTabChangeEvent>();
|
||||
|
||||
/**
|
||||
* Focus key manager for keeping tab controls accessible.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tablist_role#keyboard_interactions
|
||||
*/
|
||||
keyManager: FocusKeyManager<TabListItemDirective>;
|
||||
|
||||
constructor() {
|
||||
this._groupId = nextId++;
|
||||
}
|
||||
|
||||
protected getTabContentId(id: number): string {
|
||||
return `bit-tab-content-${this._groupId}-${id}`;
|
||||
}
|
||||
|
||||
protected getTabLabelId(id: number): string {
|
||||
return `bit-tab-label-${this._groupId}-${id}`;
|
||||
}
|
||||
|
||||
selectTab(index: number) {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
|
||||
/**
|
||||
* After content is checked, the tab group knows what tabs are defined and which index
|
||||
* should be currently selected.
|
||||
*/
|
||||
ngAfterContentChecked(): void {
|
||||
const indexToSelect = (this._indexToSelect = this._clampTabIndex(this._indexToSelect));
|
||||
|
||||
if (this._selectedIndex != indexToSelect) {
|
||||
const isFirstRun = this._selectedIndex == null;
|
||||
|
||||
if (!isFirstRun) {
|
||||
this.selectedTabChange.emit({
|
||||
index: indexToSelect,
|
||||
tab: this.tabs.toArray()[indexToSelect],
|
||||
});
|
||||
}
|
||||
|
||||
// These values need to be updated after change detection as
|
||||
// the checked content may have references to them.
|
||||
Promise.resolve().then(() => {
|
||||
this.tabs.forEach((tab, index) => (tab.isActive = index === indexToSelect));
|
||||
|
||||
if (!isFirstRun) {
|
||||
this.selectedIndexChange.emit(indexToSelect);
|
||||
}
|
||||
});
|
||||
|
||||
// Manually update the _selectedIndex and keyManager active item
|
||||
this._selectedIndex = indexToSelect;
|
||||
if (this.keyManager) {
|
||||
this.keyManager.setActiveItem(indexToSelect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.keyManager = new FocusKeyManager(this.tabLabels)
|
||||
.withHorizontalOrientation("ltr")
|
||||
.withWrap()
|
||||
.withHomeAndEnd();
|
||||
}
|
||||
|
||||
ngAfterContentInit() {
|
||||
// Subscribe to any changes in the number of tabs, in order to be able
|
||||
// to re-render content when new tabs are added or removed.
|
||||
this.tabs.changes.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||
const indexToSelect = this._clampTabIndex(this._indexToSelect);
|
||||
|
||||
// If the selected tab didn't explicitly change, keep the previously
|
||||
// selected tab selected/active
|
||||
if (indexToSelect === this._selectedIndex) {
|
||||
const tabs = this.tabs.toArray();
|
||||
let selectedTab: TabComponent | undefined;
|
||||
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
if (tabs[i].isActive) {
|
||||
// Set both _indexToSelect and _selectedIndex to avoid firing a change
|
||||
// event which could cause an infinite loop if adding a tab within the
|
||||
// selectedIndexChange event
|
||||
this._indexToSelect = this._selectedIndex = i;
|
||||
selectedTab = tabs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No active tab found and a tab does exist means the active tab
|
||||
// was removed, so a new active tab must be set manually
|
||||
if (!selectedTab && tabs[indexToSelect]) {
|
||||
tabs[indexToSelect].isActive = true;
|
||||
this.selectedTabChange.emit({
|
||||
index: indexToSelect,
|
||||
tab: tabs[indexToSelect],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private _clampTabIndex(index: number): number {
|
||||
return Math.min(this.tabs.length - 1, Math.max(index || 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
export class BitTabChangeEvent {
|
||||
/**
|
||||
* The currently selected tab index
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* The currently selected tab
|
||||
*/
|
||||
tab: TabComponent;
|
||||
}
|
||||
22
libs/components/src/tabs/tab-group/tab-label.directive.ts
Normal file
22
libs/components/src/tabs/tab-group/tab-label.directive.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Directive, TemplateRef } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Used to identify template based tab labels (allows complex labels instead of just plaintext)
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* <bit-tab>
|
||||
* <ng-template bitTabLabel>
|
||||
* <i class="bwi bwi-search"></i> Search
|
||||
* </ng-template>
|
||||
*
|
||||
* <p>Tab Content</p>
|
||||
* </bit-tab>
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitTabLabel]",
|
||||
})
|
||||
export class TabLabelDirective {
|
||||
constructor(public templateRef: TemplateRef<unknown>) {}
|
||||
}
|
||||
1
libs/components/src/tabs/tab-group/tab.component.html
Normal file
1
libs/components/src/tabs/tab-group/tab.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<ng-template><ng-content></ng-content></ng-template>
|
||||
42
libs/components/src/tabs/tab-group/tab.component.ts
Normal file
42
libs/components/src/tabs/tab-group/tab.component.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
Component,
|
||||
ContentChild,
|
||||
Input,
|
||||
OnInit,
|
||||
TemplateRef,
|
||||
ViewChild,
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
|
||||
import { TabLabelDirective } from "./tab-label.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab",
|
||||
templateUrl: "./tab.component.html",
|
||||
host: {
|
||||
role: "tabpanel",
|
||||
},
|
||||
})
|
||||
export class TabComponent implements OnInit {
|
||||
@Input() disabled = false;
|
||||
|
||||
@Input("label") textLabel = "";
|
||||
|
||||
@ViewChild(TemplateRef, { static: true }) implicitContent: TemplateRef<unknown>;
|
||||
@ContentChild(TabLabelDirective) templateLabel: TabLabelDirective;
|
||||
|
||||
private _contentPortal: TemplatePortal | null = null;
|
||||
|
||||
get content(): TemplatePortal | null {
|
||||
return this._contentPortal;
|
||||
}
|
||||
|
||||
isActive: boolean;
|
||||
|
||||
constructor(private _viewContainerRef: ViewContainerRef) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this._contentPortal = new TemplatePortal(this.implicitContent, this._viewContainerRef);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<ng-container [ngSwitch]="disabled">
|
||||
<a
|
||||
*ngSwitchCase="false"
|
||||
role="tab"
|
||||
[class]="baseClassList"
|
||||
[routerLink]="route"
|
||||
[routerLinkActive]="activeClassList"
|
||||
#rla="routerLinkActive"
|
||||
[attr.aria-selected]="rla.isActive"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="content"></ng-container>
|
||||
</a>
|
||||
<button
|
||||
*ngSwitchCase="true"
|
||||
type="button"
|
||||
role="tab"
|
||||
[class]="baseClassList"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="content"></ng-container>
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-template #content>
|
||||
<ng-content></ng-content>
|
||||
</ng-template>
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-item",
|
||||
templateUrl: "./tab-item.component.html",
|
||||
})
|
||||
export class TabItemComponent {
|
||||
@Input() route: string; // ['/route']
|
||||
@Input() disabled = false;
|
||||
|
||||
get baseClassList(): string[] {
|
||||
return [
|
||||
"tw-block",
|
||||
"tw-relative",
|
||||
"tw-py-2",
|
||||
"tw-px-4",
|
||||
"tw-font-semibold",
|
||||
"tw-transition",
|
||||
"tw-rounded-t",
|
||||
"tw-border-0",
|
||||
"tw-border-x",
|
||||
"tw-border-t-4",
|
||||
"tw-border-transparent",
|
||||
"tw-border-solid",
|
||||
"!tw-text-main",
|
||||
"hover:tw-underline",
|
||||
"hover:!tw-text-main",
|
||||
"focus-visible:tw-z-10",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:tw-ring-2",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
"disabled:tw-bg-transparent",
|
||||
"disabled:!tw-text-muted/60",
|
||||
"disabled:tw-no-underline",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
];
|
||||
}
|
||||
|
||||
get activeClassList(): string {
|
||||
return [
|
||||
"tw--mb-px",
|
||||
"tw-border-x-secondary-300",
|
||||
"tw-border-t-primary-500",
|
||||
"tw-border-b",
|
||||
"tw-border-b-background",
|
||||
"tw-bg-background",
|
||||
"!tw-text-primary-500",
|
||||
"hover:tw-border-t-primary-700",
|
||||
"hover:!tw-text-primary-700",
|
||||
"focus-visible:tw-border-t-primary-700",
|
||||
"focus-visible:!tw-text-primary-700",
|
||||
].join(" ");
|
||||
}
|
||||
}
|
||||
13
libs/components/src/tabs/tab-nav-bar/tab-link.component.html
Normal file
13
libs/components/src/tabs/tab-nav-bar/tab-link.component.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<a
|
||||
bitTabListItem
|
||||
[routerLink]="disabled ? null : route"
|
||||
routerLinkActive
|
||||
#rla="routerLinkActive"
|
||||
[active]="rla.isActive"
|
||||
[disabled]="disabled"
|
||||
[attr.aria-disabled]="disabled"
|
||||
ariaCurrentWhenActive="page"
|
||||
role="link"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</a>
|
||||
51
libs/components/src/tabs/tab-nav-bar/tab-link.component.ts
Normal file
51
libs/components/src/tabs/tab-nav-bar/tab-link.component.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { FocusableOption } from "@angular/cdk/a11y";
|
||||
import { AfterViewInit, Component, HostListener, Input, OnDestroy, ViewChild } from "@angular/core";
|
||||
import { RouterLinkActive } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { TabListItemDirective } from "../shared/tab-list-item.directive";
|
||||
|
||||
import { TabNavBarComponent } from "./tab-nav-bar.component";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-link",
|
||||
templateUrl: "tab-link.component.html",
|
||||
})
|
||||
export class TabLinkComponent implements FocusableOption, AfterViewInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@ViewChild(TabListItemDirective) tabItem: TabListItemDirective;
|
||||
@ViewChild("rla") routerLinkActive: RouterLinkActive;
|
||||
|
||||
@Input() route: string;
|
||||
@Input() disabled = false;
|
||||
|
||||
@HostListener("keydown", ["$event"]) onKeyDown(event: KeyboardEvent) {
|
||||
if (event.code === "Space") {
|
||||
this.tabItem.click();
|
||||
}
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this.routerLinkActive?.isActive ?? false;
|
||||
}
|
||||
|
||||
constructor(private _tabNavBar: TabNavBarComponent) {}
|
||||
|
||||
focus(): void {
|
||||
this.tabItem.focus();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
// The active state of tab links are tracked via the routerLinkActive directive
|
||||
// We need to watch for changes to tell the parent nav group when the tab is active
|
||||
this.routerLinkActive.isActiveChange
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((_) => this._tabNavBar.updateActiveLink());
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<bit-tab-header>
|
||||
<nav bitTabListContainer [attr.aria-label]="label" (keydown)="keyManager.onKeydown($event)">
|
||||
<ng-content></ng-content>
|
||||
</nav>
|
||||
</bit-tab-header>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { FocusKeyManager } from "@angular/cdk/a11y";
|
||||
import {
|
||||
AfterContentInit,
|
||||
Component,
|
||||
ContentChildren,
|
||||
forwardRef,
|
||||
Input,
|
||||
QueryList,
|
||||
} from "@angular/core";
|
||||
|
||||
import { TabLinkComponent } from "./tab-link.component";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-nav-bar",
|
||||
templateUrl: "tab-nav-bar.component.html",
|
||||
})
|
||||
export class TabNavBarComponent implements AfterContentInit {
|
||||
@ContentChildren(forwardRef(() => TabLinkComponent)) tabLabels: QueryList<TabLinkComponent>;
|
||||
@Input() label = "";
|
||||
|
||||
/**
|
||||
* Focus key manager for keeping tab controls accessible.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tablist_role#keyboard_interactions
|
||||
*/
|
||||
keyManager: FocusKeyManager<TabLinkComponent>;
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
this.keyManager = new FocusKeyManager(this.tabLabels)
|
||||
.withHorizontalOrientation("ltr")
|
||||
.withWrap()
|
||||
.withHomeAndEnd();
|
||||
}
|
||||
|
||||
updateActiveLink() {
|
||||
// Keep the keyManager in sync with active tabs
|
||||
const items = this.tabLabels.toArray();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].active) {
|
||||
this.keyManager.updateActiveItem(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,37 @@
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { TabGroupComponent } from "./tab-group.component";
|
||||
import { TabItemComponent } from "./tab-item.component";
|
||||
import { TabHeaderComponent } from "./shared/tab-header.component";
|
||||
import { TabListContainerDirective } from "./shared/tab-list-container.directive";
|
||||
import { TabListItemDirective } from "./shared/tab-list-item.directive";
|
||||
import { TabBodyComponent } from "./tab-group/tab-body.component";
|
||||
import { TabGroupComponent } from "./tab-group/tab-group.component";
|
||||
import { TabLabelDirective } from "./tab-group/tab-label.directive";
|
||||
import { TabComponent } from "./tab-group/tab.component";
|
||||
import { TabLinkComponent } from "./tab-nav-bar/tab-link.component";
|
||||
import { TabNavBarComponent } from "./tab-nav-bar/tab-nav-bar.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, RouterModule],
|
||||
exports: [TabGroupComponent, TabItemComponent],
|
||||
declarations: [TabGroupComponent, TabItemComponent],
|
||||
imports: [CommonModule, RouterModule, PortalModule],
|
||||
exports: [
|
||||
TabGroupComponent,
|
||||
TabComponent,
|
||||
TabLabelDirective,
|
||||
TabNavBarComponent,
|
||||
TabLinkComponent,
|
||||
],
|
||||
declarations: [
|
||||
TabGroupComponent,
|
||||
TabComponent,
|
||||
TabLabelDirective,
|
||||
TabListContainerDirective,
|
||||
TabListItemDirective,
|
||||
TabHeaderComponent,
|
||||
TabNavBarComponent,
|
||||
TabLinkComponent,
|
||||
TabBodyComponent,
|
||||
],
|
||||
})
|
||||
export class TabsModule {}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Component } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { TabGroupComponent } from "./tab-group.component";
|
||||
import { TabItemComponent } from "./tab-item.component";
|
||||
import { TabGroupComponent } from "./tab-group/tab-group.component";
|
||||
import { TabsModule } from "./tabs.module";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-active-dummy",
|
||||
@@ -36,8 +36,6 @@ export default {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [
|
||||
TabGroupComponent,
|
||||
TabItemComponent,
|
||||
ActiveDummyComponent,
|
||||
ItemTwoDummyComponent,
|
||||
ItemThreeDummyComponent,
|
||||
@@ -45,6 +43,7 @@ export default {
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TabsModule,
|
||||
RouterModule.forRoot(
|
||||
[
|
||||
{ path: "", redirectTo: "active", pathMatch: "full" },
|
||||
@@ -66,19 +65,63 @@ export default {
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const TabGroupTemplate: Story<TabGroupComponent> = (args: TabGroupComponent) => ({
|
||||
const ContentTabGroupTemplate: Story<TabGroupComponent> = (args: any) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-tab-group>
|
||||
<bit-tab-item [route]="['active']">Active</bit-tab-item>
|
||||
<bit-tab-item [route]="['item-2']">Item 2</bit-tab-item>
|
||||
<bit-tab-item [route]="['item-3']">Item 3</bit-tab-item>
|
||||
<bit-tab-item [route]="['disabled']" [disabled]="true">Disabled</bit-tab-item>
|
||||
<bit-tab-group label="Main Content Tabs" class="tw-text-main">
|
||||
<bit-tab label="First Tab">First Tab Content</bit-tab>
|
||||
<bit-tab label="Second Tab">Second Tab Content</bit-tab>
|
||||
<bit-tab>
|
||||
<ng-template bitTabLabel>
|
||||
<i class="bwi bwi-search tw-pr-1"></i> Template Label
|
||||
</ng-template>
|
||||
Template Label Content
|
||||
</bit-tab>
|
||||
<bit-tab [disabled]="true" label="Disabled">
|
||||
Disabled Content
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
<div class="tw-bg-transparent tw-text-semibold tw-text-center !tw-text-main tw-py-10">
|
||||
`,
|
||||
});
|
||||
|
||||
export const ContentTabs = ContentTabGroupTemplate.bind({});
|
||||
|
||||
const NavTabGroupTemplate: Story<TabGroupComponent> = (args: TabGroupComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-tab-nav-bar label="Main">
|
||||
<bit-tab-link [route]="['active']">Active</bit-tab-link>
|
||||
<bit-tab-link [route]="['item-2']">Item 2</bit-tab-link>
|
||||
<bit-tab-link [route]="['item-3']">Item 3</bit-tab-link>
|
||||
<bit-tab-link [route]="['disable']" [disabled]="true">Disabled</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
<div class="tw-bg-transparent tw-text-semibold tw-text-center tw-text-main tw-py-10">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const TabGroup = TabGroupTemplate.bind({});
|
||||
export const NavigationTabs = NavTabGroupTemplate.bind({});
|
||||
|
||||
const PreserveContentTabGroupTemplate: Story<TabGroupComponent> = (args: any) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-tab-group label="Preserve Content Tabs" [preserveContent]="true" class="tw-text-main">
|
||||
<bit-tab label="Text Tab">
|
||||
<p>
|
||||
Play the video in the other tab and switch back to hear the video is still playing.
|
||||
</p>
|
||||
</bit-tab>
|
||||
<bit-tab label="Video Tab">
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/H0-yWbe5XG4"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen></iframe>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
`,
|
||||
});
|
||||
|
||||
export const PreserveContentTabs = PreserveContentTabGroupTemplate.bind({});
|
||||
|
||||
Reference in New Issue
Block a user