mirror of
https://github.com/bitwarden/browser
synced 2026-03-01 02:51:24 +00:00
Merge branch 'main' into PM-14892-Sales-Tax-Estimation-For-Clients
This commit is contained in:
@@ -282,4 +282,15 @@ export abstract class OrganizationUserApiService {
|
||||
* @param id - Organization user identifier
|
||||
*/
|
||||
abstract deleteOrganizationUser(organizationId: string, id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete many organization users
|
||||
* @param organizationId - Identifier for the organization the users belongs to
|
||||
* @param ids - List of organization user identifiers to delete
|
||||
* @return List of user ids, including both those that were successfully deleted and those that had an error
|
||||
*/
|
||||
abstract deleteManyOrganizationUsers(
|
||||
organizationId: string,
|
||||
ids: string[],
|
||||
): Promise<ListResponse<OrganizationUserBulkResponse>>;
|
||||
}
|
||||
|
||||
@@ -369,4 +369,18 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteManyOrganizationUsers(
|
||||
organizationId: string,
|
||||
ids: string[],
|
||||
): Promise<ListResponse<OrganizationUserBulkResponse>> {
|
||||
const r = await this.apiService.send(
|
||||
"DELETE",
|
||||
"/organizations/" + organizationId + "/users/delete-account",
|
||||
new OrganizationUserBulkRequest(ids),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new ListResponse(r, OrganizationUserBulkResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
MasterPasswordVerification,
|
||||
MasterPasswordVerificationResponse,
|
||||
} from "@bitwarden/common/auth/types/verification";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -318,7 +319,24 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service.
|
||||
await this.syncService.fullSync(false);
|
||||
const clientType = this.platformUtilsService.getClientType();
|
||||
if (clientType === ClientType.Browser || clientType === ClientType.Desktop) {
|
||||
// Desktop and Browser have better offline support and to facilitate this we don't make the user wait for what
|
||||
// could be an HTTP Timeout because their server is unreachable.
|
||||
await Promise.race([
|
||||
this.syncService
|
||||
.fullSync(false)
|
||||
.catch((err) => this.logService.error("Error during unlock sync", err)),
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
this.logService.warning("Skipping sync wait, continuing to unlock.");
|
||||
resolve();
|
||||
}, 5_000),
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
await this.syncService.fullSync(false);
|
||||
}
|
||||
|
||||
if (this.onSuccessfulSubmit != null) {
|
||||
await this.onSuccessfulSubmit();
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
||||
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { CreateAuthRequest } from "@bitwarden/common/auth/models/request/create-auth.request";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { HttpStatusCode } from "@bitwarden/common/enums/http-status-code.enum";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
@@ -43,7 +43,7 @@ enum State {
|
||||
}
|
||||
|
||||
@Directive()
|
||||
export class LoginViaAuthRequestComponent
|
||||
export class LoginViaAuthRequestComponentV1
|
||||
extends CaptchaProtectedComponent
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
@@ -51,7 +51,7 @@ export class LoginViaAuthRequestComponent
|
||||
userAuthNStatus: AuthenticationStatus;
|
||||
email: string;
|
||||
showResendNotification = false;
|
||||
authRequest: CreateAuthRequest;
|
||||
authRequest: AuthRequest;
|
||||
fingerprintPhrase: string;
|
||||
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
|
||||
onSuccessfulLogin: () => Promise<any>;
|
||||
@@ -265,7 +265,7 @@ export class LoginViaAuthRequestComponent
|
||||
this.authRequestKeyPair.publicKey,
|
||||
);
|
||||
|
||||
this.authRequest = new CreateAuthRequest(
|
||||
this.authRequest = new AuthRequest(
|
||||
this.email,
|
||||
deviceIdentifier,
|
||||
publicKey,
|
||||
@@ -1,89 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-italic-300.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-italic-400.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-italic-600.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-italic-700.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-italic-800.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-normal-300.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-normal-400.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-normal-600.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-normal-700.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: auto;
|
||||
src: url(webfonts/Open_Sans-normal-800.woff) format("woff");
|
||||
unicode-range: U+0-10FFFF;
|
||||
font-family: "DM Sans";
|
||||
src:
|
||||
url("webfonts/dm-sans.woff2") format("woff2 supports variations"),
|
||||
url("webfonts/dm-sans.woff2") format("woff2-variations");
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
libs/angular/src/scss/webfonts/dm-sans.woff2
Normal file
BIN
libs/angular/src/scss/webfonts/dm-sans.woff2
Normal file
Binary file not shown.
@@ -31,6 +31,8 @@ import {
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
LogoutReason,
|
||||
RegisterRouteService,
|
||||
AuthRequestApiService,
|
||||
DefaultAuthRequestApiService,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
@@ -1384,6 +1386,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultCipherAuthorizationService,
|
||||
deps: [CollectionService, OrganizationServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestApiService,
|
||||
useClass: DefaultAuthRequestApiService,
|
||||
deps: [ApiServiceAbstraction, LogService],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -713,19 +713,26 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
protected deleteCipher() {
|
||||
// cipher.collectionIds may be null or an empty array. Either is a valid indication that the item is unassigned.
|
||||
const asAdmin =
|
||||
this.organization?.canEditAllCiphers ||
|
||||
!this.cipher.collectionIds ||
|
||||
this.cipher.collectionIds.length === 0;
|
||||
return this.cipher.isDeleted
|
||||
? this.cipherService.deleteWithServer(this.cipher.id, asAdmin)
|
||||
: this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin);
|
||||
? this.cipherService.deleteWithServer(this.cipher.id, this.asAdmin)
|
||||
: this.cipherService.softDeleteWithServer(this.cipher.id, this.asAdmin);
|
||||
}
|
||||
|
||||
protected restoreCipher() {
|
||||
const asAdmin = this.organization?.canEditAllCiphers;
|
||||
return this.cipherService.restoreWithServer(this.cipher.id, asAdmin);
|
||||
return this.cipherService.restoreWithServer(this.cipher.id, this.asAdmin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a cipher must be deleted as an admin by belonging to an organization and being unassigned to a collection.
|
||||
*/
|
||||
get asAdmin(): boolean {
|
||||
return (
|
||||
this.cipher.organizationId !== null &&
|
||||
this.cipher.organizationId.length > 0 &&
|
||||
(this.organization?.canEditAllCiphers ||
|
||||
!this.cipher.collectionIds ||
|
||||
this.cipher.collectionIds.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
get defaultOwnerId(): string | null {
|
||||
|
||||
52
libs/auth/src/angular/icons/devices.icon.ts
Normal file
52
libs/auth/src/angular/icons/devices.icon.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
|
||||
export const DevicesIcon = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 120 100">
|
||||
<path
|
||||
class="tw-fill-art-primary"
|
||||
fill-rule="evenodd"
|
||||
d="M41.212 87.309c0-.335.271-.606.606-.606H76.97a.606.606 0 0 1 0 1.212H41.818a.606.606 0 0 1-.606-.606Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
class="tw-fill-art-primary"
|
||||
fill-rule="evenodd"
|
||||
d="M53.176 87.31V76.542h1.212V87.31h-1.212Zm12.103 0V76.542h1.212V87.31h-1.212Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
class="tw-fill-art-primary"
|
||||
fill-rule="evenodd"
|
||||
d="M16.363 29.733a8.485 8.485 0 0 1 8.485-8.485h70.303a8.485 8.485 0 0 1 8.485 8.485v3.637h-2.424v-3.637a6.06 6.06 0 0 0-6.06-6.06H24.847a6.06 6.06 0 0 0-6.06 6.06v9.697h-2.425v-9.697Zm9.091 44.849H76.97v2.424H25.454v-2.424Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
class="tw-fill-art-accent"
|
||||
fill-rule="evenodd"
|
||||
d="M21.212 30.34c0-2.344 1.9-4.243 4.242-4.243h69.091c2.343 0 4.243 1.9 4.243 4.242v3.03h-1.212v-3.03a3.03 3.03 0 0 0-3.03-3.03H25.453a3.03 3.03 0 0 0-3.03 3.03v9.091h-1.212v-9.09Zm4.242 40.605H76.97v1.212H25.454v-1.212Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
class="tw-fill-art-primary"
|
||||
fill-rule="evenodd"
|
||||
d="M75.758 38.218a6.06 6.06 0 0 1 6.06-6.06h32.122a6.06 6.06 0 0 1 6.06 6.06v48.485a6.06 6.06 0 0 1-6.06 6.06H81.818a6.06 6.06 0 0 1-6.06-6.06V38.218Zm6.06-3.636a3.636 3.636 0 0 0-3.636 3.636v48.485a3.636 3.636 0 0 0 3.636 3.636h32.122a3.636 3.636 0 0 0 3.636-3.636V38.218a3.636 3.636 0 0 0-3.636-3.636H81.818Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
class="tw-fill-art-accent"
|
||||
d="M99.394 87.31a1.212 1.212 0 1 1-2.424 0 1.212 1.212 0 0 1 2.424 0Z"
|
||||
/>
|
||||
<path
|
||||
class="tw-fill-art-primary"
|
||||
fill-rule="evenodd"
|
||||
d="M20.606 40.642H6.061a3.636 3.636 0 0 0-3.637 3.636V80.64a3.636 3.636 0 0 0 3.637 3.637h14.545a3.636 3.636 0 0 0 3.636-3.637V44.278a3.636 3.636 0 0 0-3.636-3.636ZM6.061 38.217A6.06 6.06 0 0 0 0 44.277v36.364a6.06 6.06 0 0 0 6.06 6.061h14.546a6.06 6.06 0 0 0 6.06-6.06V44.277a6.06 6.06 0 0 0-6.06-6.06H6.061Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
class="tw-fill-art-accent"
|
||||
fill-rule="evenodd"
|
||||
d="M12.345 43.556c0-.334.272-.606.606-.606h.753a.606.606 0 1 1 0 1.212h-.753a.606.606 0 0 1-.606-.606Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./bitwarden-logo.icon";
|
||||
export * from "./bitwarden-shield.icon";
|
||||
export * from "./devices.icon";
|
||||
export * from "./lock.icon";
|
||||
export * from "./registration-check-email.icon";
|
||||
export * from "./user-lock.icon";
|
||||
|
||||
@@ -24,6 +24,9 @@ export * from "./login/login-secondary-content.component";
|
||||
export * from "./login/login-component.service";
|
||||
export * from "./login/default-login-component.service";
|
||||
|
||||
// login via auth request
|
||||
export * from "./login-via-auth-request/login-via-auth-request.component";
|
||||
|
||||
// password callout
|
||||
export * from "./password-callout/password-callout.component";
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="tw-text-center">
|
||||
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
|
||||
<p>{{ "makeSureYourAccountIsUnlockedAndTheFingerprintEtc" | i18n }}</p>
|
||||
|
||||
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
|
||||
|
||||
<button
|
||||
*ngIf="showResendNotification"
|
||||
type="button"
|
||||
bitButton
|
||||
block
|
||||
buttonType="secondary"
|
||||
class="tw-mt-4"
|
||||
(click)="startStandardAuthRequestLogin()"
|
||||
>
|
||||
{{ "resendNotification" | i18n }}
|
||||
</button>
|
||||
|
||||
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
|
||||
<span>{{ "needAnotherOptionV1" | i18n }}</span>
|
||||
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
|
||||
"viewAllLogInOptions" | i18n
|
||||
}}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
|
||||
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
|
||||
|
||||
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
|
||||
|
||||
<div class="tw-mt-4">
|
||||
<span>{{ "troubleLoggingIn" | i18n }}</span>
|
||||
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
|
||||
"viewAllLogInOptions" | i18n
|
||||
}}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
@@ -0,0 +1,569 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { IsActiveMatchOptions, Router, RouterModule } from "@angular/router";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
AuthRequestLoginCredentials,
|
||||
AuthRequestServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
LoginStrategyServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { ButtonModule, LinkModule, ToastService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { AuthRequestApiService } from "../../common/abstractions/auth-request-api.service";
|
||||
|
||||
enum Flow {
|
||||
StandardAuthRequest, // when user clicks "Login with device" from /login or "Approve from your other device" from /login-initiated
|
||||
AdminAuthRequest, // when user clicks "Request admin approval" from /login-initiated
|
||||
}
|
||||
|
||||
const matchOptions: IsActiveMatchOptions = {
|
||||
paths: "exact",
|
||||
queryParams: "ignored",
|
||||
fragment: "ignored",
|
||||
matrixParams: "ignored",
|
||||
};
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "./login-via-auth-request.component.html",
|
||||
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
|
||||
})
|
||||
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
private authRequest: AuthRequest;
|
||||
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
|
||||
private authStatus: AuthenticationStatus;
|
||||
private showResendNotificationTimeoutSeconds = 12;
|
||||
|
||||
protected backToRoute = "/login";
|
||||
protected clientType: ClientType;
|
||||
protected ClientType = ClientType;
|
||||
protected email: string;
|
||||
protected fingerprintPhrase: string;
|
||||
protected showResendNotification = false;
|
||||
protected Flow = Flow;
|
||||
protected flow = Flow.StandardAuthRequest;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private anonymousHubService: AnonymousHubService,
|
||||
private appIdService: AppIdService,
|
||||
private authRequestApiService: AuthRequestApiService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private authService: AuthService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private deviceTrustService: DeviceTrustServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private loginEmailService: LoginEmailServiceAbstraction,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
private syncService: SyncService,
|
||||
private toastService: ToastService,
|
||||
private validationService: ValidationService,
|
||||
) {
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
|
||||
// Gets SignalR push notification
|
||||
// Only fires on approval to prevent enumeration
|
||||
this.authRequestService.authRequestPushNotification$
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((requestId) => {
|
||||
this.verifyAndHandleApprovedAuthReq(requestId).catch((e: Error) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
this.logService.error("Failed to use approved auth request: " + e.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
// Get the authStatus early because we use it in both flows
|
||||
this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
|
||||
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
||||
|
||||
if (userHasAuthenticatedViaSSO) {
|
||||
this.backToRoute = "/login-initiated";
|
||||
}
|
||||
|
||||
/**
|
||||
* The LoginViaAuthRequestComponent handles both the `login-with-device` and
|
||||
* the `admin-approval-requested` routes. Therefore we check the route to determine
|
||||
* which flow to initialize.
|
||||
*/
|
||||
if (this.router.isActive("admin-approval-requested", matchOptions)) {
|
||||
await this.initAdminAuthRequestFlow();
|
||||
} else {
|
||||
await this.initStandardAuthRequestFlow();
|
||||
}
|
||||
}
|
||||
|
||||
private async initAdminAuthRequestFlow(): Promise<void> {
|
||||
this.flow = Flow.AdminAuthRequest;
|
||||
|
||||
// Get email from state for admin auth requests because it is available and also
|
||||
// prevents it from being lost on refresh as the loginEmailService email does not persist.
|
||||
this.email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
);
|
||||
|
||||
if (!this.email) {
|
||||
await this.handleMissingEmail();
|
||||
return;
|
||||
}
|
||||
|
||||
// We only allow a single admin approval request to be active at a time
|
||||
// so we must check state to see if we have an existing one or not
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
|
||||
|
||||
if (existingAdminAuthRequest) {
|
||||
await this.handleExistingAdminAuthRequest(existingAdminAuthRequest, userId);
|
||||
} else {
|
||||
await this.startAdminAuthRequestLogin();
|
||||
}
|
||||
}
|
||||
|
||||
private async initStandardAuthRequestFlow(): Promise<void> {
|
||||
this.flow = Flow.StandardAuthRequest;
|
||||
|
||||
this.email = await firstValueFrom(this.loginEmailService.loginEmail$);
|
||||
|
||||
if (!this.email) {
|
||||
await this.handleMissingEmail();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startStandardAuthRequestLogin();
|
||||
}
|
||||
|
||||
private async handleMissingEmail(): Promise<void> {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("userEmailMissing"),
|
||||
});
|
||||
|
||||
await this.router.navigate([this.backToRoute]);
|
||||
}
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
await this.anonymousHubService.stopHubConnection();
|
||||
}
|
||||
|
||||
private async startAdminAuthRequestLogin(): Promise<void> {
|
||||
try {
|
||||
await this.buildAuthRequest(AuthRequestType.AdminApproval);
|
||||
|
||||
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
|
||||
this.authRequest,
|
||||
);
|
||||
const adminAuthReqStorable = new AdminAuthRequestStorable({
|
||||
id: authRequestResponse.id,
|
||||
privateKey: this.authRequestKeyPair.privateKey,
|
||||
});
|
||||
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected async startStandardAuthRequestLogin(): Promise<void> {
|
||||
this.showResendNotification = false;
|
||||
|
||||
try {
|
||||
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
||||
|
||||
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
|
||||
this.authRequest,
|
||||
);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.showResendNotification = true;
|
||||
}, this.showResendNotificationTimeoutSeconds * 1000);
|
||||
}
|
||||
|
||||
private async buildAuthRequest(authRequestType: AuthRequestType): Promise<void> {
|
||||
const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
|
||||
|
||||
this.authRequestKeyPair = {
|
||||
publicKey: authRequestKeyPairArray[0],
|
||||
privateKey: authRequestKeyPairArray[1],
|
||||
};
|
||||
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
|
||||
const accessCode = await this.passwordGenerationService.generatePassword({
|
||||
type: "password",
|
||||
length: 25,
|
||||
});
|
||||
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
this.authRequestKeyPair.publicKey,
|
||||
);
|
||||
|
||||
this.authRequest = new AuthRequest(
|
||||
this.email,
|
||||
deviceIdentifier,
|
||||
publicKey,
|
||||
authRequestType,
|
||||
accessCode,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleExistingAdminAuthRequest(
|
||||
adminAuthRequestStorable: AdminAuthRequestStorable,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
// Note: on login, the SSOLoginStrategy will also call to see if an existing admin auth req
|
||||
// has been approved and handle it if so.
|
||||
|
||||
// Regardless, we always retrieve the auth request from the server and verify and handle status changes here as well
|
||||
let adminAuthRequestResponse: AuthRequestResponse;
|
||||
|
||||
try {
|
||||
adminAuthRequestResponse = await this.authRequestApiService.getAuthRequest(
|
||||
adminAuthRequestStorable.id,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Request doesn't exist anymore
|
||||
if (!adminAuthRequestResponse) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
}
|
||||
|
||||
// Re-derive the user's fingerprint phrase
|
||||
// It is important to not use the server's public key here as it could have been compromised via MITM
|
||||
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
|
||||
adminAuthRequestStorable.privateKey,
|
||||
);
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
derivedPublicKeyArrayBuffer,
|
||||
);
|
||||
|
||||
// Request denied
|
||||
if (adminAuthRequestResponse.isAnswered && !adminAuthRequestResponse.requestApproved) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
}
|
||||
|
||||
// Request approved
|
||||
if (adminAuthRequestResponse.requestApproved) {
|
||||
return await this.decryptViaApprovedAuthRequest(
|
||||
adminAuthRequestResponse,
|
||||
adminAuthRequestStorable.privateKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
// Request still pending response from admin
|
||||
// set keypair and create hub connection so that any approvals will be received via push notification
|
||||
this.authRequestKeyPair = { privateKey: adminAuthRequestStorable.privateKey, publicKey: null };
|
||||
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
|
||||
}
|
||||
|
||||
private async verifyAndHandleApprovedAuthReq(requestId: string): Promise<void> {
|
||||
/**
|
||||
* ***********************************
|
||||
* Standard Auth Request Flows
|
||||
* ***********************************
|
||||
*
|
||||
* Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory.
|
||||
*
|
||||
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
|
||||
* > receives approval from a device with authRequestPublicKey(masterKey) > decrypts masterKey > decrypts userKey > proceed to vault
|
||||
*
|
||||
* Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory.
|
||||
*
|
||||
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
|
||||
* > receives approval from a device with authRequestPublicKey(userKey) > decrypts userKey > proceeds to vault
|
||||
*
|
||||
* Note: this flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could get into this flow:
|
||||
* 1) An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey in memory.
|
||||
* 2) The org admin...
|
||||
* (2a) Changes the member decryption options from "Trusted devices" to "Master password" AND
|
||||
* (2b) Turns off the "Require single sign-on authentication" policy
|
||||
* 3) On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO.
|
||||
* 4) The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in memory (see step 1 above).
|
||||
*
|
||||
* Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory.
|
||||
*
|
||||
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
|
||||
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(masterKey)
|
||||
* > decrypts masterKey > decrypts userKey > establishes trust (if required) > proceeds to vault
|
||||
*
|
||||
* Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory.
|
||||
*
|
||||
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
|
||||
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(userKey)
|
||||
* > decrypts userKey > establishes trust (if required) > proceeds to vault
|
||||
*
|
||||
* ***********************************
|
||||
* Admin Auth Request Flow
|
||||
* ***********************************
|
||||
*
|
||||
* Flow: Authed SSO TD user requests admin approval.
|
||||
*
|
||||
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Request admin approval"
|
||||
* > navigates to /admin-approval-requested which creates an AdminAuthRequest > receives approval from device with authRequestPublicKey(userKey)
|
||||
* > decrypts userKey > establishes trust (if required) > proceeds to vault
|
||||
*
|
||||
* Note: TDE users are required to be enrolled in admin password reset, which gives the admin access to the user's userKey.
|
||||
* This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
|
||||
*
|
||||
*
|
||||
* Summary Table
|
||||
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
* | Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory (see note 1) |
|
||||
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
* | Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
|
||||
* | Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
|
||||
* | Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
|
||||
* | Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no | |
|
||||
* | Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
|
||||
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
* * Note 1: The phrase "in memory" here is important. It is possible for a user to have a master password for their account, but not have a masterKey IN MEMORY for
|
||||
* a specific device. For example, if a user registers an account with a master password, then joins an SSO TD org, then logs in to a device via SSO and
|
||||
* admin auth request, they are now logged into that device but that device does not have masterKey IN MEMORY.
|
||||
*/
|
||||
|
||||
try {
|
||||
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
||||
|
||||
if (userHasAuthenticatedViaSSO) {
|
||||
// Get the auth request from the server
|
||||
// User is authenticated, therefore the endpoint does not require an access code.
|
||||
const authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
|
||||
|
||||
if (authRequestResponse.requestApproved) {
|
||||
// Handles Standard Flows 3-4 and Admin Flow
|
||||
await this.handleAuthenticatedFlows(authRequestResponse);
|
||||
}
|
||||
} else {
|
||||
// Get the auth request from the server
|
||||
// User is unauthenticated, therefore the endpoint requires an access code for user verification.
|
||||
const authRequestResponse = await this.authRequestApiService.getAuthResponse(
|
||||
requestId,
|
||||
this.authRequest.accessCode,
|
||||
);
|
||||
|
||||
if (authRequestResponse.requestApproved) {
|
||||
// Handles Standard Flows 1-2
|
||||
await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
await this.router.navigate([this.backToRoute]);
|
||||
this.validationService.showError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
|
||||
await this.decryptViaApprovedAuthRequest(
|
||||
authRequestResponse,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleUnauthenticatedFlows(
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
requestId: string,
|
||||
) {
|
||||
const authRequestLoginCredentials = await this.buildAuthRequestLoginCredentials(
|
||||
requestId,
|
||||
authRequestResponse,
|
||||
);
|
||||
|
||||
// Note: keys are set by AuthRequestLoginStrategy success handling
|
||||
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
|
||||
|
||||
await this.handlePostLoginNavigation(authResult);
|
||||
}
|
||||
|
||||
private async decryptViaApprovedAuthRequest(
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
privateKey: ArrayBuffer,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
/**
|
||||
* See verifyAndHandleApprovedAuthReq() for flow details.
|
||||
*
|
||||
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
|
||||
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
|
||||
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
|
||||
*/
|
||||
|
||||
if (authRequestResponse.masterPasswordHash) {
|
||||
// ...in Standard Auth Request Flow 3
|
||||
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
authRequestResponse,
|
||||
privateKey,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// ...in Standard Auth Request Flow 4 or Admin Auth Request Flow
|
||||
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
|
||||
authRequestResponse,
|
||||
privateKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
// clear the admin auth request from state so it cannot be used again (it's a one time use)
|
||||
// TODO: this should eventually be enforced via deleting this on the server once it is used
|
||||
await this.authRequestService.clearAdminAuthRequest(userId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("loginApproved"),
|
||||
});
|
||||
|
||||
// Now that we have a decrypted user key in memory, we can check if we
|
||||
// need to establish trust on the current device
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
|
||||
|
||||
await this.handleSuccessfulLoginNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an `AuthRequestResponse` and decrypts the `key` to build an `AuthRequestLoginCredentials`
|
||||
* object for use in the `AuthRequestLoginStrategy`.
|
||||
*
|
||||
* The credentials object that gets built is affected by whether the `authRequestResponse.key`
|
||||
* is an encrypted MasterKey or an encrypted UserKey.
|
||||
*/
|
||||
private async buildAuthRequestLoginCredentials(
|
||||
requestId: string,
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
): Promise<AuthRequestLoginCredentials> {
|
||||
/**
|
||||
* See verifyAndHandleApprovedAuthReq() for flow details.
|
||||
*
|
||||
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
|
||||
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
|
||||
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
|
||||
*/
|
||||
|
||||
if (authRequestResponse.masterPasswordHash) {
|
||||
// ...in Standard Auth Request Flow 1
|
||||
const { masterKey, masterKeyHash } =
|
||||
await this.authRequestService.decryptPubKeyEncryptedMasterKeyAndHash(
|
||||
authRequestResponse.key,
|
||||
authRequestResponse.masterPasswordHash,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
);
|
||||
|
||||
return new AuthRequestLoginCredentials(
|
||||
this.email,
|
||||
this.authRequest.accessCode,
|
||||
requestId,
|
||||
null, // no userKey
|
||||
masterKey,
|
||||
masterKeyHash,
|
||||
);
|
||||
} else {
|
||||
// ...in Standard Auth Request Flow 2
|
||||
const userKey = await this.authRequestService.decryptPubKeyEncryptedUserKey(
|
||||
authRequestResponse.key,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
);
|
||||
return new AuthRequestLoginCredentials(
|
||||
this.email,
|
||||
this.authRequest.accessCode,
|
||||
requestId,
|
||||
userKey,
|
||||
null, // no masterKey
|
||||
null, // no masterKeyHash
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
|
||||
// clear the admin auth request from state
|
||||
await this.authRequestService.clearAdminAuthRequest(userId);
|
||||
|
||||
// start new auth request
|
||||
await this.startAdminAuthRequestLogin();
|
||||
}
|
||||
|
||||
private async handlePostLoginNavigation(loginResponse: AuthResult) {
|
||||
if (loginResponse.requiresTwoFactor) {
|
||||
await this.router.navigate(["2fa"]);
|
||||
} else if (loginResponse.forcePasswordReset != ForceSetPasswordReason.None) {
|
||||
await this.router.navigate(["update-temp-password"]);
|
||||
} else {
|
||||
await this.handleSuccessfulLoginNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSuccessfulLoginNavigation() {
|
||||
if (this.flow === Flow.StandardAuthRequest) {
|
||||
// Only need to set remembered email on standard login with auth req flow
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
}
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
await this.router.navigate(["vault"]);
|
||||
}
|
||||
}
|
||||
@@ -63,12 +63,6 @@ describe("DefaultLoginComponentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoginViaAuthRequestSupported", () => {
|
||||
it("returns false by default", () => {
|
||||
expect(service.isLoginViaAuthRequestSupported()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoginWithPasskeySupported", () => {
|
||||
it("returns true when clientType is Web", () => {
|
||||
service["clientType"] = ClientType.Web;
|
||||
|
||||
@@ -25,10 +25,6 @@ export class DefaultLoginComponentService implements LoginComponentService {
|
||||
return null;
|
||||
}
|
||||
|
||||
isLoginViaAuthRequestSupported(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLoginWithPasskeySupported(): boolean {
|
||||
return this.clientType === ClientType.Web;
|
||||
}
|
||||
|
||||
@@ -23,11 +23,6 @@ export abstract class LoginComponentService {
|
||||
*/
|
||||
getOrgPolicies: () => Promise<PasswordPolicies | null>;
|
||||
|
||||
/**
|
||||
* Indicates whether login with device (auth request) is supported on the given client
|
||||
*/
|
||||
isLoginViaAuthRequestSupported: () => boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether login with passkey is supported on the given client
|
||||
*/
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
</button>
|
||||
|
||||
<!-- Button to Login with Device -->
|
||||
<ng-container *ngIf="loginViaAuthRequestSupported && isKnownDevice">
|
||||
<ng-container *ngIf="isKnownDevice">
|
||||
<div class="tw-text-center">{{ "or" | i18n }}</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -104,12 +104,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
return this.formGroup.controls.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* LoginViaAuthRequestSupported is a boolean that determines if we show the Login with device button.
|
||||
* An AuthRequest is the mechanism that allows users to login to the client via a device that is already logged in.
|
||||
*/
|
||||
loginViaAuthRequestSupported = false;
|
||||
|
||||
// Web properties
|
||||
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions;
|
||||
policies: Policy[];
|
||||
@@ -144,7 +138,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
this.loginViaAuthRequestSupported = this.loginComponentService.isLoginViaAuthRequestSupported();
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -402,10 +395,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
// Reset master password only when going from validated to not validated so that autofill can work properly
|
||||
this.formGroup.controls.masterPassword.reset();
|
||||
|
||||
if (this.loginViaAuthRequestSupported) {
|
||||
// Reset known device state when going back to email entry if it is supported
|
||||
this.isKnownDevice = false;
|
||||
}
|
||||
// Reset known device state when going back to email entry if it is supported
|
||||
this.isKnownDevice = false;
|
||||
} else if (this.loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY) {
|
||||
this.loginComponentService.showBackButton(true);
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
@@ -426,9 +417,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.loginViaAuthRequestSupported) {
|
||||
await this.getKnownDevice(this.emailFormControl.value);
|
||||
}
|
||||
// Check to see if the device is known so we can show the Login with Device option
|
||||
await this.getKnownDevice(this.emailFormControl.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,9 +570,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
await this.loadEmailSettings();
|
||||
}
|
||||
|
||||
if (this.loginViaAuthRequestSupported) {
|
||||
await this.getKnownDevice(this.emailFormControl.value);
|
||||
}
|
||||
// Check to see if the device is known so that we can show the Login with Device option
|
||||
await this.getKnownDevice(this.emailFormControl.value);
|
||||
|
||||
// Backup check to handle unknown case where activatedRoute is not available
|
||||
// This shouldn't happen under normal circumstances
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
|
||||
export abstract class AuthRequestApiService {
|
||||
/**
|
||||
* Gets an auth request by its ID.
|
||||
*
|
||||
* @param requestId The ID of the auth request.
|
||||
* @returns A promise that resolves to the auth request response.
|
||||
*/
|
||||
abstract getAuthRequest: (requestId: string) => Promise<AuthRequestResponse>;
|
||||
|
||||
/**
|
||||
* Gets an auth request response by its ID and access code.
|
||||
*
|
||||
* @param requestId The ID of the auth request.
|
||||
* @param accessCode The access code of the auth request.
|
||||
* @returns A promise that resolves to the auth request response.
|
||||
*/
|
||||
abstract getAuthResponse: (requestId: string, accessCode: string) => Promise<AuthRequestResponse>;
|
||||
|
||||
/**
|
||||
* Sends an admin auth request.
|
||||
*
|
||||
* @param request The auth request object.
|
||||
* @returns A promise that resolves to the auth request response.
|
||||
*/
|
||||
abstract postAdminAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
|
||||
|
||||
/**
|
||||
* Sends an auth request.
|
||||
*
|
||||
* @param request The auth request object.
|
||||
* @returns A promise that resolves to the auth request response.
|
||||
*/
|
||||
abstract postAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./auth-request-api.service";
|
||||
export * from "./pin.service.abstraction";
|
||||
export * from "./login-email.service";
|
||||
export * from "./login-strategy.service";
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { AuthRequestApiService } from "../../abstractions/auth-request-api.service";
|
||||
|
||||
export class DefaultAuthRequestApiService implements AuthRequestApiService {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async getAuthRequest(requestId: string): Promise<AuthRequestResponse> {
|
||||
try {
|
||||
const path = `/auth-requests/${requestId}`;
|
||||
const response = await this.apiService.send("GET", path, null, true, true);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthResponse(requestId: string, accessCode: string): Promise<AuthRequestResponse> {
|
||||
try {
|
||||
const path = `/auth-requests/${requestId}/response?code=${accessCode}`;
|
||||
const response = await this.apiService.send("GET", path, null, false, true);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async postAdminAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
|
||||
try {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/auth-requests/admin-request",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
|
||||
try {
|
||||
const response = await this.apiService.send("POST", "/auth-requests/", request, false, true);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ export * from "./login-email/login-email.service";
|
||||
export * from "./login-strategies/login-strategy.service";
|
||||
export * from "./user-decryption-options/user-decryption-options.service";
|
||||
export * from "./auth-request/auth-request.service";
|
||||
export * from "./auth-request/auth-request-api.service";
|
||||
export * from "./register-route.service";
|
||||
export * from "./accounts/lock.service";
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../admin-console/models/response/organization-connection.response";
|
||||
import { OrganizationExportResponse } from "../admin-console/models/response/organization-export.response";
|
||||
import { OrganizationSponsorshipSyncStatusResponse } from "../admin-console/models/response/organization-sponsorship-sync-status.response";
|
||||
import { PreValidateSponsorshipResponse } from "../admin-console/models/response/pre-validate-sponsorship.response";
|
||||
import {
|
||||
ProviderOrganizationOrganizationDetailsResponse,
|
||||
ProviderOrganizationResponse,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
ProviderUserUserDetailsResponse,
|
||||
} from "../admin-console/models/response/provider/provider-user.response";
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
import { CreateAuthRequest } from "../auth/models/request/create-auth.request";
|
||||
import { AuthRequest } from "../auth/models/request/auth.request";
|
||||
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
|
||||
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
|
||||
@@ -185,8 +186,8 @@ export abstract class ApiService {
|
||||
putUpdateTdeOffboardingPassword: (request: UpdateTdeOffboardingPasswordRequest) => Promise<any>;
|
||||
postConvertToKeyConnector: () => Promise<void>;
|
||||
//passwordless
|
||||
postAuthRequest: (request: CreateAuthRequest) => Promise<AuthRequestResponse>;
|
||||
postAdminAuthRequest: (request: CreateAuthRequest) => Promise<AuthRequestResponse>;
|
||||
postAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
|
||||
postAdminAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
|
||||
getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
|
||||
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;
|
||||
@@ -490,7 +491,9 @@ export abstract class ApiService {
|
||||
) => Promise<OrganizationSponsorshipSyncStatusResponse>;
|
||||
deleteRevokeSponsorship: (sponsoringOrganizationId: string) => Promise<void>;
|
||||
deleteRemoveSponsorship: (sponsoringOrgId: string) => Promise<void>;
|
||||
postPreValidateSponsorshipToken: (sponsorshipToken: string) => Promise<boolean>;
|
||||
postPreValidateSponsorshipToken: (
|
||||
sponsorshipToken: string,
|
||||
) => Promise<PreValidateSponsorshipResponse>;
|
||||
postRedeemSponsorship: (
|
||||
sponsorshipToken: string,
|
||||
request: OrganizationSponsorshipRedeemRequest,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
export function canAccessVaultTab(org: Organization): boolean {
|
||||
return org.canViewAllCollections;
|
||||
}
|
||||
|
||||
export function canAccessSettingsTab(org: Organization): boolean {
|
||||
return (
|
||||
org.isOwner ||
|
||||
org.canManagePolicies ||
|
||||
org.canManageSso ||
|
||||
org.canManageScim ||
|
||||
org.canAccessImportExport ||
|
||||
org.canManageDeviceApprovals
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessMembersTab(org: Organization): boolean {
|
||||
return org.canManageUsers || org.canManageUsersPassword;
|
||||
}
|
||||
|
||||
export function canAccessGroupsTab(org: Organization): boolean {
|
||||
return org.canManageGroups;
|
||||
}
|
||||
|
||||
export function canAccessReportingTab(org: Organization): boolean {
|
||||
return org.canAccessReports || org.canAccessEventLogs;
|
||||
}
|
||||
|
||||
export function canAccessBillingTab(org: Organization): boolean {
|
||||
return org.isOwner;
|
||||
}
|
||||
|
||||
export function canAccessOrgAdmin(org: Organization): boolean {
|
||||
// Admin console can only be accessed by Owners for disabled organizations
|
||||
if (!org.enabled && !org.isOwner) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
canAccessMembersTab(org) ||
|
||||
canAccessGroupsTab(org) ||
|
||||
canAccessReportingTab(org) ||
|
||||
canAccessBillingTab(org) ||
|
||||
canAccessSettingsTab(org) ||
|
||||
canAccessVaultTab(org)
|
||||
);
|
||||
}
|
||||
|
||||
export function getOrganizationById(id: string) {
|
||||
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
|
||||
}
|
||||
|
||||
export function canAccessAdmin(i18nService: I18nService) {
|
||||
return map<Organization[], Organization[]>((orgs) =>
|
||||
orgs.filter(canAccessOrgAdmin).sort(Utils.getSortFunction(i18nService, "name")),
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessImport(i18nService: I18nService) {
|
||||
return map<Organization[], Organization[]>((orgs) =>
|
||||
orgs
|
||||
.filter((org) => org.canAccessImportExport || org.canCreateNewCollections)
|
||||
.sort(Utils.getSortFunction(i18nService, "name")),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an observable stream of organizations. This service is meant to
|
||||
* be used widely across Bitwarden as the primary way of fetching organizations.
|
||||
* Risky operations like updates are isolated to the
|
||||
* internal extension `InternalOrganizationServiceAbstraction`.
|
||||
*/
|
||||
export abstract class vNextOrganizationService {
|
||||
/**
|
||||
* Publishes state for all organizations under the specified user.
|
||||
* @returns An observable list of organizations
|
||||
*/
|
||||
organizations$: (userId: UserId) => Observable<Organization[]>;
|
||||
|
||||
// @todo Clean these up. Continuing to expand them is not recommended.
|
||||
// @see https://bitwarden.atlassian.net/browse/AC-2252
|
||||
memberOrganizations$: (userId: UserId) => Observable<Organization[]>;
|
||||
/**
|
||||
* Emits true if the user can create or manage a Free Bitwarden Families sponsorship.
|
||||
*/
|
||||
canManageSponsorships$: (userId: UserId) => Observable<boolean>;
|
||||
/**
|
||||
* Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available.
|
||||
*/
|
||||
familySponsorshipAvailable$: (userId: UserId) => Observable<boolean>;
|
||||
hasOrganizations: (userId: UserId) => Observable<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Big scary buttons that **update** organization state. These should only be
|
||||
* called from within admin-console scoped code. Extends the base
|
||||
* `OrganizationService` for easy access to `get` calls.
|
||||
* @internal
|
||||
*/
|
||||
export abstract class vNextInternalOrganizationServiceAbstraction extends vNextOrganizationService {
|
||||
/**
|
||||
* Replaces state for the provided organization, or creates it if not found.
|
||||
* @param organization The organization state being saved.
|
||||
* @param userId The userId to replace state for.
|
||||
*/
|
||||
upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Replaces state for the entire registered organization list for the specified user.
|
||||
* You probably don't want this unless you're calling from a full sync
|
||||
* operation or a logout. See `upsert` for creating & updating a single
|
||||
* organization in the state.
|
||||
* @param organizations A complete list of all organization state for the provided
|
||||
* user.
|
||||
* @param userId The userId to replace state for.
|
||||
*/
|
||||
replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise<void>;
|
||||
}
|
||||
@@ -12,4 +12,5 @@ export enum PolicyType {
|
||||
DisablePersonalVaultExport = 10, // Disable personal vault export
|
||||
ActivateAutofill = 11, // Activates autofill with page load on the browser extension
|
||||
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
|
||||
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
|
||||
}
|
||||
|
||||
@@ -283,9 +283,7 @@ export class Organization {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.hasProvider && this.providerType === ProviderType.Msp
|
||||
? this.isProviderUser
|
||||
: this.isOwner;
|
||||
return this.hasBillableProvider ? this.isProviderUser : this.isOwner;
|
||||
}
|
||||
|
||||
get canEditSubscription() {
|
||||
@@ -304,6 +302,14 @@ export class Organization {
|
||||
return this.providerId != null || this.providerName != null;
|
||||
}
|
||||
|
||||
get hasBillableProvider() {
|
||||
return (
|
||||
this.hasProvider &&
|
||||
(this.providerType === ProviderType.Msp ||
|
||||
this.providerType === ProviderType.MultiOrganizationEnterprise)
|
||||
);
|
||||
}
|
||||
|
||||
get hasReseller() {
|
||||
return this.hasProvider && this.providerType === ProviderType.Reseller;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class OrganizationSponsorshipResponse extends BaseResponse {
|
||||
isPolicyEnabled: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.isPolicyEnabled = this.getResponseProperty("IsPolicyEnabled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class PreValidateSponsorshipResponse extends BaseResponse {
|
||||
isTokenValid: boolean;
|
||||
isFreeFamilyPolicyEnabled: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.isTokenValid = this.getResponseProperty("IsTokenValid");
|
||||
this.isFreeFamilyPolicyEnabled = this.getResponseProperty("IsFreeFamilyPolicyEnabled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
import { DefaultvNextOrganizationService } from "./default-vnext-organization.service";
|
||||
import { ORGANIZATIONS } from "./vnext-organization.state";
|
||||
|
||||
describe("OrganizationService", () => {
|
||||
let organizationService: DefaultvNextOrganizationService;
|
||||
|
||||
const fakeUserId = Utils.newGuid() as UserId;
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
/**
|
||||
* It is easier to read arrays than records in code, but we store a record
|
||||
* in state. This helper methods lets us build organization arrays in tests
|
||||
* and easily map them to records before storing them in state.
|
||||
*/
|
||||
function arrayToRecord(input: OrganizationData[]): Record<OrganizationId, OrganizationData> {
|
||||
if (input == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(input?.map((i) => [i.id, i]));
|
||||
}
|
||||
|
||||
/**
|
||||
* There are a few assertions in this spec that check for array equality
|
||||
* but want to ignore a specific index that _should_ be different. This
|
||||
* function takes two arrays, and an index. It checks for equality of the
|
||||
* arrays, but splices out the specified index from both arrays first.
|
||||
*/
|
||||
function expectIsEqualExceptForIndex(x: any[], y: any[], indexToExclude: number) {
|
||||
// Clone the arrays to avoid modifying the reference values
|
||||
const a = [...x];
|
||||
const b = [...y];
|
||||
delete a[indexToExclude];
|
||||
delete b[indexToExclude];
|
||||
expect(a).toEqual(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a simple mock `OrganizationData[]` array that can be used in tests
|
||||
* to populate state.
|
||||
* @param count The number of organizations to populate the list with. The
|
||||
* function returns undefined if this is less than 1. The default value is 1.
|
||||
* @param suffix A string to append to data fields on each organization.
|
||||
* This defaults to the index of the organization in the list.
|
||||
* @returns an `OrganizationData[]` array that can be used to populate
|
||||
* stateProvider.
|
||||
*/
|
||||
function buildMockOrganizations(count = 1, suffix?: string): OrganizationData[] {
|
||||
if (count < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildMockOrganization(id: OrganizationId, name: string, identifier: string) {
|
||||
const data = new OrganizationData({} as any, {} as any);
|
||||
data.id = id;
|
||||
data.name = name;
|
||||
data.identifier = identifier;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
const mockOrganizations = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const s = suffix ? suffix + i.toString() : i.toString();
|
||||
mockOrganizations.push(
|
||||
buildMockOrganization(("org" + s) as OrganizationId, "org" + s, "orgIdentifier" + s),
|
||||
);
|
||||
}
|
||||
|
||||
return mockOrganizations;
|
||||
}
|
||||
|
||||
const setOrganizationsState = (organizationData: OrganizationData[] | null) =>
|
||||
fakeStateProvider.setUserState(
|
||||
ORGANIZATIONS,
|
||||
organizationData == null ? null : arrayToRecord(organizationData),
|
||||
fakeUserId,
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(fakeUserId));
|
||||
organizationService = new DefaultvNextOrganizationService(fakeStateProvider);
|
||||
});
|
||||
|
||||
describe("canManageSponsorships", () => {
|
||||
it("can because one is available", async () => {
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipAvailable = true;
|
||||
await setOrganizationsState(mockData);
|
||||
const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can because one is used", async () => {
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipFriendlyName = "Something";
|
||||
await setOrganizationsState(mockData);
|
||||
const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can not because one isn't available or taken", async () => {
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipFriendlyName = null;
|
||||
await setOrganizationsState(mockData);
|
||||
const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("organizations$", () => {
|
||||
describe("null checking behavior", () => {
|
||||
it("publishes an empty array if organizations in state = undefined", async () => {
|
||||
const mockData: OrganizationData[] = undefined;
|
||||
await setOrganizationsState(mockData);
|
||||
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes an empty array if organizations in state = null", async () => {
|
||||
const mockData: OrganizationData[] = null;
|
||||
await setOrganizationsState(mockData);
|
||||
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes an empty array if organizations in state = []", async () => {
|
||||
const mockData: OrganizationData[] = [];
|
||||
await setOrganizationsState(mockData);
|
||||
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns state for a user", async () => {
|
||||
const mockData = buildMockOrganizations(10);
|
||||
await setOrganizationsState(mockData);
|
||||
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsert()", () => {
|
||||
it("can create the organization list if necassary", async () => {
|
||||
// Notice that no default state is provided in this test, so the list in
|
||||
// `stateProvider` will be null when the `upsert` method is called.
|
||||
const mockData = buildMockOrganizations();
|
||||
await organizationService.upsert(mockData[0], fakeUserId);
|
||||
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||
expect(result).toEqual(mockData.map((x) => new Organization(x)));
|
||||
});
|
||||
|
||||
it("updates an organization that already exists in state", async () => {
|
||||
const mockData = buildMockOrganizations(10);
|
||||
await setOrganizationsState(mockData);
|
||||
const indexToUpdate = 5;
|
||||
const anUpdatedOrganization = {
|
||||
...buildMockOrganizations(1, "UPDATED").pop(),
|
||||
id: mockData[indexToUpdate].id,
|
||||
};
|
||||
await organizationService.upsert(anUpdatedOrganization, fakeUserId);
|
||||
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||
expect(result[indexToUpdate]).not.toEqual(new Organization(mockData[indexToUpdate]));
|
||||
expect(result[indexToUpdate].id).toEqual(new Organization(mockData[indexToUpdate]).id);
|
||||
expectIsEqualExceptForIndex(
|
||||
result,
|
||||
mockData.map((x) => new Organization(x)),
|
||||
indexToUpdate,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace()", () => {
|
||||
it("replaces the entire organization list in state", async () => {
|
||||
const originalData = buildMockOrganizations(10);
|
||||
await setOrganizationsState(originalData);
|
||||
|
||||
const newData = buildMockOrganizations(10, "newData");
|
||||
await organizationService.replace(arrayToRecord(newData), fakeUserId);
|
||||
|
||||
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||
|
||||
expect(result).toEqual(newData);
|
||||
expect(result).not.toEqual(originalData);
|
||||
});
|
||||
|
||||
// This is more or less a test for logouts
|
||||
it("can replace state with null", async () => {
|
||||
const originalData = buildMockOrganizations(2);
|
||||
await setOrganizationsState(originalData);
|
||||
await organizationService.replace(null, fakeUserId);
|
||||
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||
expect(result).toEqual([]);
|
||||
expect(result).not.toEqual(originalData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { vNextInternalOrganizationServiceAbstraction } from "../../abstractions/organization/vnext.organization.service";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
import { ORGANIZATIONS } from "./vnext-organization.state";
|
||||
|
||||
/**
|
||||
* Filter out organizations from an observable that __do not__ offer a
|
||||
* families-for-enterprise sponsorship to members.
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() {
|
||||
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.canManageSponsorships));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out organizations from an observable that the organization user
|
||||
* __is not__ a direct member of. This will exclude organizations only
|
||||
* accessible as a provider.
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToExcludeProviderOrganizations() {
|
||||
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.isMember));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an observable stream of organizations down to a boolean indicating
|
||||
* if any organizations exist (`orgs.length > 0`).
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToBooleanHasAnyOrganizations() {
|
||||
return map<Organization[], boolean>((orgs) => orgs.length > 0);
|
||||
}
|
||||
|
||||
export class DefaultvNextOrganizationService
|
||||
implements vNextInternalOrganizationServiceAbstraction
|
||||
{
|
||||
memberOrganizations$(userId: UserId): Observable<Organization[]> {
|
||||
return this.organizations$(userId).pipe(mapToExcludeProviderOrganizations());
|
||||
}
|
||||
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
canManageSponsorships$(userId: UserId) {
|
||||
return this.organizations$(userId).pipe(
|
||||
mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(),
|
||||
mapToBooleanHasAnyOrganizations(),
|
||||
);
|
||||
}
|
||||
|
||||
familySponsorshipAvailable$(userId: UserId) {
|
||||
return this.organizations$(userId).pipe(
|
||||
map((orgs) => orgs.some((o) => o.familySponsorshipAvailable)),
|
||||
);
|
||||
}
|
||||
|
||||
hasOrganizations(userId: UserId): Observable<boolean> {
|
||||
return this.organizations$(userId).pipe(mapToBooleanHasAnyOrganizations());
|
||||
}
|
||||
|
||||
async upsert(organization: OrganizationData, userId: UserId): Promise<void> {
|
||||
await this.organizationState(userId).update((existingOrganizations) => {
|
||||
const organizations = existingOrganizations ?? {};
|
||||
organizations[organization.id] = organization;
|
||||
return organizations;
|
||||
});
|
||||
}
|
||||
|
||||
async replace(organizations: { [id: string]: OrganizationData }, userId: UserId): Promise<void> {
|
||||
await this.organizationState(userId).update(() => organizations);
|
||||
}
|
||||
|
||||
organizations$(userId: UserId): Observable<Organization[] | undefined> {
|
||||
return this.organizationState(userId).state$.pipe(this.mapOrganizationRecordToArray());
|
||||
}
|
||||
|
||||
private organizationState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, ORGANIZATIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a record of `OrganizationData`, which is how we store the
|
||||
* organization list as a JSON object on disk, to an array of
|
||||
* `Organization`, which is how the data is published to callers of the
|
||||
* service.
|
||||
* @returns a function that can be used to pipe organization data from
|
||||
* stored state to an exposed object easily consumable by others.
|
||||
*/
|
||||
private mapOrganizationRecordToArray() {
|
||||
return map<Record<string, OrganizationData>, Organization[]>((orgs) =>
|
||||
Object.values(orgs ?? {})?.map((o) => new Organization(o)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ORGANIZATIONS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
|
||||
/**
|
||||
* The `KeyDefinition` for accessing organization lists in application state.
|
||||
* @todo Ideally this wouldn't require a `fromJSON()` call, but `OrganizationData`
|
||||
* has some properties that contain functions. This should probably get
|
||||
* cleaned up.
|
||||
*/
|
||||
export const ORGANIZATIONS = UserKeyDefinition.record<OrganizationData>(
|
||||
ORGANIZATIONS_DISK,
|
||||
"organizations",
|
||||
{
|
||||
deserializer: (obj: Jsonify<OrganizationData>) => OrganizationData.fromJSON(obj),
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuthRequestType } from "../../enums/auth-request-type";
|
||||
|
||||
export class CreateAuthRequest {
|
||||
export class AuthRequest {
|
||||
constructor(
|
||||
readonly email: string,
|
||||
readonly deviceIdentifier: string,
|
||||
@@ -8,8 +8,8 @@ export class AuthRequestResponse extends BaseResponse {
|
||||
publicKey: string;
|
||||
requestDeviceType: DeviceType;
|
||||
requestIpAddress: string;
|
||||
key: string;
|
||||
masterPasswordHash: string;
|
||||
key: string; // could be either an encrypted MasterKey or an encrypted UserKey
|
||||
masterPasswordHash: string; // if hash is present, the `key` above is an encrypted MasterKey (else `key` is an encrypted UserKey)
|
||||
creationDate: string;
|
||||
requestApproved?: boolean;
|
||||
responseDate?: string;
|
||||
|
||||
@@ -14,14 +14,12 @@ export enum FeatureFlag {
|
||||
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
|
||||
EmailVerification = "email-verification",
|
||||
InlineMenuFieldQualification = "inline-menu-field-qualification",
|
||||
MemberAccessReport = "ac-2059-member-access-report",
|
||||
TwoFactorComponentRefactor = "two-factor-component-refactor",
|
||||
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
|
||||
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
IdpAutoSubmitLogin = "idp-auto-submit-login",
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub",
|
||||
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
|
||||
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
|
||||
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
|
||||
@@ -41,6 +39,7 @@ export enum FeatureFlag {
|
||||
SecurityTasks = "security-tasks",
|
||||
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
|
||||
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
|
||||
DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -65,14 +64,12 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||
[FeatureFlag.EmailVerification]: FALSE,
|
||||
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
|
||||
[FeatureFlag.MemberAccessReport]: FALSE,
|
||||
[FeatureFlag.TwoFactorComponentRefactor]: FALSE,
|
||||
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
|
||||
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||
[FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE,
|
||||
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
|
||||
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
||||
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
|
||||
@@ -92,6 +89,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
|
||||
[FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -10,6 +10,11 @@ export abstract class SdkService {
|
||||
*/
|
||||
supported$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Retrieve the version of the SDK.
|
||||
*/
|
||||
version$: Observable<string>;
|
||||
|
||||
/**
|
||||
* Retrieve a client initialized without a user.
|
||||
* This client can only be used for operations that don't require a user context.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// required to avoid linting errors when there are no flags
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type SharedFlags = {
|
||||
showPasswordless?: boolean;
|
||||
sdk?: boolean;
|
||||
prereleaseBuild?: boolean;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
distinctUntilChanged,
|
||||
tap,
|
||||
switchMap,
|
||||
catchError,
|
||||
} from "rxjs";
|
||||
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -51,6 +52,11 @@ export class DefaultSdkService implements SdkService {
|
||||
}),
|
||||
);
|
||||
|
||||
version$ = this.client$.pipe(
|
||||
map((client) => client.version()),
|
||||
catchError(() => "Unsupported"),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private sdkClientFactory: SdkClientFactory,
|
||||
private environmentService: EnvironmentService,
|
||||
|
||||
@@ -173,3 +173,7 @@ export const PREMIUM_BANNER_DISK_LOCAL = new StateDefinition("premiumBannerRepro
|
||||
});
|
||||
export const BANNERS_DISMISSED_DISK = new StateDefinition("bannersDismissed", "disk");
|
||||
export const VAULT_BROWSER_UI_ONBOARDING = new StateDefinition("vaultBrowserUiOnboarding", "disk");
|
||||
export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition(
|
||||
"newDeviceVerificationNotice",
|
||||
"disk",
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "../admin-console/models/response/organization-connection.response";
|
||||
import { OrganizationExportResponse } from "../admin-console/models/response/organization-export.response";
|
||||
import { OrganizationSponsorshipSyncStatusResponse } from "../admin-console/models/response/organization-sponsorship-sync-status.response";
|
||||
import { PreValidateSponsorshipResponse } from "../admin-console/models/response/pre-validate-sponsorship.response";
|
||||
import {
|
||||
ProviderOrganizationOrganizationDetailsResponse,
|
||||
ProviderOrganizationResponse,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
} from "../admin-console/models/response/provider/provider-user.response";
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
import { TokenService } from "../auth/abstractions/token.service";
|
||||
import { CreateAuthRequest } from "../auth/models/request/create-auth.request";
|
||||
import { AuthRequest } from "../auth/models/request/auth.request";
|
||||
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
|
||||
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
|
||||
@@ -259,11 +260,12 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
|
||||
// TODO: PM-3519: Create and move to AuthRequest Api service
|
||||
async postAuthRequest(request: CreateAuthRequest): Promise<AuthRequestResponse> {
|
||||
// TODO: PM-9724: Remove legacy auth request methods when we remove legacy LoginViaAuthRequestV1Components
|
||||
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
|
||||
const r = await this.send("POST", "/auth-requests/", request, false, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
async postAdminAuthRequest(request: CreateAuthRequest): Promise<AuthRequestResponse> {
|
||||
async postAdminAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
|
||||
const r = await this.send("POST", "/auth-requests/admin-request", request, true, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
@@ -1680,8 +1682,10 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
);
|
||||
}
|
||||
|
||||
async postPreValidateSponsorshipToken(sponsorshipToken: string): Promise<boolean> {
|
||||
const r = await this.send(
|
||||
async postPreValidateSponsorshipToken(
|
||||
sponsorshipToken: string,
|
||||
): Promise<PreValidateSponsorshipResponse> {
|
||||
const response = await this.send(
|
||||
"POST",
|
||||
"/organization/sponsorship/validate-token?sponsorshipToken=" +
|
||||
encodeURIComponent(sponsorshipToken),
|
||||
@@ -1689,7 +1693,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return r as boolean;
|
||||
|
||||
return new PreValidateSponsorshipResponse(response);
|
||||
}
|
||||
|
||||
async postRedeemSponsorship(
|
||||
|
||||
@@ -28,6 +28,11 @@ type NumberConstraints = {
|
||||
/** maximum number value. When absent, min value is unbounded. */
|
||||
max?: number;
|
||||
|
||||
/** recommended value. This is the value bitwarden recommends
|
||||
* to the user as an appropriate value.
|
||||
*/
|
||||
recommendation?: number;
|
||||
|
||||
/** requires the number be a multiple of the step value;
|
||||
* this field must be a positive number. +0 and Infinity are
|
||||
* prohibited. When absent, any number is accepted.
|
||||
|
||||
@@ -17,6 +17,10 @@ export class SshKeyView extends ItemView {
|
||||
}
|
||||
|
||||
get maskedPrivateKey(): string {
|
||||
if (!this.privateKey || this.privateKey.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let lines = this.privateKey.split("\n").filter((l) => l.trim() !== "");
|
||||
lines = lines.map((l, i) => {
|
||||
if (i === 0 || i === lines.length - 1) {
|
||||
|
||||
@@ -116,7 +116,7 @@ export class AvatarComponent implements OnChanges {
|
||||
textTag.setAttribute("fill", Utils.pickTextColorBasedOnBgColor(color, 135, true));
|
||||
textTag.setAttribute(
|
||||
"font-family",
|
||||
'"Open Sans","Helvetica Neue",Helvetica,Arial,' +
|
||||
'"DM Sans","Helvetica Neue",Helvetica,Arial,' +
|
||||
'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"',
|
||||
);
|
||||
// Warning do not use innerHTML here, characters are user provided
|
||||
|
||||
@@ -5,21 +5,25 @@ import { FocusableElement } from "../shared/focusable-element";
|
||||
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
|
||||
const styles: Record<BadgeVariant, string[]> = {
|
||||
primary: ["tw-bg-primary-600"],
|
||||
secondary: ["tw-bg-text-muted"],
|
||||
success: ["tw-bg-success-600"],
|
||||
danger: ["tw-bg-danger-600"],
|
||||
warning: ["tw-bg-warning-600"],
|
||||
info: ["tw-bg-info-600"],
|
||||
primary: ["tw-bg-primary-100", "tw-border-primary-700", "!tw-text-primary-700"],
|
||||
secondary: ["tw-bg-secondary-100", "tw-border-secondary-700", "!tw-text-secondary-700"],
|
||||
success: ["tw-bg-success-100", "tw-border-success-700", "!tw-text-success-700"],
|
||||
danger: ["tw-bg-danger-100", "tw-border-danger-700", "!tw-text-danger-700"],
|
||||
warning: ["tw-bg-warning-100", "tw-border-warning-700", "!tw-text-warning-700"],
|
||||
info: ["tw-bg-info-100", "tw-border-info-700", "!tw-text-info-700"],
|
||||
};
|
||||
|
||||
const hoverStyles: Record<BadgeVariant, string[]> = {
|
||||
primary: ["hover:tw-bg-primary-700"],
|
||||
secondary: ["hover:tw-bg-secondary-700"],
|
||||
success: ["hover:tw-bg-success-700"],
|
||||
danger: ["hover:tw-bg-danger-700"],
|
||||
warning: ["hover:tw-bg-warning-700"],
|
||||
info: ["hover:tw-bg-info-700"],
|
||||
primary: ["hover:tw-bg-primary-600", "hover:tw-border-primary-600", "hover:!tw-text-contrast"],
|
||||
secondary: [
|
||||
"hover:tw-bg-secondary-600",
|
||||
"hover:tw-border-secondary-600",
|
||||
"hover:!tw-text-contrast",
|
||||
],
|
||||
success: ["hover:tw-bg-success-600", "hover:tw-border-success-600", "hover:!tw-text-contrast"],
|
||||
danger: ["hover:tw-bg-danger-600", "hover:tw-border-danger-600", "hover:!tw-text-contrast"],
|
||||
warning: ["hover:tw-bg-warning-600", "hover:tw-border-warning-600", "hover:!tw-text-black"],
|
||||
info: ["hover:tw-bg-info-600", "hover:tw-border-info-600", "hover:!tw-text-black"],
|
||||
};
|
||||
|
||||
@Directive({
|
||||
@@ -30,22 +34,29 @@ export class BadgeDirective implements FocusableElement {
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-inline-block",
|
||||
"tw-py-0.5",
|
||||
"tw-px-1.5",
|
||||
"tw-font-bold",
|
||||
"tw-py-1",
|
||||
"tw-px-2",
|
||||
"tw-font-medium",
|
||||
"tw-text-center",
|
||||
"tw-align-text-top",
|
||||
"!tw-text-contrast",
|
||||
"tw-rounded",
|
||||
"tw-border-none",
|
||||
"tw-rounded-full",
|
||||
"tw-border-[0.5px]",
|
||||
"tw-border-solid",
|
||||
"tw-box-border",
|
||||
"tw-whitespace-nowrap",
|
||||
"tw-text-xs",
|
||||
"hover:tw-no-underline",
|
||||
"focus:tw-outline-none",
|
||||
"focus:tw-ring",
|
||||
"focus:tw-ring-offset-2",
|
||||
"focus:tw-ring-primary-700",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:tw-ring-2",
|
||||
"focus-visible:tw-ring-offset-2",
|
||||
"focus-visible:tw-ring-primary-600",
|
||||
"disabled:tw-bg-secondary-300",
|
||||
"disabled:hover:tw-bg-secondary-300",
|
||||
"disabled:tw-border-secondary-300",
|
||||
"disabled:hover:tw-border-secondary-300",
|
||||
"disabled:!tw-text-muted",
|
||||
"disabled:hover:!tw-text-muted",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
]
|
||||
.concat(styles[this.variant])
|
||||
.concat(this.hasHoverEffects ? hoverStyles[this.variant] : [])
|
||||
|
||||
@@ -10,12 +10,17 @@ import { BadgeModule } from "@bitwarden/components";
|
||||
|
||||
# Badge
|
||||
|
||||
The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
|
||||
for interactive events. The Focus and Hover states only apply to badges used for interactive events.
|
||||
Badges are primarily used as labels, counters, and small buttons.
|
||||
|
||||
Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the
|
||||
component configurations may be reviewed and adjusted.
|
||||
|
||||
The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
|
||||
for interactive events. The Focus and Hover states only apply to badges used for interactive events.
|
||||
The `disabled` state only applies to buttons.
|
||||
|
||||
The story below uses the `<button>` element to demonstrate all the possible states.
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
|
||||
@@ -26,10 +26,49 @@ export default {
|
||||
|
||||
type Story = StoryObj<BadgeDirective>;
|
||||
|
||||
export const Variants: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<span class="tw-text-main tw-mx-1">Default</span>
|
||||
<button class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Hover</span>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Focus Visible</span>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Disabled</span>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<span class="tw-text-main">Span </span><span bitBadge [variant]="variant" [truncate]="truncate">Badge containing lengthy text</span>
|
||||
<br /><br />
|
||||
<span class="tw-text-main">Link </span><a href="#" bitBadge [variant]="variant" [truncate]="truncate">Badge</a>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-pl-4 tw-text-contrast tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
|
||||
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-pl-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
|
||||
[ngClass]="bannerClass"
|
||||
[attr.role]="useAlertRole ? 'status' : null"
|
||||
[attr.aria-live]="useAlertRole ? 'polite' : null"
|
||||
>
|
||||
<i class="bwi tw-align-middle tw-text-base" [ngClass]="icon" *ngIf="icon" aria-hidden="true"></i>
|
||||
<span class="tw-grow tw-text-base">
|
||||
<!-- Overriding focus-visible color for link buttons for a11y against colored background -->
|
||||
<span class="tw-grow tw-text-base [&>button[bitlink]:focus-visible:before]:!tw-ring-text-main">
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
<!-- Overriding hover and focus-visible colors for a11y against colored background -->
|
||||
<button
|
||||
*ngIf="showClose"
|
||||
class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main"
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="contrast"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
(click)="onClose.emit()"
|
||||
[attr.title]="'close' | i18n"
|
||||
|
||||
@@ -28,13 +28,13 @@ export class BannerComponent implements OnInit {
|
||||
get bannerClass() {
|
||||
switch (this.bannerType) {
|
||||
case "danger":
|
||||
return "tw-bg-danger-600";
|
||||
return "tw-bg-danger-100 tw-border-b-danger-700";
|
||||
case "info":
|
||||
return "tw-bg-info-600";
|
||||
return "tw-bg-info-100 tw-border-b-info-700";
|
||||
case "premium":
|
||||
return "tw-bg-success-600";
|
||||
return "tw-bg-success-100 tw-border-b-success-700";
|
||||
case "warning":
|
||||
return "tw-bg-warning-600";
|
||||
return "tw-bg-warning-100 tw-border-b-warning-700";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ persist across all pages a user navigates to.
|
||||
- Use banners sparingly, as they can feel intrusive to the user if they appear unexpectedly. Their
|
||||
effectiveness may decrease if too many are used.
|
||||
- Avoid stacking multiple banners.
|
||||
- Banners support a button link (text button).
|
||||
- Banners can contain a button or anchor that uses the `bitLink` directive with
|
||||
`linkType="secondary"`.
|
||||
|
||||
<Primary />
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Premium: Story = {
|
||||
template: `
|
||||
<bit-banner [bannerType]="bannerType" (onClose)="onClose($event)" [showClose]=showClose>
|
||||
Content Really Long Text Lorem Ipsum Ipsum Ipsum
|
||||
<button bitLink linkType="contrast">Button</button>
|
||||
<button bitLink linkType="secondary">Button</button>
|
||||
</bit-banner>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -27,57 +27,6 @@ describe("Button", () => {
|
||||
linkDebugElement = fixture.debugElement.query(By.css("a"));
|
||||
}));
|
||||
|
||||
it("should apply classes based on type", () => {
|
||||
testAppComponent.buttonType = "primary";
|
||||
fixture.detectChanges();
|
||||
expect(buttonDebugElement.nativeElement.classList.contains("tw-bg-primary-600")).toBe(true);
|
||||
expect(linkDebugElement.nativeElement.classList.contains("tw-bg-primary-600")).toBe(true);
|
||||
|
||||
testAppComponent.buttonType = "secondary";
|
||||
fixture.detectChanges();
|
||||
expect(buttonDebugElement.nativeElement.classList.contains("tw-border-text-muted")).toBe(true);
|
||||
expect(linkDebugElement.nativeElement.classList.contains("tw-border-text-muted")).toBe(true);
|
||||
|
||||
testAppComponent.buttonType = "danger";
|
||||
fixture.detectChanges();
|
||||
expect(buttonDebugElement.nativeElement.classList.contains("tw-border-danger-600")).toBe(true);
|
||||
expect(linkDebugElement.nativeElement.classList.contains("tw-border-danger-600")).toBe(true);
|
||||
|
||||
testAppComponent.buttonType = "unstyled";
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
Array.from(buttonDebugElement.nativeElement.classList).some((klass: string) =>
|
||||
klass.startsWith("tw-bg"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Array.from(linkDebugElement.nativeElement.classList).some((klass: string) =>
|
||||
klass.startsWith("tw-bg"),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
testAppComponent.buttonType = null;
|
||||
fixture.detectChanges();
|
||||
expect(buttonDebugElement.nativeElement.classList.contains("tw-border-text-muted")).toBe(true);
|
||||
expect(linkDebugElement.nativeElement.classList.contains("tw-border-text-muted")).toBe(true);
|
||||
});
|
||||
|
||||
it("should apply block when true and inline-block when false", () => {
|
||||
testAppComponent.block = true;
|
||||
fixture.detectChanges();
|
||||
expect(buttonDebugElement.nativeElement.classList.contains("tw-block")).toBe(true);
|
||||
expect(linkDebugElement.nativeElement.classList.contains("tw-block")).toBe(true);
|
||||
expect(buttonDebugElement.nativeElement.classList.contains("tw-inline-block")).toBe(false);
|
||||
expect(linkDebugElement.nativeElement.classList.contains("tw-inline-block")).toBe(false);
|
||||
|
||||
testAppComponent.block = false;
|
||||
fixture.detectChanges();
|
||||
expect(buttonDebugElement.nativeElement.classList.contains("tw-inline-block")).toBe(true);
|
||||
expect(linkDebugElement.nativeElement.classList.contains("tw-inline-block")).toBe(true);
|
||||
expect(buttonDebugElement.nativeElement.classList.contains("tw-block")).toBe(false);
|
||||
expect(linkDebugElement.nativeElement.classList.contains("tw-block")).toBe(false);
|
||||
});
|
||||
|
||||
it("should not be disabled when loading and disabled are false", () => {
|
||||
testAppComponent.loading = false;
|
||||
testAppComponent.disabled = false;
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Input, HostBinding, Component } from "@angular/core";
|
||||
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
||||
|
||||
const focusRing = [
|
||||
"focus-visible:tw-ring",
|
||||
"focus-visible:tw-ring-2",
|
||||
"focus-visible:tw-ring-offset-2",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
"focus-visible:tw-ring-primary-600",
|
||||
"focus-visible:tw-z-10",
|
||||
];
|
||||
|
||||
@@ -17,24 +17,15 @@ const buttonStyles: Record<ButtonType, string[]> = {
|
||||
"!tw-text-contrast",
|
||||
"hover:tw-bg-primary-700",
|
||||
"hover:tw-border-primary-700",
|
||||
"disabled:tw-bg-primary-600/60",
|
||||
"disabled:tw-border-primary-600/60",
|
||||
"disabled:!tw-text-contrast/60",
|
||||
"disabled:tw-bg-clip-padding",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
...focusRing,
|
||||
],
|
||||
secondary: [
|
||||
"tw-bg-transparent",
|
||||
"tw-border-text-muted",
|
||||
"!tw-text-muted",
|
||||
"hover:tw-bg-text-muted",
|
||||
"hover:tw-border-text-muted",
|
||||
"tw-border-primary-600",
|
||||
"!tw-text-primary-600",
|
||||
"hover:tw-bg-primary-600",
|
||||
"hover:tw-border-primary-600",
|
||||
"hover:!tw-text-contrast",
|
||||
"disabled:tw-bg-transparent",
|
||||
"disabled:tw-border-text-muted/60",
|
||||
"disabled:!tw-text-muted/60",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
...focusRing,
|
||||
],
|
||||
danger: [
|
||||
@@ -44,10 +35,6 @@ const buttonStyles: Record<ButtonType, string[]> = {
|
||||
"hover:tw-bg-danger-600",
|
||||
"hover:tw-border-danger-600",
|
||||
"hover:!tw-text-contrast",
|
||||
"disabled:tw-bg-transparent",
|
||||
"disabled:tw-border-danger-600/60",
|
||||
"disabled:!tw-text-danger/60",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
...focusRing,
|
||||
],
|
||||
unstyled: [],
|
||||
@@ -64,14 +51,22 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
"tw-font-semibold",
|
||||
"tw-py-1.5",
|
||||
"tw-px-3",
|
||||
"tw-rounded",
|
||||
"tw-rounded-full",
|
||||
"tw-transition",
|
||||
"tw-border",
|
||||
"tw-border-2",
|
||||
"tw-border-solid",
|
||||
"tw-text-center",
|
||||
"tw-no-underline",
|
||||
"hover:tw-no-underline",
|
||||
"focus:tw-outline-none",
|
||||
"disabled:tw-bg-secondary-300",
|
||||
"disabled:hover:tw-bg-secondary-300",
|
||||
"disabled:tw-border-secondary-300",
|
||||
"disabled:hover:tw-border-secondary-300",
|
||||
"disabled:!tw-text-muted",
|
||||
"disabled:hover:!tw-text-muted",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
"disabled:hover:tw-no-underline",
|
||||
]
|
||||
.concat(this.block ? ["tw-w-full", "tw-block"] : ["tw-inline-block"])
|
||||
.concat(buttonStyles[this.buttonType ?? "secondary"]);
|
||||
@@ -99,8 +94,4 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
@Input() loading = false;
|
||||
|
||||
@Input() disabled = false;
|
||||
|
||||
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
|
||||
this.buttonType = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +64,6 @@ Use the danger styling only in settings when the user may preform a permanent ac
|
||||
|
||||
## Disabled UI
|
||||
|
||||
Both the disabled and loading states use the default state’s color with a 60% opacity or
|
||||
`tw-opacity-60`.
|
||||
|
||||
<Story of={stories.Disabled} />
|
||||
|
||||
## Block
|
||||
|
||||
@@ -23,9 +23,21 @@ type Story = StoryObj<ButtonComponent>;
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-gap-4 tw-mb-6">
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block">Button</button>
|
||||
<a bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" href="#" class="tw-ml-2">Link</a>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-hover">Button:hover</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-active">Button:active</button>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4">
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block">Anchor</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-hover">Anchor:hover</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-focus-visible">Anchor:focus-visible</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-hover tw-test-focus-visible">Anchor:hover:focus-visible</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-active">Anchor:active</a>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<aside
|
||||
class="tw-mb-4 tw-box-border tw-rounded tw-border tw-border-l-8 tw-border-solid tw-border-secondary-300 tw-bg-background-alt tw-px-5 tw-py-3 tw-leading-5 tw-text-main"
|
||||
class="tw-mb-4 tw-box-border tw-rounded-lg tw-border tw-border-l-4 tw-border-solid tw-bg-background tw-pl-3 tw-pr-2 tw-py-2 tw-leading-5 tw-text-main"
|
||||
[ngClass]="calloutClass"
|
||||
[attr.aria-labelledby]="titleId"
|
||||
>
|
||||
<header
|
||||
id="{{ titleId }}"
|
||||
class="tw-mb-2 tw-mt-0 tw-text-base tw-font-bold tw-uppercase"
|
||||
[ngClass]="headerClass"
|
||||
*ngIf="title"
|
||||
>
|
||||
<i class="bwi {{ icon }}" *ngIf="icon" aria-hidden="true"></i>
|
||||
<header id="{{ titleId }}" class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold" *ngIf="title">
|
||||
<i class="bwi" [ngClass]="[icon, headerClass]" *ngIf="icon" aria-hidden="true"></i>
|
||||
{{ title }}
|
||||
</header>
|
||||
<ng-content></ng-content>
|
||||
<div bitTypography="body2">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("Callout", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [CalloutComponent],
|
||||
imports: [CalloutComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Component, Input, OnInit } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { SharedModule } from "../shared";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
export type CalloutTypes = "success" | "info" | "warning" | "danger";
|
||||
|
||||
const defaultIcon: Record<CalloutTypes, string> = {
|
||||
@@ -22,6 +25,8 @@ let nextId = 0;
|
||||
@Component({
|
||||
selector: "bit-callout",
|
||||
templateUrl: "callout.component.html",
|
||||
standalone: true,
|
||||
imports: [SharedModule, TypographyModule],
|
||||
})
|
||||
export class CalloutComponent implements OnInit {
|
||||
@Input() type: CalloutTypes = "info";
|
||||
@@ -42,13 +47,13 @@ export class CalloutComponent implements OnInit {
|
||||
get calloutClass() {
|
||||
switch (this.type) {
|
||||
case "danger":
|
||||
return "tw-border-l-danger-600";
|
||||
return "tw-border-danger-600";
|
||||
case "info":
|
||||
return "tw-border-l-info-600";
|
||||
return "tw-border-info-600";
|
||||
case "success":
|
||||
return "tw-border-l-success-600";
|
||||
return "tw-border-success-600";
|
||||
case "warning":
|
||||
return "tw-border-l-warning-600";
|
||||
return "tw-border-warning-600";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./callout.stories";
|
||||
|
||||
```ts
|
||||
import { CalloutModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
# Callouts
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { CalloutComponent } from "./callout.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule],
|
||||
imports: [CalloutComponent],
|
||||
exports: [CalloutComponent],
|
||||
declarations: [CalloutComponent],
|
||||
})
|
||||
export class CalloutModule {}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class:
|
||||
"tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 [&:not(bit-layout_*)]:tw-rounded-lg tw-py-4 tw-px-3",
|
||||
"tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 [&:not(bit-layout_*)]:tw-rounded-lg [&:not(bit-layout_*)]:tw-border-b-shadow tw-py-4 bit-compact:tw-py-3 tw-px-3 bit-compact:tw-px-2",
|
||||
},
|
||||
})
|
||||
export class CardComponent {}
|
||||
|
||||
@@ -17,12 +17,13 @@ export class CheckboxComponent implements BitFormControlAbstraction {
|
||||
"tw-transition",
|
||||
"tw-cursor-pointer",
|
||||
"tw-inline-block",
|
||||
"tw-align-sub",
|
||||
"tw-rounded",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-border-secondary-600",
|
||||
"tw-h-3.5",
|
||||
"tw-w-3.5",
|
||||
"tw-border-secondary-500",
|
||||
"tw-h-5",
|
||||
"tw-w-5",
|
||||
"tw-mr-1.5",
|
||||
"tw-bottom-[-1px]", // Fix checkbox looking off-center
|
||||
"tw-flex-none", // Flexbox fix for bit-form-control
|
||||
@@ -35,13 +36,16 @@ export class CheckboxComponent implements BitFormControlAbstraction {
|
||||
"hover:tw-border-2",
|
||||
"[&>label]:tw-border-2",
|
||||
|
||||
"focus-visible:tw-ring-2",
|
||||
"focus-visible:tw-ring-offset-2",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
// if it exists, the parent form control handles focus
|
||||
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-2",
|
||||
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-offset-2",
|
||||
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-primary-600",
|
||||
|
||||
"disabled:tw-cursor-auto",
|
||||
"disabled:tw-border",
|
||||
"disabled:hover:tw-border",
|
||||
"disabled:tw-bg-secondary-100",
|
||||
"disabled:hover:tw-bg-secondary-100",
|
||||
|
||||
"checked:tw-bg-primary-600",
|
||||
"checked:tw-border-primary-600",
|
||||
@@ -53,6 +57,7 @@ export class CheckboxComponent implements BitFormControlAbstraction {
|
||||
"checked:before:tw-mask-position-[center]",
|
||||
"checked:before:tw-mask-repeat-[no-repeat]",
|
||||
"checked:disabled:tw-border-secondary-100",
|
||||
"checked:disabled:hover:tw-border-secondary-100",
|
||||
"checked:disabled:tw-bg-secondary-100",
|
||||
"checked:disabled:before:tw-bg-text-muted",
|
||||
|
||||
@@ -78,11 +83,11 @@ export class CheckboxComponent implements BitFormControlAbstraction {
|
||||
|
||||
@HostBinding("style.--mask-image")
|
||||
protected maskImage =
|
||||
`url('data:image/svg+xml,%3Csvg class="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8" viewBox="0 0 10 10"%3E%3Cpath d="M0.5 6.2L2.9 8.6L9.5 1.4" fill="none" stroke="white" stroke-width="2"%3E%3C/path%3E%3C/svg%3E')`;
|
||||
`url('data:image/svg+xml,%3Csvg class="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12" viewBox="0 0 10 10"%3E%3Cpath d="M0.5 6.2L2.9 8.6L9.5 1.4" fill="none" stroke="white" stroke-width="2"%3E%3C/path%3E%3C/svg%3E')`;
|
||||
|
||||
@HostBinding("style.--indeterminate-mask-image")
|
||||
protected indeterminateImage =
|
||||
`url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`;
|
||||
`url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`;
|
||||
|
||||
@HostBinding()
|
||||
@Input()
|
||||
|
||||
@@ -11,12 +11,14 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/src/platform/abstractions/i18n.service";
|
||||
|
||||
import { BadgeModule } from "../badge";
|
||||
import { FormControlModule } from "../form-control";
|
||||
import { TableModule } from "../table";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { CheckboxModule } from "./checkbox.module";
|
||||
|
||||
const template = `
|
||||
const template = /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox" />
|
||||
@@ -54,7 +56,14 @@ export default {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [ExampleComponent],
|
||||
imports: [FormsModule, ReactiveFormsModule, FormControlModule, CheckboxModule],
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormControlModule,
|
||||
CheckboxModule,
|
||||
TableModule,
|
||||
BadgeModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
@@ -82,7 +91,10 @@ type Story = StoryObj<ExampleComponent>;
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-example [checked]="checked" [disabled]="disabled"></app-example>`,
|
||||
template: /*html*/ `
|
||||
<app-example></app-example>
|
||||
<app-example [checked]="true"></app-example>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
@@ -91,9 +103,39 @@ export const Default: Story = {
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export const LongLabel: Story = {
|
||||
render: () => ({
|
||||
props: {
|
||||
formObj: new FormGroup({
|
||||
checkbox: new FormControl(false),
|
||||
}),
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj" class="tw-w-96">
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox">
|
||||
<bit-label>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur iaculis consequat enim vitae elementum.
|
||||
Ut non odio est. </bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox">
|
||||
<bit-label>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur iaculis consequat enim vitae elementum.
|
||||
Ut non odio est.
|
||||
<span slot="end" bitBadge variant="success">Premium</span>
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
code: template,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -104,7 +146,7 @@ export const Hint: Story = {
|
||||
checkbox: new FormControl(false),
|
||||
}),
|
||||
},
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox" />
|
||||
@@ -131,20 +173,37 @@ export const Hint: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<app-example [disabled]="true"></app-example>
|
||||
<app-example [checked]="true" [disabled]="true"></app-example>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
code: template,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Custom: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-flex-col tw-w-32">
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
A-Z
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
a-z
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2 tw-items-baseline">
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
0-9
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
@@ -156,8 +215,51 @@ export const Custom: Story = {
|
||||
export const Indeterminate: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<input type="checkbox" bitCheckbox [indeterminate]="true">
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const InTableRow: Story = {
|
||||
render: () => ({
|
||||
template: /*html*/ `
|
||||
<bit-table>
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
id="checkAll"
|
||||
class="tw-mr-2"
|
||||
/>
|
||||
<label for="checkAll" class="tw-mb-0">
|
||||
All
|
||||
</label>
|
||||
</th>
|
||||
<th bitCell>
|
||||
Foo
|
||||
</th>
|
||||
<th bitCell>
|
||||
Bar
|
||||
</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body>
|
||||
<tr bitRow>
|
||||
<td bitCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
id="checkOne"
|
||||
/>
|
||||
</td>
|
||||
<td bitCell>Lorem</td>
|
||||
<td bitCell>Ipsum</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
<div
|
||||
bitTypography="body2"
|
||||
class="tw-inline-flex tw-items-center tw-rounded-full tw-border-solid tw-border tw-border-text-muted"
|
||||
[ngClass]="[
|
||||
selectedOption
|
||||
? 'tw-bg-text-muted tw-text-contrast tw-gap-1'
|
||||
: 'tw-bg-transparent tw-text-muted tw-gap-1.5',
|
||||
focusVisibleWithin() ? 'tw-ring-2 tw-ring-primary-500 tw-ring-offset-1' : '',
|
||||
fullWidth ? 'tw-w-full' : 'tw-max-w-52',
|
||||
]"
|
||||
class="tw-inline-flex tw-items-center tw-rounded-full tw-w-full tw-border-solid tw-border tw-gap-1.5 tw-group/chip-select"
|
||||
[ngClass]="{
|
||||
'tw-bg-text-muted hover:tw-bg-secondary-700 tw-text-contrast hover:!tw-border-secondary-700':
|
||||
selectedOption && !disabled,
|
||||
'tw-bg-transparent hover:tw-border-secondary-700 !tw-text-muted hover:tw-bg-secondary-100':
|
||||
!selectedOption && !disabled,
|
||||
'tw-bg-secondary-300 tw-text-muted tw-border-transparent': disabled,
|
||||
'tw-border-text-muted': !disabled,
|
||||
'tw-ring-2 tw-ring-primary-600 tw-ring-offset-1': focusVisibleWithin(),
|
||||
}"
|
||||
>
|
||||
<!-- Primary button -->
|
||||
<button
|
||||
type="button"
|
||||
class="fvw-target tw-inline-flex tw-gap-1.5 tw-items-center tw-justify-between tw-bg-transparent hover:tw-bg-transparent tw-border-none tw-outline-none tw-w-full tw-py-1 tw-pl-3 last:tw-pr-3 tw-truncate tw-text-[inherit]"
|
||||
class="fvw-target tw-inline-flex tw-gap-1.5 tw-items-center tw-justify-between tw-bg-transparent hover:tw-bg-transparent tw-border-none tw-outline-none tw-w-full tw-py-1 tw-pl-3 last:tw-pr-3 [&:not(:last-child)]:tw-pr-0 tw-truncate tw-text-[color:inherit] tw-text-[length:inherit]"
|
||||
[ngClass]="{
|
||||
'tw-cursor-not-allowed': disabled,
|
||||
'group-hover/chip-select:tw-text-secondary-700': !selectedOption && !disabled,
|
||||
}"
|
||||
[bitMenuTriggerFor]="menu"
|
||||
[disabled]="disabled"
|
||||
[title]="label"
|
||||
#menuTrigger="menuTrigger"
|
||||
(click)="setMenuWidth()"
|
||||
#chipSelectButton
|
||||
>
|
||||
<span class="tw-inline-flex tw-items-center tw-gap-1.5 tw-truncate">
|
||||
<i class="bwi !tw-text-[inherit]" [ngClass]="icon"></i>
|
||||
@@ -27,7 +32,7 @@
|
||||
</span>
|
||||
<i
|
||||
*ngIf="!selectedOption"
|
||||
class="bwi"
|
||||
class="bwi tw-mt-0.5"
|
||||
[ngClass]="menuTrigger.isOpen ? 'bwi-angle-up' : 'bwi-angle-down'"
|
||||
></i>
|
||||
</button>
|
||||
@@ -38,7 +43,7 @@
|
||||
type="button"
|
||||
[attr.aria-label]="'removeItem' | i18n: label"
|
||||
[disabled]="disabled"
|
||||
class="tw-bg-transparent hover:tw-bg-transparent tw-outline-none tw-rounded-full tw-p-1 tw-my-1 tw-mr-1 tw-text-[inherit] tw-border-solid tw-border tw-border-text-muted hover:tw-border-text-contrast hover:disabled:tw-border-transparent tw-aspect-square tw-flex tw-items-center tw-justify-center tw-h-fit focus-visible:tw-ring-2 tw-ring-text-contrast focus-visible:hover:tw-border-transparent"
|
||||
class="tw-bg-transparent hover:tw-bg-transparent tw-outline-none tw-rounded-full tw-py-0.5 tw-px-1 tw-mr-1 tw-text-[color:inherit] tw-text-[length:inherit] tw-border-solid tw-border tw-border-transparent hover:tw-border-text-contrast hover:disabled:tw-border-transparent tw-flex tw-items-center tw-justify-center focus-visible:tw-ring-2 tw-ring-text-contrast focus-visible:hover:tw-border-transparent"
|
||||
[ngClass]="{
|
||||
'tw-cursor-not-allowed': disabled,
|
||||
}"
|
||||
@@ -48,13 +53,18 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<bit-menu #menu>
|
||||
<div *ngIf="renderedOptions" class="tw-max-h-80 tw-min-w-52 tw-max-w-80 tw-text-sm">
|
||||
<bit-menu #menu (closed)="handleMenuClosed()">
|
||||
<div
|
||||
*ngIf="renderedOptions"
|
||||
class="tw-max-h-80 tw-min-w-32 tw-max-w-80 tw-text-sm"
|
||||
[ngStyle]="menuWidth && { width: menuWidth + 'px' }"
|
||||
>
|
||||
<ng-container *ngIf="getParent(renderedOptions) as parent">
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="viewOption(parent, $event)"
|
||||
class="tw-text-[length:inherit]"
|
||||
[title]="'backTo' | i18n: parent.label ?? placeholderText"
|
||||
>
|
||||
<i slot="start" class="bwi bwi-angle-left" aria-hidden="true"></i>
|
||||
@@ -66,6 +76,7 @@
|
||||
bitMenuItem
|
||||
(click)="selectOption(renderedOptions, $event)"
|
||||
[title]="'viewItemsIn' | i18n: renderedOptions.label"
|
||||
class="tw-text-[length:inherit]"
|
||||
>
|
||||
<i slot="start" class="bwi bwi-list" aria-hidden="true"></i>
|
||||
{{ "viewItemsIn" | i18n: renderedOptions.label }}
|
||||
@@ -79,6 +90,7 @@
|
||||
(click)="option.children?.length ? viewOption(option, $event) : selectOption(option, $event)"
|
||||
[disabled]="option.disabled"
|
||||
[title]="option.label"
|
||||
class="tw-text-[length:inherit]"
|
||||
[attr.aria-haspopup]="option.children?.length ? 'menu' : null"
|
||||
>
|
||||
<i
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
QueryList,
|
||||
@@ -44,6 +46,7 @@ export type ChipSelectOption<T> = Option<T> & {
|
||||
export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, AfterViewInit {
|
||||
@ViewChild(MenuComponent) menu: MenuComponent;
|
||||
@ViewChildren(MenuItemDirective) menuItems: QueryList<MenuItemDirective>;
|
||||
@ViewChild("chipSelectButton") chipSelectButton: ElementRef<HTMLButtonElement>;
|
||||
|
||||
/** Text to show when there is no selected option */
|
||||
@Input({ required: true }) placeholderText: string;
|
||||
@@ -81,6 +84,11 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
this.focusVisibleWithin.set(false);
|
||||
}
|
||||
|
||||
@HostBinding("class")
|
||||
get classList() {
|
||||
return ["tw-inline-block", this.fullWidth ? "tw-w-full" : "tw-max-w-52"];
|
||||
}
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
/** Tree constructed from `this.options` */
|
||||
@@ -92,6 +100,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
/** The option that is currently selected by the user */
|
||||
protected selectedOption: ChipSelectOption<T>;
|
||||
|
||||
/**
|
||||
* The initial calculated width of the menu when it opens, which is used to
|
||||
* keep the width consistent as the user navigates through submenus
|
||||
*/
|
||||
protected menuWidth: number | null = null;
|
||||
|
||||
/** The label to show in the chip button */
|
||||
protected get label(): string {
|
||||
return this.selectedOption?.label || this.placeholderText;
|
||||
@@ -102,6 +116,24 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
return this.selectedOption?.icon || this.placeholderIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the rendered options based on whether or not an option is already selected, so that the correct
|
||||
* submenu displays.
|
||||
*/
|
||||
protected setOrResetRenderedOptions(): void {
|
||||
this.renderedOptions = this.selectedOption
|
||||
? this.selectedOption.children?.length > 0
|
||||
? this.selectedOption
|
||||
: this.getParent(this.selectedOption)
|
||||
: this.rootTree;
|
||||
}
|
||||
|
||||
protected handleMenuClosed(): void {
|
||||
this.setOrResetRenderedOptions();
|
||||
// reset menu width so that it can be recalculated upon open
|
||||
this.menuWidth = null;
|
||||
}
|
||||
|
||||
protected selectOption(option: ChipSelectOption<T>, _event: MouseEvent) {
|
||||
this.selectedOption = option;
|
||||
this.onChange(option);
|
||||
@@ -179,6 +211,19 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the width of the menu based on whichever is larger, the chip select width or the width of
|
||||
* the initially rendered options
|
||||
*/
|
||||
protected setMenuWidth() {
|
||||
const chipWidth = this.chipSelectButton.nativeElement.getBoundingClientRect().width;
|
||||
|
||||
const firstMenuItemWidth =
|
||||
this.menu.menuItems.first.elementRef.nativeElement.getBoundingClientRect().width;
|
||||
|
||||
this.menuWidth = Math.max(chipWidth, firstMenuItemWidth);
|
||||
}
|
||||
|
||||
/** Control Value Accessor */
|
||||
|
||||
private notifyOnChange?: (value: T) => void;
|
||||
@@ -187,11 +232,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
/** Implemented as part of NG_VALUE_ACCESSOR */
|
||||
writeValue(obj: T): void {
|
||||
this.selectedOption = this.findOption(this.rootTree, obj);
|
||||
|
||||
/** Update the rendered options for next time the menu is opened */
|
||||
this.renderedOptions = this.selectedOption
|
||||
? this.getParent(this.selectedOption)
|
||||
: this.rootTree;
|
||||
this.setOrResetRenderedOptions();
|
||||
}
|
||||
|
||||
/** Implemented as part of NG_VALUE_ACCESSOR */
|
||||
|
||||
@@ -35,6 +35,47 @@ export default {
|
||||
type Story = StoryObj<ChipSelectComponent & { value: any }>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
},
|
||||
template: /* html */ `
|
||||
<bit-chip-select
|
||||
placeholderText="Folder"
|
||||
placeholderIcon="bwi-folder"
|
||||
[options]="options"
|
||||
></bit-chip-select>
|
||||
<bit-chip-select
|
||||
placeholderText="Folder"
|
||||
placeholderIcon="bwi-folder"
|
||||
[options]="options"
|
||||
[ngModel]="value"
|
||||
></bit-chip-select>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
options: [
|
||||
{
|
||||
label: "Foo",
|
||||
value: "foo",
|
||||
icon: "bwi-folder",
|
||||
},
|
||||
{
|
||||
label: "Bar",
|
||||
value: "bar",
|
||||
icon: "bwi-exclamation-triangle tw-text-danger",
|
||||
},
|
||||
{
|
||||
label: "Baz",
|
||||
value: "baz",
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
value: "foo",
|
||||
},
|
||||
};
|
||||
|
||||
export const MenuOpen: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
@@ -122,7 +163,7 @@ export const NestedOptions: Story = {
|
||||
icon: "bwi-folder",
|
||||
children: [
|
||||
{
|
||||
label: "Foo1",
|
||||
label: "Foo1 very long name of folder but even longer than you thought",
|
||||
value: "foo1",
|
||||
icon: "bwi-folder",
|
||||
children: [
|
||||
@@ -170,12 +211,17 @@ export const TextOverflow: Story = {
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
...Default,
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
},
|
||||
template: /* html */ `
|
||||
<bit-chip-select
|
||||
placeholderText="Folder"
|
||||
placeholderIcon="bwi-folder"
|
||||
[options]="options"
|
||||
disabled
|
||||
></bit-chip-select>
|
||||
<bit-chip-select
|
||||
placeholderText="Folder"
|
||||
placeholderIcon="bwi-folder"
|
||||
|
||||
@@ -78,6 +78,7 @@ export default {
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
close: "Close",
|
||||
loading: "Loading",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<section
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
[ngClass]="width"
|
||||
@fadeIn
|
||||
>
|
||||
<header
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
|
||||
>
|
||||
<h1 bitDialogTitleContainer bitTypography="h3" noMargin class="tw-mb-0 tw-truncate">
|
||||
<h1
|
||||
bitDialogTitleContainer
|
||||
bitTypography="h3"
|
||||
noMargin
|
||||
class="tw-text-main tw-mb-0 tw-truncate"
|
||||
>
|
||||
{{ title }}
|
||||
<span *ngIf="subtitle" class="tw-text-muted tw-font-normal tw-text-sm">
|
||||
{{ subtitle }}
|
||||
@@ -24,15 +29,19 @@
|
||||
></button>
|
||||
</header>
|
||||
|
||||
<div class="tw-relative tw-flex tw-flex-col tw-overflow-hidden">
|
||||
<div
|
||||
class="tw-relative tw-flex tw-flex-col tw-overflow-hidden"
|
||||
[ngClass]="{
|
||||
'tw-min-h-60': loading,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
*ngIf="loading"
|
||||
class="tw-absolute tw-flex tw-h-full tw-w-full tw-items-center tw-justify-center"
|
||||
>
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-lg" [attr.aria-label]="'loading' | i18n"></i>
|
||||
</div>
|
||||
<div
|
||||
class="tw-pb-8"
|
||||
[ngClass]="{
|
||||
'tw-p-4': !disablePadding,
|
||||
'tw-overflow-y-auto': !loading,
|
||||
@@ -46,7 +55,7 @@
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background-alt tw-p-4"
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-4"
|
||||
>
|
||||
<ng-content select="[bitDialogFooter]"></ng-content>
|
||||
</footer>
|
||||
|
||||
@@ -75,3 +75,10 @@ loading state.
|
||||
Use tabs to separate related content within a dialog.
|
||||
|
||||
<Story of={stories.TabContent} />
|
||||
|
||||
## Background Color
|
||||
|
||||
The `background` input can be set to `alt` to change the background color. This is useful for
|
||||
dialogs that contain multiple card sections.
|
||||
|
||||
<Story of={stories.WithCards} />
|
||||
|
||||
@@ -4,6 +4,10 @@ import * as stories from "./dialog.service.stories";
|
||||
|
||||
<Meta title="Component Library/Dialogs" />
|
||||
|
||||
```ts
|
||||
import { DialogModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Dialog
|
||||
|
||||
Dialogs are used throughout the app to help the user focus on a specific action.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -144,7 +145,7 @@ export default {
|
||||
component: StoryDialogComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ButtonModule, DialogModule, CalloutModule],
|
||||
imports: [ButtonModule, BrowserAnimationsModule, DialogModule, CalloutModule],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="tw-my-4 tw-flex tw-max-h-screen tw-w-96 tw-max-w-90vw tw-flex-col tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-text-contrast tw-text-main"
|
||||
class="tw-my-4 tw-flex tw-max-h-screen tw-w-96 tw-max-w-90vw tw-flex-col tw-overflow-hidden tw-rounded-3xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-text-contrast tw-text-main"
|
||||
@fadeIn
|
||||
>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
|
||||
@@ -11,13 +11,15 @@
|
||||
</ng-template>
|
||||
<h1
|
||||
bitDialogTitleContainer
|
||||
class="tw-mb-0 tw-text-base tw-font-semibold tw-w-full tw-break-words tw-hyphens-auto"
|
||||
bitTypography="h3"
|
||||
noMargin
|
||||
class="tw-w-full tw-text-main tw-break-words tw-hyphens-auto"
|
||||
>
|
||||
<ng-content select="[bitDialogTitle]"></ng-content>
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="tw-overflow-y-auto tw-px-4 tw-pb-4 tw-pt-2 tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
|
||||
class="tw-overflow-y-auto tw-px-4 tw-pb-4 tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
|
||||
>
|
||||
<ng-content select="[bitDialogContent]"></ng-content>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DialogModule, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -8,11 +9,8 @@ import { ButtonModule } from "../../button";
|
||||
import { IconButtonModule } from "../../icon-button";
|
||||
import { SharedModule } from "../../shared/shared.module";
|
||||
import { I18nMockService } from "../../utils/i18n-mock.service";
|
||||
import { DialogModule } from "../dialog.module";
|
||||
import { DialogService } from "../dialog.service";
|
||||
import { DialogCloseDirective } from "../directives/dialog-close.directive";
|
||||
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
|
||||
|
||||
import { SimpleDialogComponent } from "./simple-dialog.component";
|
||||
|
||||
interface Animal {
|
||||
animal: string;
|
||||
@@ -65,13 +63,14 @@ export default {
|
||||
component: StoryDialogComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [
|
||||
StoryDialogContentComponent,
|
||||
DialogCloseDirective,
|
||||
DialogTitleContainerDirective,
|
||||
SimpleDialogComponent,
|
||||
declarations: [StoryDialogContentComponent],
|
||||
imports: [
|
||||
SharedModule,
|
||||
IconButtonModule,
|
||||
ButtonModule,
|
||||
BrowserAnimationsModule,
|
||||
DialogModule,
|
||||
],
|
||||
imports: [SharedModule, IconButtonModule, ButtonModule, DialogModule],
|
||||
providers: [
|
||||
DialogService,
|
||||
{
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { ButtonModule } from "../../button";
|
||||
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
|
||||
import { DialogModule } from "../dialog.module";
|
||||
|
||||
import { IconDirective, SimpleDialogComponent } from "./simple-dialog.component";
|
||||
import { SimpleDialogComponent } from "./simple-dialog.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Dialogs/Simple Dialog",
|
||||
component: SimpleDialogComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ButtonModule],
|
||||
declarations: [IconDirective, DialogTitleContainerDirective],
|
||||
imports: [ButtonModule, NoopAnimationsModule, DialogModule],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Component, HostBinding, Input, booleanAttribute } from "@angular/core";
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostBinding,
|
||||
Input,
|
||||
Output,
|
||||
booleanAttribute,
|
||||
} from "@angular/core";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
@@ -8,14 +15,26 @@ let nextId = 0;
|
||||
template: `<ng-content></ng-content>`,
|
||||
})
|
||||
export class DisclosureComponent {
|
||||
private _open: boolean;
|
||||
|
||||
/** Emits the visibility of the disclosure content */
|
||||
@Output() openChange = new EventEmitter<boolean>();
|
||||
|
||||
/**
|
||||
* Optionally init the disclosure in its opened state
|
||||
*/
|
||||
@Input({ transform: booleanAttribute }) open?: boolean = false;
|
||||
@Input({ transform: booleanAttribute }) set open(isOpen: boolean) {
|
||||
this._open = isOpen;
|
||||
this.openChange.emit(isOpen);
|
||||
}
|
||||
|
||||
@HostBinding("class") get classList() {
|
||||
return this.open ? "" : "tw-hidden";
|
||||
}
|
||||
|
||||
@HostBinding("id") id = `bit-disclosure-${nextId++}`;
|
||||
|
||||
get open(): boolean {
|
||||
return this._open;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<label [class]="labelClasses">
|
||||
<label
|
||||
class="tw-transition tw-select-none tw-mb-0 tw-inline-flex tw-rounded tw-p-0.5 has-[:focus-visible]:tw-ring has-[:focus-visible]:tw-ring-primary-600"
|
||||
[ngClass]="[formControl.disabled ? 'tw-cursor-auto' : 'tw-cursor-pointer']"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<span [class]="labelContentClasses">
|
||||
<span>
|
||||
<span
|
||||
class="tw-inline-flex tw-flex-col"
|
||||
[ngClass]="formControl.disabled ? 'tw-text-muted' : 'tw-text-main'"
|
||||
>
|
||||
<span bitTypography="body1">
|
||||
<ng-content select="bit-label"></ng-content>
|
||||
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
|
||||
</span>
|
||||
<ng-content select="bit-hint" *ngIf="!hasError"></ng-content>
|
||||
</span>
|
||||
</label>
|
||||
<div *ngIf="hasError" class="tw-mt-1 tw-text-danger">
|
||||
<div *ngIf="hasError" class="tw-mt-1 tw-text-danger tw-text-xs tw-ml-0.5">
|
||||
<i class="bwi bwi-error"></i> {{ displayError }}
|
||||
</div>
|
||||
|
||||
@@ -33,27 +33,11 @@ export class FormControlComponent {
|
||||
@HostBinding("class") get classes() {
|
||||
return []
|
||||
.concat(this.inline ? ["tw-inline-block", "tw-mr-4"] : ["tw-block"])
|
||||
.concat(this.disableMargin ? [] : ["tw-mb-6"]);
|
||||
.concat(this.disableMargin ? [] : ["tw-mb-4"]);
|
||||
}
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
protected get labelClasses() {
|
||||
return [
|
||||
"tw-transition",
|
||||
"tw-select-none",
|
||||
"tw-mb-0",
|
||||
"tw-inline-flex",
|
||||
"tw-items-baseline",
|
||||
].concat(this.formControl.disabled ? "tw-cursor-auto" : "tw-cursor-pointer");
|
||||
}
|
||||
|
||||
protected get labelContentClasses() {
|
||||
return ["tw-inline-flex", "tw-flex-col", "tw-font-semibold"].concat(
|
||||
this.formControl.disabled ? "tw-text-muted" : "tw-text-main",
|
||||
);
|
||||
}
|
||||
|
||||
get required() {
|
||||
return this.formControl.required;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { SharedModule } from "../shared";
|
||||
|
||||
import { FormControlComponent } from "./form-control.component";
|
||||
import { BitHintComponent } from "./hint.component";
|
||||
import { BitLabel } from "./label.directive";
|
||||
import { BitLabel } from "./label.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule],
|
||||
declarations: [FormControlComponent, BitLabel, BitHintComponent],
|
||||
imports: [SharedModule, BitLabel],
|
||||
declarations: [FormControlComponent, BitHintComponent],
|
||||
exports: [FormControlComponent, BitLabel, BitHintComponent],
|
||||
})
|
||||
export class FormControlModule {}
|
||||
|
||||
@@ -6,7 +6,7 @@ let nextId = 0;
|
||||
@Directive({
|
||||
selector: "bit-hint",
|
||||
host: {
|
||||
class: "tw-text-muted tw-font-normal tw-inline-block tw-mt-1",
|
||||
class: "tw-text-muted tw-font-normal tw-inline-block tw-mt-1 tw-text-xs",
|
||||
},
|
||||
})
|
||||
export class BitHintComponent {
|
||||
|
||||
14
libs/components/src/form-control/label.component.html
Normal file
14
libs/components/src/form-control/label.component.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<ng-template #endSlotContent>
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
</ng-template>
|
||||
|
||||
<!-- labels inside a form control (checkbox, radio button) should not truncate -->
|
||||
<span [ngClass]="{ 'tw-truncate': !isInsideFormControl }">
|
||||
<ng-content></ng-content>
|
||||
<ng-container *ngIf="isInsideFormControl">
|
||||
<ng-container *ngTemplateOutlet="endSlotContent"></ng-container>
|
||||
</ng-container>
|
||||
</span>
|
||||
<ng-container *ngIf="!isInsideFormControl">
|
||||
<ng-container *ngTemplateOutlet="endSlotContent"></ng-container>
|
||||
</ng-container>
|
||||
34
libs/components/src/form-control/label.component.ts
Normal file
34
libs/components/src/form-control/label.component.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, HostBinding, Input, Optional } from "@angular/core";
|
||||
|
||||
import { FormControlComponent } from "./form-control.component";
|
||||
|
||||
// Increments for each instance of this component
|
||||
let nextId = 0;
|
||||
|
||||
@Component({
|
||||
selector: "bit-label",
|
||||
standalone: true,
|
||||
templateUrl: "label.component.html",
|
||||
imports: [CommonModule],
|
||||
})
|
||||
export class BitLabel {
|
||||
constructor(
|
||||
private elementRef: ElementRef<HTMLInputElement>,
|
||||
@Optional() private parentFormControl: FormControlComponent,
|
||||
) {}
|
||||
|
||||
@HostBinding("class") @Input() get classList() {
|
||||
return ["tw-inline-flex", "tw-gap-1", "tw-items-baseline", "tw-flex-row", "tw-min-w-0"];
|
||||
}
|
||||
|
||||
@HostBinding("title") get title() {
|
||||
return this.elementRef.nativeElement.textContent.trim();
|
||||
}
|
||||
|
||||
@HostBinding() @Input() id = `bit-label-${nextId++}`;
|
||||
|
||||
get isInsideFormControl() {
|
||||
return !!this.parentFormControl;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Directive } from "@angular/core";
|
||||
|
||||
@Directive({
|
||||
selector: "bit-label",
|
||||
})
|
||||
export class BitLabel {}
|
||||
@@ -9,7 +9,7 @@ let nextId = 0;
|
||||
selector: "bit-error",
|
||||
template: `<i class="bwi bwi-error"></i> {{ displayError }}`,
|
||||
host: {
|
||||
class: "tw-block tw-mt-1 tw-text-danger",
|
||||
class: "tw-block tw-mt-1 tw-text-danger tw-text-xs",
|
||||
"aria-live": "assertive",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -19,5 +19,6 @@ export abstract class BitFormFieldControl {
|
||||
error: [string, any];
|
||||
type?: InputTypes;
|
||||
spellcheck?: boolean;
|
||||
readOnly?: boolean;
|
||||
focus?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,114 @@
|
||||
<label class="tw-mb-1 tw-block tw-font-semibold tw-text-main" [attr.for]="input.labelForId">
|
||||
<ng-content select="bit-label"></ng-content>
|
||||
<span *ngIf="input.required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
|
||||
</label>
|
||||
<div class="tw-flex">
|
||||
<div *ngIf="prefixChildren.length" class="tw-flex">
|
||||
<ng-content select="[bitPrefix]"></ng-content>
|
||||
</div>
|
||||
<!-- We need to use templates since the content slots are repeated between the readonly and read-write views. -->
|
||||
<ng-template #defaultContent>
|
||||
<ng-content></ng-content>
|
||||
<div *ngIf="suffixChildren.length" class="tw-flex">
|
||||
<ng-content select="[bitSuffix]"></ng-content>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #labelContent>
|
||||
<ng-content select="bit-label"></ng-content>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #prefixContent>
|
||||
<ng-content select="[bitPrefix]"></ng-content>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #suffixContent>
|
||||
<ng-content select="[bitSuffix]"></ng-content>
|
||||
</ng-template>
|
||||
|
||||
<div *ngIf="!readOnly; else readOnlyView" class="tw-w-full tw-relative tw-group/bit-form-field">
|
||||
<div class="tw-absolute tw-w-full tw-h-full tw-top-0 tw-pointer-events-none tw-z-20">
|
||||
<div class="tw-w-full tw-h-full tw-flex">
|
||||
<div
|
||||
class="tw-min-w-3 tw-border-r-0 group-focus-within/bit-form-field:tw-border-r-0 !tw-rounded-l-lg"
|
||||
[ngClass]="inputBorderClasses"
|
||||
></div>
|
||||
<div
|
||||
class="tw-px-1 tw-shrink tw-min-w-0 tw-mt-px tw-border-x-0 tw-border-t-0 group-focus-within/bit-form-field:tw-border-x-0 group-focus-within/bit-form-field:tw-border-t-0 tw-hidden group-has-[bit-label]/bit-form-field:tw-block"
|
||||
[ngClass]="inputBorderClasses"
|
||||
>
|
||||
<label
|
||||
class="tw-flex tw-gap-1 tw-text-sm tw-text-muted -tw-translate-y-[0.675rem] tw-mb-0 tw-max-w-full tw-pointer-events-auto"
|
||||
[attr.for]="input.labelForId"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="labelContent"></ng-container>
|
||||
<span *ngIf="input.required" class="tw-text-[0.625rem] tw-relative tw-bottom-[-1px]">
|
||||
({{ "required" | i18n }})</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="tw-min-w-3 tw-grow tw-border-l-0 group-focus-within/bit-form-field:tw-border-l-0 !tw-rounded-r-lg"
|
||||
[ngClass]="inputBorderClasses"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="tw-gap-1 tw-bg-background tw-rounded-lg tw-flex tw-min-h-11 [&:not(:has(button:enabled)):has(input:read-only)]:tw-bg-secondary-100 [&:not(:has(button:enabled)):has(textarea:read-only)]:tw-bg-secondary-100"
|
||||
>
|
||||
<div
|
||||
#prefixContainer
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-pl-3 tw-py-2"
|
||||
[hidden]="!prefixHasChildren()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="prefixContent"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
class="default-content tw-w-full tw-relative tw-py-2 has-[bit-select]:tw-p-0 has-[bit-multi-select]:tw-p-0 has-[input:read-only:not([hidden])]:tw-bg-secondary-100 has-[textarea:read-only:not([hidden])]:tw-bg-secondary-100"
|
||||
[ngClass]="[
|
||||
prefixHasChildren() ? '' : 'tw-rounded-l-lg tw-pl-3',
|
||||
suffixHasChildren() ? '' : 'tw-rounded-r-lg tw-pr-3',
|
||||
]"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
#suffixContainer
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-pr-3 tw-py-2"
|
||||
[hidden]="!suffixHasChildren()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="suffixContent"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #readOnlyView>
|
||||
<div class="tw-w-full tw-relative">
|
||||
<label
|
||||
class="tw-flex tw-gap-1 tw-text-sm tw-text-muted tw-mb-0 tw-max-w-full"
|
||||
[attr.for]="input.labelForId"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="labelContent"></ng-container>
|
||||
</label>
|
||||
<div
|
||||
class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid"
|
||||
[ngClass]="{
|
||||
'tw-border-secondary-300/50 tw-border-b tw-pb-[2px]': !disableReadOnlyBorder,
|
||||
'tw-border-transparent tw-pb-[3px]': disableReadOnlyBorder,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
#prefixContainer
|
||||
[hidden]="!prefixHasChildren()"
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-pl-1"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="prefixContent"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
class="default-content tw-w-full tw-pb-0 tw-relative [&>*]:tw-p-0 [&>*::selection]:tw-bg-primary-700 [&>*::selection]:tw-text-contrast"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
#suffixContainer
|
||||
[hidden]="!suffixHasChildren()"
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-pr-1"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="suffixContent"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-container [ngSwitch]="input.hasError">
|
||||
<ng-content select="bit-hint" *ngSwitchCase="false"></ng-content>
|
||||
<bit-error [error]="input.error" *ngSwitchCase="true"></bit-error>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import {
|
||||
AfterContentChecked,
|
||||
booleanAttribute,
|
||||
Component,
|
||||
ContentChild,
|
||||
ContentChildren,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
QueryList,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
|
||||
import { BitHintComponent } from "../form-control/hint.component";
|
||||
import { BitLabel } from "../form-control/label.component";
|
||||
import { inputBorderClasses } from "../input/input.directive";
|
||||
|
||||
import { BitErrorComponent } from "./error.component";
|
||||
import { BitFormFieldControl } from "./form-field-control";
|
||||
import { BitPrefixDirective } from "./prefix.directive";
|
||||
import { BitSuffixDirective } from "./suffix.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-form-field",
|
||||
@@ -25,30 +25,74 @@ import { BitSuffixDirective } from "./suffix.directive";
|
||||
export class BitFormFieldComponent implements AfterContentChecked {
|
||||
@ContentChild(BitFormFieldControl) input: BitFormFieldControl;
|
||||
@ContentChild(BitHintComponent) hint: BitHintComponent;
|
||||
@ContentChild(BitLabel) label: BitLabel;
|
||||
|
||||
@ViewChild("prefixContainer") prefixContainer: ElementRef<HTMLDivElement>;
|
||||
@ViewChild("suffixContainer") suffixContainer: ElementRef<HTMLDivElement>;
|
||||
|
||||
@ViewChild(BitErrorComponent) error: BitErrorComponent;
|
||||
|
||||
@ContentChildren(BitPrefixDirective) prefixChildren: QueryList<BitPrefixDirective>;
|
||||
@ContentChildren(BitSuffixDirective) suffixChildren: QueryList<BitSuffixDirective>;
|
||||
@Input({ transform: booleanAttribute })
|
||||
disableMargin = false;
|
||||
|
||||
private _disableMargin = false;
|
||||
@Input() set disableMargin(value: boolean | "") {
|
||||
this._disableMargin = coerceBooleanProperty(value);
|
||||
}
|
||||
get disableMargin() {
|
||||
return this._disableMargin;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: Placeholder to match the API of the form-field component in the `ps/extension` branch,
|
||||
* no functionality is implemented as of now.
|
||||
*/
|
||||
/** If `true`, remove the bottom border for `readonly` inputs */
|
||||
@Input({ transform: booleanAttribute })
|
||||
disableReadOnlyBorder = false;
|
||||
|
||||
protected prefixHasChildren = signal(false);
|
||||
protected suffixHasChildren = signal(false);
|
||||
|
||||
get inputBorderClasses(): string {
|
||||
const shouldFocusBorderAppear = this.defaultContentIsFocused();
|
||||
|
||||
const groupClasses = [
|
||||
this.input.hasError
|
||||
? "group-hover/bit-form-field:tw-border-danger-700"
|
||||
: "group-hover/bit-form-field:tw-border-primary-600",
|
||||
// the next 2 selectors override the above hover selectors when the input (or text area) is non-interactive (i.e. readonly, disabled)
|
||||
"group-has-[input:read-only]/bit-form-field:group-hover/bit-form-field:tw-border-secondary-500",
|
||||
"group-has-[textarea:read-only]/bit-form-field:group-hover/bit-form-field:tw-border-secondary-500",
|
||||
"group-focus-within/bit-form-field:tw-outline-none",
|
||||
shouldFocusBorderAppear ? "group-focus-within/bit-form-field:tw-border-2" : "",
|
||||
shouldFocusBorderAppear ? "group-focus-within/bit-form-field:tw-border-primary-600" : "",
|
||||
shouldFocusBorderAppear
|
||||
? "group-focus-within/bit-form-field:group-hover/bit-form-field:tw-border-primary-600"
|
||||
: "",
|
||||
];
|
||||
|
||||
const baseInputBorderClasses = inputBorderClasses(this.input.hasError);
|
||||
|
||||
const borderClasses = baseInputBorderClasses.concat(groupClasses);
|
||||
|
||||
return borderClasses.join(" ");
|
||||
}
|
||||
|
||||
@HostBinding("class")
|
||||
get classList() {
|
||||
return ["tw-block"].concat(this.disableMargin ? [] : ["tw-mb-6"]);
|
||||
return ["tw-block"]
|
||||
.concat(this.disableMargin ? [] : ["tw-mb-4", "bit-compact:tw-mb-3"])
|
||||
.concat(this.readOnly ? [] : "tw-pt-2");
|
||||
}
|
||||
|
||||
/**
|
||||
* If the currently focused element is not part of the default content, then we don't want to show focus on the
|
||||
* input field itself.
|
||||
*
|
||||
* This is necessary because the `tw-group/bit-form-field` wraps the input and any prefix/suffix
|
||||
* buttons
|
||||
*/
|
||||
protected defaultContentIsFocused = signal(false);
|
||||
@HostListener("focusin", ["$event.target"])
|
||||
onFocusIn(target: HTMLElement) {
|
||||
this.defaultContentIsFocused.set(target.matches(".default-content *:focus-visible"));
|
||||
}
|
||||
@HostListener("focusout")
|
||||
onFocusOut() {
|
||||
this.defaultContentIsFocused.set(false);
|
||||
}
|
||||
|
||||
protected get readOnly(): boolean {
|
||||
return this.input.readOnly;
|
||||
}
|
||||
|
||||
ngAfterContentChecked(): void {
|
||||
@@ -59,5 +103,8 @@ export class BitFormFieldComponent implements AfterContentChecked {
|
||||
} else {
|
||||
this.input.ariaDescribedBy = undefined;
|
||||
}
|
||||
|
||||
this.prefixHasChildren.set(this.prefixContainer?.nativeElement.childElementCount > 0);
|
||||
this.suffixHasChildren.set(this.suffixContainer?.nativeElement.childElementCount > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TextFieldModule } from "@angular/cdk/text-field";
|
||||
import {
|
||||
AbstractControl,
|
||||
UntypedFormBuilder,
|
||||
@@ -9,14 +10,19 @@ import {
|
||||
} from "@angular/forms";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { A11yTitleDirective } from "@bitwarden/angular/src/directives/a11y-title.directive";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { AsyncActionsModule } from "../async-actions";
|
||||
import { BadgeModule } from "../badge";
|
||||
import { ButtonModule } from "../button";
|
||||
import { CardComponent } from "../card";
|
||||
import { CheckboxModule } from "../checkbox";
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { LinkModule } from "../link";
|
||||
import { RadioButtonModule } from "../radio-button";
|
||||
import { SectionComponent } from "../section";
|
||||
import { SelectModule } from "../select";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
@@ -39,7 +45,13 @@ export default {
|
||||
CheckboxModule,
|
||||
RadioButtonModule,
|
||||
SelectModule,
|
||||
LinkModule,
|
||||
CardComponent,
|
||||
SectionComponent,
|
||||
TextFieldModule,
|
||||
BadgeModule,
|
||||
],
|
||||
declarations: [A11yTitleDirective],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
@@ -49,6 +61,7 @@ export default {
|
||||
required: "required",
|
||||
inputRequired: "Input is required.",
|
||||
inputEmail: "Input is not an email-address.",
|
||||
toggleVisibility: "Toggle visibility",
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -74,6 +87,7 @@ const defaultFormObj = fb.group({
|
||||
email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]],
|
||||
terms: [false, [Validators.requiredTrue]],
|
||||
updates: ["yes"],
|
||||
file: [""],
|
||||
});
|
||||
|
||||
// Custom error message, `message` is shown as the error message
|
||||
@@ -96,7 +110,7 @@ export const Default: Story = {
|
||||
submit: submit,
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
@@ -108,13 +122,68 @@ export const Default: Story = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const LabelWithIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
formObj: defaultFormObj,
|
||||
submit: submit,
|
||||
...args,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
Label
|
||||
<a href="#" slot="end" bitLink>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-label>
|
||||
<input bitInput formControlName="name" />
|
||||
<bit-hint>Optional Hint</bit-hint>
|
||||
</bit-form-field>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const LongLabel: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
formObj: defaultFormObj,
|
||||
submit: submit,
|
||||
...args,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj" style="width: 200px">
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
Hello I am a very long label with lots of very cool helpful information
|
||||
</bit-label>
|
||||
<input bitInput formControlName="name" />
|
||||
<bit-hint>Optional Hint</bit-hint>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
Hello I am a very long label with lots of very cool helpful information
|
||||
<a href="#" slot="end" bitLink>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-label>
|
||||
<input bitInput formControlName="name" />
|
||||
<bit-hint>Optional Hint</bit-hint>
|
||||
</bit-form-field>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
formObj: formObj,
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<input bitInput required placeholder="Placeholder" />
|
||||
@@ -134,7 +203,7 @@ export const Hint: Story = {
|
||||
formObj: formObj,
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field [formGroup]="formObj">
|
||||
<bit-label>FormControl</bit-label>
|
||||
<input bitInput formControlName="required" placeholder="Placeholder" />
|
||||
@@ -147,7 +216,7 @@ export const Hint: Story = {
|
||||
export const Disabled: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<input bitInput placeholder="Placeholder" disabled />
|
||||
@@ -160,16 +229,54 @@ export const Disabled: Story = {
|
||||
export const Readonly: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Input</bit-label>
|
||||
<input bitInput value="Foobar" readonly />
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Input</bit-label>
|
||||
<input bitInput type="password" value="Foobar" [readonly]="true" />
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Input'"></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Textarea</bit-label>
|
||||
<textarea bitInput rows="4" readonly>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</textarea>
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-p-4 tw-mt-10 tw-border-2 tw-border-solid tw-border-black tw-bg-background-alt">
|
||||
<h2 bitTypography="h2">Inside card</h2>
|
||||
<bit-section>
|
||||
<bit-card>
|
||||
<bit-form-field>
|
||||
<bit-label>Input</bit-label>
|
||||
<input bitInput value="Foobar" readonly />
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Input</bit-label>
|
||||
<input bitInput type="password" value="Foobar" readonly />
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Input'"></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Textarea <span slot="end" bitBadge variant="success">Premium</span></bit-label>
|
||||
<textarea bitInput rows="3" readonly class="tw-resize-none">Row1
|
||||
Row2
|
||||
Row3</textarea>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field disableMargin disableReadOnlyBorder>
|
||||
<bit-label>Sans margin & border</bit-label>
|
||||
<input bitInput value="Foobar" readonly />
|
||||
</bit-form-field>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {},
|
||||
@@ -178,7 +285,7 @@ export const Readonly: Story = {
|
||||
export const InputGroup: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<input bitInput placeholder="Placeholder" />
|
||||
@@ -193,13 +300,19 @@ export const InputGroup: Story = {
|
||||
export const ButtonInputGroup: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<button bitPrefix bitIconButton="bwi-star"></button>
|
||||
<bit-label>
|
||||
Label
|
||||
<a href="#" slot="end" bitLink [appA11yTitle]="'More info'">
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-label>
|
||||
<button bitPrefix bitIconButton="bwi-star" [appA11yTitle]="'Favorite Label'"></button>
|
||||
<input bitInput placeholder="Placeholder" />
|
||||
<button bitSuffix bitIconButton="bwi-eye"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone"></button>
|
||||
<button bitSuffix bitButton>
|
||||
<button bitSuffix bitIconButton="bwi-eye" [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink>
|
||||
Apply
|
||||
</button>
|
||||
</bit-form-field>
|
||||
@@ -211,14 +324,32 @@ export const ButtonInputGroup: Story = {
|
||||
export const DisabledButtonInputGroup: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<button bitPrefix bitIconButton="bwi-star" disabled></button>
|
||||
<button bitPrefix bitIconButton="bwi-star" disabled [appA11yTitle]="'Favorite Label'"></button>
|
||||
<input bitInput placeholder="Placeholder" disabled />
|
||||
<button bitSuffix bitIconButton="bwi-eye" disabled></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" disabled></button>
|
||||
<button bitSuffix bitButton disabled>
|
||||
<button bitSuffix bitIconButton="bwi-eye" disabled [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" disabled [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink disabled>
|
||||
Apply
|
||||
</button>
|
||||
</bit-form-field>
|
||||
`,
|
||||
}),
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const PartiallyDisabledButtonInputGroup: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<input bitInput placeholder="Placeholder" disabled />
|
||||
<button bitSuffix bitIconButton="bwi-eye" [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink disabled>
|
||||
Apply
|
||||
</button>
|
||||
</bit-form-field>
|
||||
@@ -230,7 +361,7 @@ export const DisabledButtonInputGroup: Story = {
|
||||
export const Select: Story = {
|
||||
render: (args: BitFormFieldComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<select bitInput>
|
||||
@@ -246,7 +377,7 @@ export const Select: Story = {
|
||||
export const AdvancedSelect: Story = {
|
||||
render: (args: BitFormFieldComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<bit-select>
|
||||
@@ -258,10 +389,40 @@ export const AdvancedSelect: Story = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const FileInput: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
formObj: defaultFormObj,
|
||||
submit: submit,
|
||||
...args,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-form-field>
|
||||
<bit-label>File</bit-label>
|
||||
<div class="tw-text-main">
|
||||
<button bitButton type="button" buttonType="secondary">
|
||||
Choose File
|
||||
</button>
|
||||
No file chosen
|
||||
</div>
|
||||
<input
|
||||
bitInput
|
||||
#fileSelector
|
||||
type="file"
|
||||
formControlName="file"
|
||||
hidden
|
||||
/>
|
||||
</bit-form-field>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Textarea: Story = {
|
||||
render: (args: BitFormFieldComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Textarea</bit-label>
|
||||
<textarea bitInput rows="4"></textarea>
|
||||
|
||||
@@ -66,9 +66,9 @@ export const actionsData = {
|
||||
};
|
||||
|
||||
const fb = new FormBuilder();
|
||||
const formObjFactory = () =>
|
||||
const formObjFactory = (isDisabled = false) =>
|
||||
fb.group({
|
||||
select: [[], [Validators.required]],
|
||||
select: fb.control({ value: [], disabled: isDisabled }, { validators: [Validators.required] }),
|
||||
});
|
||||
|
||||
function submit(formObj: FormGroup) {
|
||||
@@ -85,7 +85,7 @@ export const Loading: Story = {
|
||||
...args,
|
||||
onItemsConfirmed: actionsData.onItemsConfirmed,
|
||||
},
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj" (ngSubmit)="submit(formObj)">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ name }}</bit-label>
|
||||
@@ -100,7 +100,6 @@ export const Loading: Story = {
|
||||
</bit-multi-select>
|
||||
<bit-hint>{{ hint }}</bit-hint>
|
||||
</bit-form-field>
|
||||
<button type="submit" bitButton buttonType="primary">Submit</button>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
@@ -113,8 +112,33 @@ export const Loading: Story = {
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
...Loading,
|
||||
render: (args) => ({
|
||||
props: {
|
||||
formObj: formObjFactory(true),
|
||||
submit: submit,
|
||||
...args,
|
||||
onItemsConfirmed: actionsData.onItemsConfirmed,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj" (ngSubmit)="submit(formObj)">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ name }}</bit-label>
|
||||
<bit-multi-select
|
||||
class="tw-w-full"
|
||||
formControlName="select"
|
||||
[baseItems]="baseItems"
|
||||
[removeSelectedItems]="removeSelectedItems"
|
||||
[loading]="loading"
|
||||
[disabled]="disabled"
|
||||
(onItemsConfirmed)="onItemsConfirmed($event)">
|
||||
</bit-multi-select>
|
||||
<bit-hint>{{ hint }}</bit-hint>
|
||||
</bit-form-field>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
baseItems: [] as any,
|
||||
name: "Disabled",
|
||||
disabled: true,
|
||||
hint: "This is what a disabled multi-select looks like",
|
||||
@@ -178,7 +202,7 @@ export const Members: Story = {
|
||||
{
|
||||
id: "7",
|
||||
listName: "Final listName (fname@mail.me)",
|
||||
labelName: "(fname@mail.me)",
|
||||
labelName: "Final listName (fname@mail.me)",
|
||||
icon: "bwi-user",
|
||||
},
|
||||
],
|
||||
@@ -269,34 +293,3 @@ export const RemoveSelected: Story = {
|
||||
removeSelectedItems: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Standalone: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
onItemsConfirmed: actionsData.onItemsConfirmed,
|
||||
},
|
||||
template: `
|
||||
<bit-multi-select
|
||||
class="tw-w-full"
|
||||
[baseItems]="baseItems"
|
||||
[removeSelectedItems]="removeSelectedItems"
|
||||
[loading]="loading"
|
||||
[disabled]="disabled"
|
||||
(onItemsConfirmed)="onItemsConfirmed($event)">
|
||||
</bit-multi-select>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
baseItems: [
|
||||
{ id: "1", listName: "Group 1", labelName: "Group 1", icon: "bwi-family" },
|
||||
{ id: "2", listName: "Group 2", labelName: "Group 2", icon: "bwi-family" },
|
||||
{ id: "3", listName: "Group 3", labelName: "Group 3", icon: "bwi-family" },
|
||||
{ id: "4", listName: "Group 4", labelName: "Group 4", icon: "bwi-family" },
|
||||
{ id: "5", listName: "Group 5", labelName: "Group 5", icon: "bwi-family" },
|
||||
{ id: "6", listName: "Group 6", labelName: "Group 6", icon: "bwi-family" },
|
||||
{ id: "7", listName: "Group 7", labelName: "Group 7", icon: "bwi-family" },
|
||||
],
|
||||
removeSelectedItems: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,8 +37,6 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
|
||||
this.toggledChange.emit(this.toggled);
|
||||
|
||||
this.update();
|
||||
|
||||
this.formField.input?.focus();
|
||||
}
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -43,7 +43,7 @@ type Story = StoryObj<BitPasswordInputToggleDirective>;
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<form>
|
||||
<bit-form-field>
|
||||
<bit-label>Password</bit-label>
|
||||
@@ -58,7 +58,7 @@ export const Default: Story = {
|
||||
export const Binding: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<form>
|
||||
<bit-form-field>
|
||||
<bit-label>Password</bit-label>
|
||||
|
||||
@@ -1,51 +1,20 @@
|
||||
import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core";
|
||||
|
||||
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
|
||||
|
||||
export const PrefixClasses = [
|
||||
"tw-bg-background-alt",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-border-secondary-600",
|
||||
"tw-text-muted",
|
||||
"tw-rounded-none",
|
||||
];
|
||||
|
||||
export const PrefixButtonClasses = [
|
||||
"hover:tw-bg-text-muted",
|
||||
"hover:tw-text-contrast",
|
||||
"disabled:tw-opacity-100",
|
||||
"disabled:tw-bg-secondary-100",
|
||||
"disabled:hover:tw-bg-secondary-100",
|
||||
"disabled:hover:tw-text-muted",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
|
||||
"focus-visible:tw-border-primary-700",
|
||||
"focus-visible:tw-ring-1",
|
||||
"focus-visible:tw-ring-inset",
|
||||
"focus-visible:tw-ring-primary-700",
|
||||
"focus-visible:tw-z-10",
|
||||
];
|
||||
|
||||
export const PrefixStaticContentClasses = ["tw-block", "tw-px-3", "tw-py-1.5"];
|
||||
import { BitIconButtonComponent } from "../icon-button/icon-button.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitPrefix]",
|
||||
})
|
||||
export class BitPrefixDirective implements OnInit {
|
||||
constructor(@Optional() private buttonComponent: ButtonLikeAbstraction) {}
|
||||
|
||||
@HostBinding("class") @Input() get classList() {
|
||||
return PrefixClasses.concat([
|
||||
"tw-border-r-0",
|
||||
"first:tw-rounded-l",
|
||||
|
||||
"focus-visible:tw-border-r",
|
||||
"focus-visible:tw-mr-[-1px]",
|
||||
]).concat(this.buttonComponent != undefined ? PrefixButtonClasses : PrefixStaticContentClasses);
|
||||
return ["tw-text-muted"];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.buttonComponent?.setButtonType("unstyled");
|
||||
constructor(@Optional() private iconButtonComponent: BitIconButtonComponent) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.iconButtonComponent) {
|
||||
this.iconButtonComponent.size = "small";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core";
|
||||
|
||||
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
|
||||
|
||||
import { PrefixButtonClasses, PrefixClasses, PrefixStaticContentClasses } from "./prefix.directive";
|
||||
import { BitIconButtonComponent } from "../icon-button/icon-button.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitSuffix]",
|
||||
})
|
||||
export class BitSuffixDirective implements OnInit {
|
||||
constructor(@Optional() private buttonComponent: ButtonLikeAbstraction) {}
|
||||
|
||||
@HostBinding("class") @Input() get classList() {
|
||||
return PrefixClasses.concat([
|
||||
"tw-border-l-0",
|
||||
"last:tw-rounded-r",
|
||||
|
||||
"focus-visible:tw-border-l",
|
||||
"focus-visible:tw-ml-[-1px]",
|
||||
]).concat(this.buttonComponent != undefined ? PrefixButtonClasses : PrefixStaticContentClasses);
|
||||
return ["tw-text-muted"];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.buttonComponent?.setButtonType("unstyled");
|
||||
constructor(@Optional() private iconButtonComponent: BitIconButtonComponent) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.iconButtonComponent) {
|
||||
this.iconButtonComponent.size = "small";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user