1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 21:50:15 +00:00

Merge branch 'main' into ac/pm-17168/revoking-user-is-not-synced-immediately-via-push-notification

This commit is contained in:
Rui Tomé
2025-02-04 10:22:52 +00:00
committed by GitHub
97 changed files with 4267 additions and 7731 deletions

3
.github/CODEOWNERS vendored
View File

@@ -97,12 +97,15 @@ apps/web/src/translation-constants.ts @bitwarden/team-platform-dev
.github/workflows/scan.yml @bitwarden/team-platform-dev
.github/workflows/test.yml @bitwarden/team-platform-dev
.github/workflows/version-auto-bump.yml @bitwarden/team-platform-dev
# ESLint custom rules
libs/eslint @bitwarden/team-platform-dev
## Autofill team files ##
apps/browser/src/autofill @bitwarden/team-autofill-dev
apps/desktop/src/autofill @bitwarden/team-autofill-dev
libs/common/src/autofill @bitwarden/team-autofill-dev
apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev
apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev

View File

@@ -211,6 +211,8 @@
"@storybook/angular",
"@storybook/manager-api",
"@storybook/theming",
"@typescript-eslint/utils",
"@typescript-eslint/rule-tester",
"@types/react",
"autoprefixer",
"bootstrap",

View File

@@ -982,8 +982,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
const queueLength = this.mutationsQueue.length;
if (!this.domQueryService.pageContainsShadowDomElements()) {
// Checking if a page contains shadowDOM elements is a heavy operation and doesn't have to be done immediately, so we can call this within an idle moment on the event loop.
requestIdleCallbackPolyfill(this.checkPageContainsShadowDom, { timeout: 500 });
this.checkPageContainsShadowDom();
}
for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) {

View File

@@ -5,3 +5,4 @@ index.node
npm-debug.log*
*.node
dist
windows_pluginauthenticator_bindings.rs

View File

@@ -410,6 +410,26 @@ dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]]
name = "bitflags"
version = "2.8.0"
@@ -553,6 +573,15 @@ dependencies = [
"shlex",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -593,6 +622,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.5.27"
@@ -1458,6 +1498,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.14"
@@ -2259,6 +2308,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "483f8c21f64f3ea09fe0f30f5d48c3e8eefe5dac9129f0075f76593b4c1da705"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro-crate"
version = "3.2.0"
@@ -2437,6 +2496,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3447,6 +3512,13 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-plugin-authenticator"
version = "0.0.0"
dependencies = [
"bindgen",
]
[[package]]
name = "windows-registry"
version = "0.4.0"

View File

@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["napi", "core", "proxy", "macos_provider"]
members = ["napi", "core", "proxy", "macos_provider", "windows-plugin-authenticator"]
[workspace.dependencies]
anyhow = "=1.0.94"

View File

@@ -0,0 +1,9 @@
[package]
name = "windows-plugin-authenticator"
version = "0.0.0"
edition = "2021"
license = "GPL-3.0"
publish = false
[target.'cfg(target_os = "windows")'.build-dependencies]
bindgen = "0.71.1"

View File

@@ -0,0 +1,23 @@
# windows-plugin-authenticator
This is an internal crate that's meant to be a safe abstraction layer over the generated Rust bindings for the Windows WebAuthn Plugin Authenticator API's.
You can find more information about the Windows WebAuthn API's [here](https://github.com/microsoft/webauthn).
## Building
To build this crate, set the following environment variables:
- `LIBCLANG_PATH` -> the path to the `bin` directory of your LLVM install ([more info](https://rust-lang.github.io/rust-bindgen/requirements.html?highlight=libclang_path#installing-clang))
### Bash Example
```
export LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin'
```
### PowerShell Example
```
$env:LIBCLANG_PATH = 'C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin'
```

View File

@@ -0,0 +1,22 @@
fn main() {
#[cfg(target_os = "windows")]
windows();
}
#[cfg(target_os = "windows")]
fn windows() {
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
let bindings = bindgen::Builder::default()
.header("pluginauthenticator.hpp")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings.");
bindings
.write_to_file(format!(
"{}\\windows_pluginauthenticator_bindings.rs",
out_dir
))
.expect("Couldn't write bindings.");
}

View File

@@ -0,0 +1,231 @@
/*
Bitwarden's pluginauthenticator.hpp
Source: https://github.com/microsoft/webauthn/blob/master/experimental/pluginauthenticator.h
This is a C++ header file, so the extension has been manually
changed from `.h` to `.hpp`, so bindgen will automatically
generate the correct C++ bindings.
More Info: https://rust-lang.github.io/rust-bindgen/cpp.html
*/
/* this ALWAYS GENERATED file contains the definitions for the interfaces */
/* File created by MIDL compiler version 8.01.0628 */
/* @@MIDL_FILE_HEADING( ) */
/* verify that the <rpcndr.h> version is high enough to compile this file*/
#ifndef __REQUIRED_RPCNDR_H_VERSION__
#define __REQUIRED_RPCNDR_H_VERSION__ 501
#endif
/* verify that the <rpcsal.h> version is high enough to compile this file*/
#ifndef __REQUIRED_RPCSAL_H_VERSION__
#define __REQUIRED_RPCSAL_H_VERSION__ 100
#endif
#include "rpc.h"
#include "rpcndr.h"
#ifndef __RPCNDR_H_VERSION__
#error this stub requires an updated version of <rpcndr.h>
#endif /* __RPCNDR_H_VERSION__ */
#ifndef COM_NO_WINDOWS_H
#include "windows.h"
#include "ole2.h"
#endif /*COM_NO_WINDOWS_H*/
#ifndef __pluginauthenticator_h__
#define __pluginauthenticator_h__
#if defined(_MSC_VER) && (_MSC_VER >= 1020)
#pragma once
#endif
#ifndef DECLSPEC_XFGVIRT
#if defined(_CONTROL_FLOW_GUARD_XFG)
#define DECLSPEC_XFGVIRT(base, func) __declspec(xfg_virtual(base, func))
#else
#define DECLSPEC_XFGVIRT(base, func)
#endif
#endif
/* Forward Declarations */
#ifndef __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__
#define __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__
typedef interface EXPERIMENTAL_IPluginAuthenticator EXPERIMENTAL_IPluginAuthenticator;
#endif /* __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__ */
/* header files for imported files */
#include "oaidl.h"
#include "webauthn.h"
#ifdef __cplusplus
extern "C"{
#endif
/* interface __MIDL_itf_pluginauthenticator_0000_0000 */
/* [local] */
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST
{
HWND hWnd;
GUID transactionId;
DWORD cbRequestSignature;
/* [size_is] */ byte *pbRequestSignature;
DWORD cbEncodedRequest;
/* [size_is] */ byte *pbEncodedRequest;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST *EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE
{
DWORD cbEncodedResponse;
/* [size_is] */ byte *pbEncodedResponse;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE *EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST
{
GUID transactionId;
DWORD cbRequestSignature;
/* [size_is] */ byte *pbRequestSignature;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *EXPERIMENTAL_PWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_c_ifspec;
extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_s_ifspec;
#ifndef __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__
#define __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__
/* interface EXPERIMENTAL_IPluginAuthenticator */
/* [unique][version][uuid][object] */
EXTERN_C const IID IID_EXPERIMENTAL_IPluginAuthenticator;
#if defined(__cplusplus) && !defined(CINTERFACE)
MIDL_INTERFACE("e6466e9a-b2f3-47c5-b88d-89bc14a8d998")
EXPERIMENTAL_IPluginAuthenticator : public IUnknown
{
public:
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginMakeCredential(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response) = 0;
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginGetAssertion(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response) = 0;
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginCancelOperation(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request) = 0;
};
#else /* C style interface */
typedef struct EXPERIMENTAL_IPluginAuthenticatorVtbl
{
BEGIN_INTERFACE
DECLSPEC_XFGVIRT(IUnknown, QueryInterface)
HRESULT ( STDMETHODCALLTYPE *QueryInterface )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in REFIID riid,
/* [annotation][iid_is][out] */
_COM_Outptr_ void **ppvObject);
DECLSPEC_XFGVIRT(IUnknown, AddRef)
ULONG ( STDMETHODCALLTYPE *AddRef )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This);
DECLSPEC_XFGVIRT(IUnknown, Release)
ULONG ( STDMETHODCALLTYPE *Release )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginMakeCredential)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginMakeCredential )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginGetAssertion)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginGetAssertion )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginCancelOperation)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginCancelOperation )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request);
END_INTERFACE
} EXPERIMENTAL_IPluginAuthenticatorVtbl;
interface EXPERIMENTAL_IPluginAuthenticator
{
CONST_VTBL struct EXPERIMENTAL_IPluginAuthenticatorVtbl *lpVtbl;
};
#ifdef COBJMACROS
#define EXPERIMENTAL_IPluginAuthenticator_QueryInterface(This,riid,ppvObject) \
( (This)->lpVtbl -> QueryInterface(This,riid,ppvObject) )
#define EXPERIMENTAL_IPluginAuthenticator_AddRef(This) \
( (This)->lpVtbl -> AddRef(This) )
#define EXPERIMENTAL_IPluginAuthenticator_Release(This) \
( (This)->lpVtbl -> Release(This) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginMakeCredential(This,request,response) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginMakeCredential(This,request,response) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginGetAssertion(This,request,response) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginGetAssertion(This,request,response) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginCancelOperation(This,request) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginCancelOperation(This,request) )
#endif /* COBJMACROS */
#endif /* C style interface */
#endif /* __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__ */
/* Additional Prototypes for ALL interfaces */
unsigned long __RPC_USER HWND_UserSize( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserMarshal( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserUnmarshal(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * );
void __RPC_USER HWND_UserFree( __RPC__in unsigned long *, __RPC__in HWND * );
unsigned long __RPC_USER HWND_UserSize64( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserMarshal64( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserUnmarshal64(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * );
void __RPC_USER HWND_UserFree64( __RPC__in unsigned long *, __RPC__in HWND * );
/* end of Additional Prototypes */
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,11 @@
#![cfg(target_os = "windows")]
mod pa;
pub fn get_version_number() -> u64 {
unsafe { pa::WebAuthNGetApiVersionNumber() }.into()
}
pub fn add_authenticator() {
unimplemented!();
}

View File

@@ -0,0 +1,15 @@
/*
The 'pa' (plugin authenticator) module will contain the generated
bindgen code.
The attributes below will suppress warnings from the generated code.
*/
#![cfg(target_os = "windows")]
#![allow(clippy::all)]
#![allow(warnings)]
include!(concat!(
env!("OUT_DIR"),
"/windows_pluginauthenticator_bindings.rs"
));

View File

@@ -1,158 +0,0 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable tailwindcss/no-custom-classname -->
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
class="tw-container tw-mx-auto"
[formGroup]="formGroup"
>
<div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
<input
id="register-form_input_email"
bitInput
type="email"
formControlName="email"
[attr.readonly]="queryParamFromOrgInvite ? true : null"
/>
<bit-hint>{{ "emailAddressDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input id="register-form_input_name" bitInput type="text" formControlName="name" />
<bit-hint>{{ "yourNameDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div class="tw-mb-3">
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
id="register-form_input_master-password"
bitInput
type="password"
formControlName="masterPassword"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<bit-hint>
<span class="tw-font-semibold">{{ "important" | i18n }}</span>
{{ "masterPassImportant" | i18n }} {{ characterMinimumMessage }}
</bit-hint>
</bit-form-field>
<app-password-strength
[password]="formGroup.get('masterPassword')?.value"
[email]="formGroup.get('email')?.value"
[name]="formGroup.get('name')?.value"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "reTypeMasterPass" | i18n }}</bit-label>
<input
id="register-form_input_confirm-master-password"
bitInput
type="password"
formControlName="confirmMasterPassword"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input id="register-form_input_hint" bitInput type="text" formControlName="hint" />
<bit-hint>{{ "masterPassHintDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div [hidden]="!showCaptcha()">
<iframe id="hcaptcha_iframe" height="80" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<div class="tw-mb-4 tw-flex tw-items-start">
<input
class="mt-1"
type="checkbox"
bitCheckbox
id="checkForBreaches"
name="CheckBreach"
formControlName="checkForBreaches"
/>
<bit-label for="checkForBreaches"> {{ "checkForBreaches" | i18n }}</bit-label>
</div>
<div class="tw-mb-3 tw-flex tw-items-start" *ngIf="showTerms">
<input
class="mt-1"
id="register-form-input-accept-policies"
bitCheckbox
type="checkbox"
formControlName="acceptPolicies"
/>
<bit-label for="register-form-input-accept-policies">
{{ "acceptPolicies" | i18n }}<br />
<a bitLink href="https://bitwarden.com/terms/" target="_blank" rel="noreferrer">{{
"termsOfService" | i18n
}}</a
>,
<a bitLink href="https://bitwarden.com/privacy/" target="_blank" rel="noreferrer">{{
"privacyPolicy" | i18n
}}</a>
</bit-label>
</div>
<div class="tw-space-x-2 tw-pt-2">
<ng-container *ngIf="!accountCreated">
<button
[block]="true"
type="submit"
buttonType="primary"
bitButton
[loading]="form.loading"
>
{{ "createAccount" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="accountCreated">
<button
[block]="true"
type="submit"
buttonType="primary"
bitButton
[loading]="form.loading"
>
{{ "logIn" | i18n }}
</button>
</ng-container>
</div>
<p class="tw-m-0 tw-mt-5 tw-text-sm">
{{ "alreadyHaveAccount" | i18n }}
<a bitLink routerLink="/login">{{ "logIn" | i18n }}</a>
</p>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
</div>
</form>

View File

@@ -1,115 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/auth/components/register.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { RegisterRequest } from "@bitwarden/common/models/request/register.request";
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";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
@Component({
selector: "app-register-form",
templateUrl: "./register-form.component.html",
})
export class RegisterFormComponent extends BaseRegisterComponent implements OnInit {
@Input() queryParamEmail: string;
@Input() queryParamFromOrgInvite: boolean;
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
@Input() referenceDataValue: ReferenceEventRequest;
showErrorSummary = false;
characterMinimumMessage: string;
constructor(
formValidationErrorService: FormValidationErrorsService,
formBuilder: UntypedFormBuilder,
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
i18nService: I18nService,
keyService: KeyService,
apiService: ApiService,
platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
environmentService: EnvironmentService,
logService: LogService,
auditService: AuditService,
dialogService: DialogService,
acceptOrgInviteService: AcceptOrganizationInviteService,
toastService: ToastService,
) {
super(
formValidationErrorService,
formBuilder,
loginStrategyService,
router,
i18nService,
keyService,
apiService,
platformUtilsService,
environmentService,
logService,
auditService,
dialogService,
toastService,
);
this.modifyRegisterRequest = async (request: RegisterRequest) => {
// Org invites are deep linked. Non-existent accounts are redirected to the register page.
// Org user id and token are included here only for validation and two factor purposes.
const orgInvite = await acceptOrgInviteService.getOrganizationInvite();
if (orgInvite != null) {
request.organizationUserId = orgInvite.organizationUserId;
request.token = orgInvite.token;
}
// Invite is accepted after login (on deep link redirect).
};
}
async ngOnInit() {
await super.ngOnInit();
this.referenceData = this.referenceDataValue;
if (this.queryParamEmail) {
this.formGroup.get("email")?.setValue(this.queryParamEmail);
}
if (this.enforcedPolicyOptions != null && this.enforcedPolicyOptions.minLength > 0) {
this.characterMinimumMessage = "";
} else {
this.characterMinimumMessage = this.i18nService.t("characterMinimum", this.minimumLength);
}
}
async submit() {
if (
this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthResult.score,
this.formGroup.value.masterPassword,
this.enforcedPolicyOptions,
)
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
});
return;
}
await super.submit(false);
}
}

View File

@@ -1,14 +0,0 @@
import { NgModule } from "@angular/core";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { SharedModule } from "../../shared";
import { RegisterFormComponent } from "./register-form.component";
@NgModule({
imports: [SharedModule, PasswordCalloutComponent],
declarations: [RegisterFormComponent],
exports: [RegisterFormComponent],
})
export class RegisterFormModule {}

View File

@@ -1045,10 +1045,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.estimatedTax = invoice.taxAmount;
})
.catch((error) => {
const translatedMessage = this.i18nService.t(error.message);
this.toastService.showToast({
title: "",
variant: "error",
message: this.i18nService.t(error.message),
message:
!translatedMessage || translatedMessage === "" ? error.message : translatedMessage,
});
});
}

View File

@@ -294,6 +294,10 @@
</ng-template>
<ng-template #setupSelfHost>
<ng-container *ngIf="userOrg.hasReseller && resellerSeatsRemainingMessage">
<h2 bitTypography="h2" class="tw-mt-7">{{ "manageSubscription" | i18n }}</h2>
<p bitTypography="body1">{{ resellerSeatsRemainingMessage }}</p>
</ng-container>
<ng-container *ngIf="showSelfHost">
<h2 bitTypography="h2" class="tw-mt-7">
{{ "selfHostingTitleProper" | i18n }}

View File

@@ -4,13 +4,17 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, lastValueFrom, Observable, Subject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums";
import {
OrganizationApiKeyType,
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
@@ -61,12 +65,15 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
showSubscription = true;
showSelfHost = false;
organizationIsManagedByConsolidatedBillingMSP = false;
resellerSeatsRemainingMessage: string;
protected readonly subscriptionHiddenIcon = SubscriptionHiddenIcon;
protected readonly teamsStarter = ProductTierType.TeamsStarter;
private destroy$ = new Subject<void>();
private seatsRemainingMessage: string;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
@@ -79,6 +86,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
private configService: ConfigService,
private toastService: ToastService,
private billingApiService: BillingApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
) {}
async ngOnInit() {
@@ -104,6 +112,28 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
}
}
}
if (this.userOrg.hasReseller) {
const allUsers = await this.organizationUserApiService.getAllUsers(this.userOrg.id);
const userCount = allUsers.data.filter((user) =>
[
OrganizationUserStatusType.Invited,
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed,
].includes(user.status),
).length;
const remainingSeats = this.userOrg.seats - userCount;
const seatsRemaining = this.i18nService.t(
"seatsRemaining",
remainingSeats.toString(),
this.userOrg.seats.toString(),
);
this.resellerSeatsRemainingMessage = seatsRemaining;
}
}
ngOnDestroy() {

View File

@@ -1,17 +1,4 @@
<app-vertical-stepper #stepper linear>
<app-vertical-step
label="{{ 'createAccount' | i18n | titlecase }}"
[editable]="false"
[subLabel]="subLabels.createAccount"
[addSubLabelSpacing]="true"
>
<app-register-form
[referenceDataValue]="referenceEventRequest"
[isInTrialFlow]="true"
(createdAccount)="accountCreated($event)"
>
</app-register-form>
</app-vertical-step>
<app-vertical-step
label="{{ 'organizationInformation' | i18n | titlecase }}"
[subLabel]="subLabels.organizationInfo"

View File

@@ -1,17 +1,4 @@
<app-vertical-stepper #stepper linear>
<app-vertical-step
label="{{ 'createAccount' | i18n | titlecase }}"
[editable]="false"
[subLabel]="createAccountLabel"
[addSubLabelSpacing]="true"
>
<app-register-form
[referenceDataValue]="referenceEventRequest"
[isInTrialFlow]="true"
(createdAccount)="accountCreated($event)"
>
</app-register-form>
</app-vertical-step>
<app-vertical-step
label="{{ 'organizationInformation' | i18n | titlecase }}"
[subLabel]="subLabels.organizationInfo"

View File

@@ -19,7 +19,16 @@ import {
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
import { ValidOrgParams } from "../trial-initiation.component";
export enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
teamsStarter = "teamsStarter",
individual = "individual",
premium = "premium",
free = "free",
}
const trialFlowOrgs = [
ValidOrgParams.teams,

View File

@@ -1,149 +0,0 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<app-secrets-manager-trial
*ngIf="layout === layouts.secretsManager; else passwordManagerTrial"
></app-secrets-manager-trial>
<ng-template #passwordManagerTrial>
<div *ngIf="accountCreateOnly" class="">
<h1 class="tw-mt-12 tw-text-center tw-text-xl">{{ "createAccount" | i18n }}</h1>
<div
class="tw-min-w-xl tw-m-auto tw-max-w-xl tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8"
>
<app-register-form
[queryParamEmail]="email"
[queryParamFromOrgInvite]="fromOrgInvite"
[enforcedPolicyOptions]="enforcedPolicyOptions"
[referenceDataValue]="referenceData"
></app-register-form>
</div>
</div>
<div *ngIf="!accountCreateOnly">
<div class="tw-absolute tw--z-10 tw--mt-48 tw-h-[28rem] tw-w-full tw-bg-background-alt2"></div>
<div class="tw-min-w-4xl tw-mx-auto tw-flex tw-max-w-screen-xl tw-gap-12 tw-px-4">
<div class="tw-w-1/2">
<img
alt="Bitwarden"
style="height: 50px; width: 335px"
class="tw-mt-6"
src="../../images/register-layout/logo-horizontal-white.svg"
/>
<div class="tw-pt-12">
<!-- Layout params are used by marketing to determine left-hand content -->
<app-default-content *ngIf="layout === layouts.default"></app-default-content>
<app-teams-content *ngIf="layout === layouts.teams"></app-teams-content>
<app-teams1-content *ngIf="layout === layouts.teams1"></app-teams1-content>
<app-teams2-content *ngIf="layout === layouts.teams2"></app-teams2-content>
<app-teams3-content *ngIf="layout === layouts.teams3"></app-teams3-content>
<app-enterprise-content *ngIf="layout === layouts.enterprise"></app-enterprise-content>
<app-enterprise1-content *ngIf="layout === layouts.enterprise1"></app-enterprise1-content>
<app-enterprise2-content *ngIf="layout === layouts.enterprise2"></app-enterprise2-content>
<app-cnet-enterprise-content
*ngIf="layout === layouts.cnetcmpgnent"
></app-cnet-enterprise-content>
<app-cnet-individual-content
*ngIf="layout === layouts.cnetcmpgnind"
></app-cnet-individual-content>
<app-cnet-teams-content
*ngIf="layout === layouts.cnetcmpgnteams"
></app-cnet-teams-content>
<app-abm-enterprise-content
*ngIf="layout === layouts.abmenterprise"
></app-abm-enterprise-content>
<app-abm-teams-content *ngIf="layout === layouts.abmteams"></app-abm-teams-content>
</div>
</div>
<div class="tw-w-1/2">
<div *ngIf="!useTrialStepper">
<div
class="tw-min-w-xl tw-m-auto tw-mt-28 tw-max-w-xl tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8"
>
<app-register-form
[queryParamEmail]="email"
[enforcedPolicyOptions]="enforcedPolicyOptions"
[referenceDataValue]="referenceData"
></app-register-form>
</div>
</div>
<div class="tw-pt-44" *ngIf="useTrialStepper">
<div
class="tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background"
>
<div class="tw-flex tw-h-auto tw-w-full tw-gap-5 tw-rounded-t tw-bg-secondary-100">
<h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
{{ freeTrialText }}
</h2>
<environment-selector
class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 tw-text-end"
></environment-selector>
</div>
<app-vertical-stepper #stepper linear (selectionChange)="stepSelectionChange($event)">
<app-vertical-step label="Create Account" [editable]="false" [subLabel]="email">
<app-register-form
[isInTrialFlow]="true"
(createdAccount)="createdAccount($event)"
[referenceDataValue]="referenceData"
></app-register-form>
</app-vertical-step>
<app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel">
<app-org-info [nameOnly]="true" [formGroup]="orgInfoFormGroup"></app-org-info>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="orgInfoFormGroup.get('name').invalid"
[loading]="loading"
(click)="createOrganizationOnTrial()"
>
{{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
</button>
</app-vertical-step>
<app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="!(enableTrialPayment$ | async)"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.get('name').value,
email: orgInfoFormGroup.get('email').value,
type: trialOrganizationType,
}"
[subscriptionProduct]="SubscriptionProduct.PasswordManager"
(steppedBack)="previousStep()"
(organizationCreated)="createdOrganization($event)"
>
</app-trial-billing-step>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
<app-trial-confirmation-details
[email]="email"
[orgLabel]="orgLabel"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<button
type="button"
bitButton
buttonType="primary"
(click)="navigateToOrgVault()"
>
{{ "getStarted" | i18n | titlecase }}
</button>
<button
type="button"
bitButton
buttonType="secondary"
(click)="navigateToOrgInvite()"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
{{ "inviteUsers" | i18n }}
</button>
</div>
</app-vertical-step>
</app-vertical-stepper>
</div>
</div>
</div>
</div>
</div>
</ng-template>

View File

@@ -1,336 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { FormBuilder, UntypedFormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { AcceptOrganizationInviteService } from "../../auth/organization-invite/accept-organization.service";
import { OrganizationInvite } from "../../auth/organization-invite/organization-invite";
import { RouterService } from "../../core";
import { SharedModule } from "../../shared";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
describe("TrialInitiationComponent", () => {
let component: TrialInitiationComponent;
let fixture: ComponentFixture<TrialInitiationComponent>;
const mockQueryParams = new BehaviorSubject<any>({ org: "enterprise" });
const testOrgId = "91329456-5b9f-44b3-9279-6bb9ee6a0974";
const formBuilder: FormBuilder = new FormBuilder();
let routerSpy: jest.SpyInstance;
let stateServiceMock: MockProxy<StateService>;
let policyApiServiceMock: MockProxy<PolicyApiServiceAbstraction>;
let policyServiceMock: MockProxy<PolicyService>;
let routerServiceMock: MockProxy<RouterService>;
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
let organizationBillingServiceMock: MockProxy<OrganizationBillingService>;
let configServiceMock: MockProxy<ConfigService>;
beforeEach(() => {
// only define services directly that we want to mock return values in this component
stateServiceMock = mock<StateService>();
policyApiServiceMock = mock<PolicyApiServiceAbstraction>();
policyServiceMock = mock<PolicyService>();
routerServiceMock = mock<RouterService>();
acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>();
organizationBillingServiceMock = mock<OrganizationBillingService>();
configServiceMock = mock<ConfigService>();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
imports: [
SharedModule,
RouterTestingModule.withRoutes([
{ path: "trial", component: TrialInitiationComponent },
{
path: `organizations/${testOrgId}/vault`,
component: BlankComponent,
},
{
path: `organizations/${testOrgId}/members`,
component: BlankComponent,
},
]),
],
declarations: [TrialInitiationComponent, I18nPipe],
providers: [
UntypedFormBuilder,
{
provide: ActivatedRoute,
useValue: {
queryParams: mockQueryParams.asObservable(),
},
},
{ provide: StateService, useValue: stateServiceMock },
{ provide: PolicyService, useValue: policyServiceMock },
{ provide: PolicyApiServiceAbstraction, useValue: policyApiServiceMock },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: TitleCasePipe, useValue: mock<TitleCasePipe>() },
{
provide: VerticalStepperComponent,
useClass: VerticalStepperStubComponent,
},
{
provide: RouterService,
useValue: routerServiceMock,
},
{
provide: AcceptOrganizationInviteService,
useValue: acceptOrgInviteServiceMock,
},
{
provide: OrganizationBillingService,
useValue: organizationBillingServiceMock,
},
{
provide: ConfigService,
useValue: configServiceMock,
},
],
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
// These tests demonstrate mocking service calls
describe("onInit() enforcedPolicyOptions", () => {
it("should not set enforcedPolicyOptions if there isn't an org invite in deep linked url", async () => {
acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce(null);
// Need to recreate component with new service mock
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
await component.ngOnInit();
expect(component.enforcedPolicyOptions).toBe(undefined);
});
it("should set enforcedPolicyOptions if the deep linked url has an org invite", async () => {
// Set up service method mocks
acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce({
organizationId: testOrgId,
token: "token",
email: "testEmail",
organizationUserId: "123",
} as OrganizationInvite);
policyApiServiceMock.getPoliciesByToken.mockReturnValueOnce(
Promise.resolve([
{
id: "345",
organizationId: testOrgId,
type: 1,
data: {
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
},
enabled: true,
},
] as Policy[]),
);
policyServiceMock.masterPasswordPolicyOptions$.mockReturnValue(
of({
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
} as MasterPasswordPolicyOptions),
);
// Need to recreate component with new service mocks
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
await component.ngOnInit();
expect(component.enforcedPolicyOptions).toMatchObject({
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
});
});
});
// These tests demonstrate route params
describe("Route params", () => {
it("should set org variable to be enterprise and plan to EnterpriseAnnually if org param is enterprise", fakeAsync(() => {
mockQueryParams.next({ org: "enterprise" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("enterprise");
expect(component.plan).toBe(PlanType.EnterpriseAnnually);
}));
it("should not set org variable if no org param is provided", fakeAsync(() => {
mockQueryParams.next({});
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("");
expect(component.accountCreateOnly).toBe(true);
}));
it("should not set the org if org param is invalid ", fakeAsync(async () => {
mockQueryParams.next({ org: "hahahaha" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("");
expect(component.accountCreateOnly).toBe(true);
}));
it("should set the layout variable if layout param is valid ", fakeAsync(async () => {
mockQueryParams.next({ layout: "teams1" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.layout).toBe("teams1");
expect(component.accountCreateOnly).toBe(false);
}));
it("should not set the layout variable and leave as 'default' if layout param is invalid ", fakeAsync(async () => {
mockQueryParams.next({ layout: "asdfasdf" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component.ngOnInit();
expect(component.layout).toBe("default");
expect(component.accountCreateOnly).toBe(true);
}));
});
// These tests demonstrate the use of a stub component
describe("createAccount()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should set email and call verticalStepper.next()", fakeAsync(() => {
const verticalStepperNext = jest.spyOn(component.verticalStepper, "next");
component.createdAccount("test@email.com");
expect(verticalStepperNext).toHaveBeenCalled();
expect(component.email).toBe("test@email.com");
}));
});
describe("billingSuccess()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should set orgId and call verticalStepper.next()", () => {
const verticalStepperNext = jest.spyOn(component.verticalStepper, "next");
component.billingSuccess({ orgId: testOrgId });
expect(verticalStepperNext).toHaveBeenCalled();
expect(component.orgId).toBe(testOrgId);
});
});
describe("stepSelectionChange()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("on step 2 should show organization copy text", () => {
component.stepSelectionChange({
selectedIndex: 1,
previouslySelectedIndex: 0,
} as StepperSelectionEvent);
expect(component.orgInfoSubLabel).toContain("Enter your");
expect(component.orgInfoSubLabel).toContain(" organization information");
});
it("going from step 2 to 3 should set the orgInforSubLabel to be the Org name from orgInfoFormGroup", () => {
component.orgInfoFormGroup = formBuilder.group({
name: ["Hooli"],
email: [""],
});
component.stepSelectionChange({
selectedIndex: 2,
previouslySelectedIndex: 1,
} as StepperSelectionEvent);
expect(component.orgInfoSubLabel).toContain("Hooli");
});
});
describe("previousStep()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should call verticalStepper.previous()", fakeAsync(() => {
const verticalStepperPrevious = jest.spyOn(component.verticalStepper, "previous");
component.previousStep();
expect(verticalStepperPrevious).toHaveBeenCalled();
}));
});
// These tests demonstrate router navigation
describe("navigation methods", () => {
beforeEach(() => {
component.orgId = testOrgId;
const router = TestBed.inject(Router);
fixture.detectChanges();
routerSpy = jest.spyOn(router, "navigate");
});
describe("navigateToOrgVault", () => {
it("should call verticalStepper.previous()", fakeAsync(() => {
component.navigateToOrgVault();
expect(routerSpy).toHaveBeenCalledWith(["organizations", testOrgId, "vault"]);
}));
});
describe("navigateToOrgVault", () => {
it("should call verticalStepper.previous()", fakeAsync(() => {
component.navigateToOrgInvite();
expect(routerSpy).toHaveBeenCalledWith(["organizations", testOrgId, "members"]);
}));
});
});
});
export class VerticalStepperStubComponent extends VerticalStepperComponent {}
export class BlankComponent {} // For router tests

View File

@@ -1,353 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import {
OrganizationInformation,
PlanInformation,
OrganizationBillingServiceAbstraction as OrganizationBillingService,
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AcceptOrganizationInviteService } from "../../auth/organization-invite/accept-organization.service";
import { OrganizationInvite } from "../../auth/organization-invite/organization-invite";
import {
OrganizationCreatedEvent,
SubscriptionProduct,
TrialOrganizationType,
} from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { RouterService } from "./../../core/router.service";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
export enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
teamsStarter = "teamsStarter",
individual = "individual",
premium = "premium",
free = "free",
}
enum ValidLayoutParams {
default = "default",
teams = "teams",
teams1 = "teams1",
teams2 = "teams2",
teams3 = "teams3",
enterprise = "enterprise",
enterprise1 = "enterprise1",
enterprise2 = "enterprise2",
cnetcmpgnent = "cnetcmpgnent",
cnetcmpgnind = "cnetcmpgnind",
cnetcmpgnteams = "cnetcmpgnteams",
abmenterprise = "abmenterprise",
abmteams = "abmteams",
secretsManager = "secretsManager",
}
@Component({
selector: "app-trial",
templateUrl: "trial-initiation.component.html",
})
export class TrialInitiationComponent implements OnInit, OnDestroy {
email = "";
fromOrgInvite = false;
org = "";
orgInfoSubLabel = "";
orgId = "";
orgLabel = "";
billingSubLabel = "";
layout = "default";
plan: PlanType;
productTier: ProductTierType;
accountCreateOnly = true;
useTrialStepper = false;
loading = false;
policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions;
trialFlowOrgs: string[] = [
ValidOrgParams.teams,
ValidOrgParams.teamsStarter,
ValidOrgParams.enterprise,
ValidOrgParams.families,
];
routeFlowOrgs: string[] = [
ValidOrgParams.free,
ValidOrgParams.premium,
ValidOrgParams.individual,
];
layouts = ValidLayoutParams;
referenceData: ReferenceEventRequest;
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
orgInfoFormGroup = this.formBuilder.group({
name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }],
email: [""],
});
private set referenceDataId(referenceId: string) {
if (referenceId != null) {
this.referenceData.id = referenceId;
} else {
this.referenceData.id = ("; " + document.cookie)
.split("; reference=")
.pop()
.split(";")
.shift();
}
if (this.referenceData.id === "") {
this.referenceData.id = null;
} else {
// Matches "_ga_QBRN562QQQ=value1.value2.session" and captures values and session.
const regex = /_ga_QBRN562QQQ=([^.]+)\.([^.]+)\.(\d+)/;
const match = document.cookie.match(regex);
if (match) {
this.referenceData.session = match[3];
}
}
}
private destroy$ = new Subject<void>();
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);
constructor(
private route: ActivatedRoute,
protected router: Router,
private formBuilder: UntypedFormBuilder,
private titleCasePipe: TitleCasePipe,
private logService: LogService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private i18nService: I18nService,
private routerService: RouterService,
private acceptOrgInviteService: AcceptOrganizationInviteService,
private organizationBillingService: OrganizationBillingService,
private configService: ConfigService,
) {}
async ngOnInit(): Promise<void> {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
this.referenceData = new ReferenceEventRequest();
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
this.fromOrgInvite = qParams.fromOrgInvite === "true";
}
this.referenceDataId = qParams.reference;
if (Object.values(ValidLayoutParams).includes(qParams.layout)) {
this.layout = qParams.layout;
this.accountCreateOnly = false;
}
if (this.trialFlowOrgs.includes(qParams.org)) {
this.org = qParams.org;
this.orgLabel = this.titleCasePipe.transform(this.orgDisplayName);
this.useTrialStepper = true;
this.referenceData.flow = qParams.org;
if (this.org === ValidOrgParams.families) {
this.plan = PlanType.FamiliesAnnually;
this.productTier = ProductTierType.Families;
} else if (this.org === ValidOrgParams.teamsStarter) {
this.plan = PlanType.TeamsStarter;
this.productTier = ProductTierType.TeamsStarter;
} else if (this.org === ValidOrgParams.teams) {
this.plan = PlanType.TeamsAnnually;
this.productTier = ProductTierType.Teams;
} else if (this.org === ValidOrgParams.enterprise) {
this.plan = PlanType.EnterpriseAnnually;
this.productTier = ProductTierType.Enterprise;
}
} else if (this.routeFlowOrgs.includes(qParams.org)) {
this.referenceData.flow = qParams.org;
const route = this.router.createUrlTree(["create-organization"], {
queryParams: { plan: qParams.org },
});
this.routerService.setPreviousUrl(route.toString());
}
// Are they coming from an email for sponsoring a families organization
// After logging in redirect them to setup the families sponsorship
this.setupFamilySponsorship(qParams.sponsorshipToken);
this.referenceData.initiationPath = this.accountCreateOnly
? "Registration form"
: "Password Manager trial from marketing website";
});
// If there's a deep linked org invite, use it to get the password policies
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
if (orgInvite != null) {
await this.initPasswordPolicies(orgInvite);
}
this.orgInfoFormGroup.controls.name.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.orgInfoFormGroup.controls.name.markAsTouched();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
stepSelectionChange(event: StepperSelectionEvent) {
// Set org info sub label
if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") {
this.orgInfoSubLabel =
"Enter your " +
this.titleCasePipe.transform(this.orgDisplayName) +
" organization information";
} else if (event.previouslySelectedIndex === 1) {
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value;
}
//set billing sub label
if (event.selectedIndex === 2) {
this.billingSubLabel = this.i18nService.t("billingTrialSubLabel");
}
}
async createOrganizationOnTrial() {
this.loading = true;
const organization: OrganizationInformation = {
name: this.orgInfoFormGroup.get("name").value,
billingEmail: this.orgInfoFormGroup.get("email").value,
initiationPath: "Password Manager trial from marketing website",
};
const plan: PlanInformation = {
type: this.plan,
passwordManagerSeats: 1,
};
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
organization,
plan,
});
this.orgId = response?.id;
this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`;
this.loading = false;
this.verticalStepper.next();
}
createdAccount(email: string) {
this.email = email;
this.orgInfoFormGroup.get("email")?.setValue(email);
this.verticalStepper.next();
}
billingSuccess(event: any) {
this.orgId = event?.orgId;
this.billingSubLabel = event?.subLabelText;
this.verticalStepper.next();
}
createdOrganization(event: OrganizationCreatedEvent) {
this.orgId = event.organizationId;
this.billingSubLabel = event.planDescription;
this.verticalStepper.next();
}
navigateToOrgVault() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["organizations", this.orgId, "vault"]);
}
navigateToOrgInvite() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["organizations", this.orgId, "members"]);
}
previousStep() {
this.verticalStepper.previous();
}
get orgDisplayName() {
if (this.org === "teamsStarter") {
return "Teams Starter";
}
return this.org;
}
get freeTrialText() {
const translationKey =
this.layout === this.layouts.secretsManager
? "startYour7DayFreeTrialOfBitwardenSecretsManagerFor"
: "startYour7DayFreeTrialOfBitwardenFor";
return this.i18nService.t(translationKey, this.org);
}
get trialOrganizationType(): TrialOrganizationType {
switch (this.productTier) {
case ProductTierType.Free:
return null;
default:
return this.productTier;
}
}
private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
queryParams: { plan: sponsorshipToken },
});
this.routerService.setPreviousUrl(route.toString());
}
}
private async initPasswordPolicies(invite: OrganizationInvite): Promise<void> {
if (invite == null) {
return;
}
try {
this.policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
if (this.policies != null) {
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPolicyOptions = enforcedPasswordPolicyOptions;
});
}
}
protected readonly SubscriptionProduct = SubscriptionProduct;
}

View File

@@ -6,7 +6,6 @@ import { InputPasswordComponent } from "@bitwarden/auth/angular";
import { FormFieldModule } from "@bitwarden/components";
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
import { RegisterFormModule } from "../../auth/register-form/register-form.module";
import { TaxInfoComponent } from "../../billing";
import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { SecretsManagerTrialFreeStepperComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component";
@@ -39,7 +38,6 @@ import { TeamsContentComponent } from "./content/teams-content.component";
import { Teams1ContentComponent } from "./content/teams1-content.component";
import { Teams2ContentComponent } from "./content/teams2-content.component";
import { Teams3ContentComponent } from "./content/teams3-content.component";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
@NgModule({
@@ -48,7 +46,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
CdkStepperModule,
VerticalStepperModule,
FormFieldModule,
RegisterFormModule,
OrganizationCreateModule,
EnvironmentSelectorModule,
TaxInfoComponent,
@@ -56,7 +53,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
InputPasswordComponent,
],
declarations: [
TrialInitiationComponent,
CompleteTrialInitiationComponent,
EnterpriseContentComponent,
TeamsContentComponent,
@@ -87,7 +83,7 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
SecretsManagerTrialFreeStepperComponent,
SecretsManagerTrialPaidStepperComponent,
],
exports: [TrialInitiationComponent, CompleteTrialInitiationComponent],
exports: [CompleteTrialInitiationComponent],
providers: [TitleCasePipe],
})
export class TrialInitiationModule {}

View File

@@ -20,7 +20,6 @@ import { WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent } from
import { HintComponent } from "../auth/hint.component";
import { RecoverDeleteComponent } from "../auth/recover-delete.component";
import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component";
import { RegisterFormModule } from "../auth/register-form/register-form.module";
import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component";
import { AccountComponent } from "../auth/settings/account/account.component";
@@ -90,7 +89,6 @@ import { SharedModule } from "./shared.module";
@NgModule({
imports: [
SharedModule,
RegisterFormModule,
ProductSwitcherModule,
UserVerificationModule,
ChangeKdfModule,

View File

@@ -10333,5 +10333,18 @@
"example": "Acme c"
}
}
},
"seatsRemaining": {
"message": "You have $REMAINING$ seats remaining out of $TOTAL$ seats assigned to this organization. Contact your provider to manage your subscription.",
"placeholders": {
"remaining": {
"content": "$1",
"example": "5"
},
"total": {
"content": "$2",
"example": "10"
}
}
}
}

View File

@@ -0,0 +1,34 @@
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/vault";
/**
* Request type for creating tasks.
* @property cipherId - Optional. The ID of the cipher to create the task for.
* @property type - The type of task to create. Currently defined as "updateAtRiskCredential".
*/
export type CreateTasksRequest = Readonly<{
cipherId?: CipherId;
type: SecurityTaskType.UpdateAtRiskCredential;
}>;
export abstract class AdminTaskService {
/**
* Retrieves all tasks for a given organization.
* @param organizationId - The ID of the organization to retrieve tasks for.
* @param status - Optional. The status of the tasks to retrieve.
*/
abstract getAllTasks(
organizationId: OrganizationId,
status?: SecurityTaskStatus | undefined,
): Promise<SecurityTask[]>;
/**
* Creates multiple tasks for a given organization and sends out notifications to applicable users.
* @param organizationId - The ID of the organization to create tasks for.
* @param tasks - The tasks to create.
*/
abstract bulkCreateTasks(
organizationId: OrganizationId,
tasks: CreateTasksRequest[],
): Promise<void>;
}

View File

@@ -0,0 +1,65 @@
import { MockProxy, mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskStatus, SecurityTaskType } from "@bitwarden/vault";
import { CreateTasksRequest } from "./abstractions/admin-task.abstraction";
import { DefaultAdminTaskService } from "./default-admin-task.service";
describe("DefaultAdminTaskService", () => {
let defaultAdminTaskService: DefaultAdminTaskService;
let apiService: MockProxy<ApiService>;
beforeEach(() => {
apiService = mock<ApiService>();
defaultAdminTaskService = new DefaultAdminTaskService(apiService);
});
describe("getAllTasks", () => {
it("should call the api service with the correct parameters with status", async () => {
const organizationId = "orgId" as OrganizationId;
const status = SecurityTaskStatus.Pending;
const expectedUrl = `/tasks/organization?organizationId=${organizationId}&status=0`;
await defaultAdminTaskService.getAllTasks(organizationId, status);
expect(apiService.send).toHaveBeenCalledWith("GET", expectedUrl, null, true, true);
});
it("should call the api service with the correct parameters without status", async () => {
const organizationId = "orgId" as OrganizationId;
const expectedUrl = `/tasks/organization?organizationId=${organizationId}`;
await defaultAdminTaskService.getAllTasks(organizationId);
expect(apiService.send).toHaveBeenCalledWith("GET", expectedUrl, null, true, true);
});
});
describe("bulkCreateTasks", () => {
it("should call the api service with the correct parameters", async () => {
const organizationId = "orgId" as OrganizationId;
const tasks: CreateTasksRequest[] = [
{
cipherId: "cipherId-1" as CipherId,
type: SecurityTaskType.UpdateAtRiskCredential,
},
{
cipherId: "cipherId-2" as CipherId,
type: SecurityTaskType.UpdateAtRiskCredential,
},
];
await defaultAdminTaskService.bulkCreateTasks(organizationId, tasks);
expect(apiService.send).toHaveBeenCalledWith(
"POST",
`/tasks/${organizationId}/bulk-create`,
tasks,
true,
true,
);
});
});
});

View File

@@ -0,0 +1,48 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
SecurityTask,
SecurityTaskData,
SecurityTaskResponse,
SecurityTaskStatus,
} from "@bitwarden/vault";
import { AdminTaskService, CreateTasksRequest } from "./abstractions/admin-task.abstraction";
@Injectable()
export class DefaultAdminTaskService implements AdminTaskService {
constructor(private apiService: ApiService) {}
async getAllTasks(
organizationId: OrganizationId,
status?: SecurityTaskStatus | undefined,
): Promise<SecurityTask[]> {
const queryParams = new URLSearchParams();
queryParams.append("organizationId", organizationId);
if (status !== undefined) {
queryParams.append("status", status.toString());
}
const r = await this.apiService.send(
"GET",
`/tasks/organization?${queryParams.toString()}`,
null,
true,
true,
);
const response = new ListResponse(r, SecurityTaskResponse);
return response.data.map((d) => new SecurityTask(new SecurityTaskData(d)));
}
async bulkCreateTasks(
organizationId: OrganizationId,
tasks: CreateTasksRequest[],
): Promise<void> {
await this.apiService.send("POST", `/tasks/${organizationId}/bulk-create`, tasks, true, true);
}
}

View File

@@ -11,6 +11,8 @@ import rxjs from "eslint-plugin-rxjs";
import angularRxjs from "eslint-plugin-rxjs-angular";
import storybook from "eslint-plugin-storybook";
import platformPlugins from "./libs/eslint/platform/index.mjs";
export default tseslint.config(
...storybook.configs["flat/recommended"],
{
@@ -28,6 +30,7 @@ export default tseslint.config(
plugins: {
rxjs: rxjs,
"rxjs-angular": angularRxjs,
"@bitwarden/platform": platformPlugins,
},
languageOptions: {
parserOptions: {
@@ -66,7 +69,7 @@ export default tseslint.config(
"@angular-eslint/no-outputs-metadata-property": 0,
"@angular-eslint/use-lifecycle-interface": "error",
"@angular-eslint/use-pipe-transform-interface": 0,
"@bitwarden/platform/required-using": "error",
"@typescript-eslint/explicit-member-accessibility": ["error", { accessibility: "no-public" }],
"@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled
"@typescript-eslint/no-floating-promises": "error",

View File

@@ -30,6 +30,7 @@ module.exports = {
"<rootDir>/libs/billing/jest.config.js",
"<rootDir>/libs/common/jest.config.js",
"<rootDir>/libs/components/jest.config.js",
"<rootDir>/libs/eslint/jest.config.js",
"<rootDir>/libs/tools/export/vault-export/vault-export-core/jest.config.js",
"<rootDir>/libs/tools/generator/core/jest.config.js",
"<rootDir>/libs/tools/generator/components/jest.config.js",

View File

@@ -120,7 +120,7 @@
<div class="tw-mb-6" *ngIf="sentInitialCode">
{{ "enterVerificationCodeSentToEmail" | i18n }}
<p class="mb-0">
<p class="tw-mb-0">
<button bitLink type="button" linkType="primary" (click)="requestOTP()">
{{ "resendCode" | i18n }}
</button>

View File

@@ -3,6 +3,7 @@ import { Observable } from "rxjs";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { UserId } from "../../../types/guid";
import { Rc } from "../../misc/reference-counting/rc";
export abstract class SdkService {
/**
@@ -27,5 +28,5 @@ export abstract class SdkService {
*
* @param userId
*/
abstract userClient$(userId: UserId): Observable<BitwardenClient | undefined>;
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined>;
}

View File

@@ -0,0 +1,93 @@
// Temporary workaround for Symbol.dispose
// remove when https://github.com/jestjs/jest/issues/14874 is resolved and *released*
const disposeSymbol: unique symbol = Symbol("Symbol.dispose");
const asyncDisposeSymbol: unique symbol = Symbol("Symbol.asyncDispose");
(Symbol as any).asyncDispose ??= asyncDisposeSymbol as unknown as SymbolConstructor["asyncDispose"];
(Symbol as any).dispose ??= disposeSymbol as unknown as SymbolConstructor["dispose"];
// Import needs to be after the workaround
import { Rc } from "./rc";
export class FreeableTestValue {
isFreed = false;
free() {
this.isFreed = true;
}
}
describe("Rc", () => {
let value: FreeableTestValue;
let rc: Rc<FreeableTestValue>;
beforeEach(() => {
value = new FreeableTestValue();
rc = new Rc(value);
});
it("should increase refCount when taken", () => {
rc.take();
expect(rc["refCount"]).toBe(1);
});
it("should return value on take", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
expect(reference.value).toBe(value);
});
it("should decrease refCount when disposing reference", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
reference[Symbol.dispose]();
expect(rc["refCount"]).toBe(0);
});
it("should automatically decrease refCount when reference goes out of scope", () => {
{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
using reference = rc.take();
}
expect(rc["refCount"]).toBe(0);
});
it("should not free value when refCount reaches 0 if not marked for disposal", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
reference[Symbol.dispose]();
expect(value.isFreed).toBe(false);
});
it("should free value when refCount reaches 0 and rc is marked for disposal", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
rc.markForDisposal();
reference[Symbol.dispose]();
expect(value.isFreed).toBe(true);
});
it("should free value when marked for disposal if refCount is 0", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
reference[Symbol.dispose]();
rc.markForDisposal();
expect(value.isFreed).toBe(true);
});
it("should throw error when trying to take a disposed reference", () => {
rc.markForDisposal();
expect(() => rc.take()).toThrow();
});
});

View File

@@ -0,0 +1,76 @@
import { UsingRequired } from "../using-required";
export type Freeable = { free: () => void };
/**
* Reference counted disposable value.
* This class is used to manage the lifetime of a value that needs to be
* freed of at a specific time but might still be in-use when that happens.
*/
export class Rc<T extends Freeable> {
private markedForDisposal = false;
private refCount = 0;
private value: T;
constructor(value: T) {
this.value = value;
}
/**
* Use this function when you want to use the underlying object.
* This will guarantee that you have a reference to the object
* and that it won't be freed until your reference goes out of scope.
*
* This function must be used with the `using` keyword.
*
* @example
* ```typescript
* function someFunction(rc: Rc<SomeValue>) {
* using reference = rc.take();
* reference.value.doSomething();
* // reference is automatically disposed here
* }
* ```
*
* @returns The value.
*/
take(): Ref<T> {
if (this.markedForDisposal) {
throw new Error("Cannot take a reference to a value marked for disposal");
}
this.refCount++;
return new Ref(() => this.release(), this.value);
}
/**
* Mark this Rc for disposal. When the refCount reaches 0, the value
* will be freed.
*/
markForDisposal() {
this.markedForDisposal = true;
this.freeIfPossible();
}
private release() {
this.refCount--;
this.freeIfPossible();
}
private freeIfPossible() {
if (this.refCount === 0 && this.markedForDisposal) {
this.value.free();
}
}
}
export class Ref<T extends Freeable> implements UsingRequired {
constructor(
private readonly release: () => void,
readonly value: T,
) {}
[Symbol.dispose]() {
this.release();
}
}

View File

@@ -0,0 +1,11 @@
export type Disposable = { [Symbol.dispose]: () => void };
/**
* Types implementing this type must be used together with the `using` keyword
*
* @example using ref = rc.take();
*/
// We want to use `interface` here because it creates a separate type.
// Type aliasing would not expose `UsingRequired` to the linter.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UsingRequired extends Disposable {}

View File

@@ -10,6 +10,7 @@ import { UserKey } from "../../../types/key";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { Rc } from "../../misc/reference-counting/rc";
import { EncryptedString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
@@ -75,15 +76,14 @@ describe("DefaultSdkService", () => {
});
it("creates an SDK client when called the first time", async () => {
const result = await firstValueFrom(service.userClient$(userId));
await firstValueFrom(service.userClient$(userId));
expect(result).toBe(mockClient);
expect(sdkClientFactory.createSdkClient).toHaveBeenCalled();
});
it("does not create an SDK client when called the second time with same userId", async () => {
const subject_1 = new BehaviorSubject(undefined);
const subject_2 = new BehaviorSubject(undefined);
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
// Use subjects to ensure the subscription is kept alive
service.userClient$(userId).subscribe(subject_1);
@@ -92,14 +92,14 @@ describe("DefaultSdkService", () => {
// Wait for the next tick to ensure all async operations are done
await new Promise(process.nextTick);
expect(subject_1.value).toBe(mockClient);
expect(subject_2.value).toBe(mockClient);
expect(subject_1.value.take().value).toBe(mockClient);
expect(subject_2.value.take().value).toBe(mockClient);
expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1);
});
it("destroys the SDK client when all subscriptions are closed", async () => {
const subject_1 = new BehaviorSubject(undefined);
const subject_2 = new BehaviorSubject(undefined);
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
await new Promise(process.nextTick);
@@ -107,6 +107,7 @@ describe("DefaultSdkService", () => {
subscription_1.unsubscribe();
subscription_2.unsubscribe();
await new Promise(process.nextTick);
expect(mockClient.free).toHaveBeenCalledTimes(1);
});
@@ -114,7 +115,7 @@ describe("DefaultSdkService", () => {
const userKey$ = new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey);
keyService.userKey$.calledWith(userId).mockReturnValue(userKey$);
const subject = new BehaviorSubject(undefined);
const subject = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
service.userClient$(userId).subscribe(subject);
await new Promise(process.nextTick);

View File

@@ -30,10 +30,11 @@ import { PlatformUtilsService } from "../../abstractions/platform-utils.service"
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { SdkService } from "../../abstractions/sdk/sdk.service";
import { compareValues } from "../../misc/compare-values";
import { Rc } from "../../misc/reference-counting/rc";
import { EncryptedString } from "../../models/domain/enc-string";
export class DefaultSdkService implements SdkService {
private sdkClientCache = new Map<UserId, Observable<BitwardenClient>>();
private sdkClientCache = new Map<UserId, Observable<Rc<BitwardenClient>>>();
client$ = this.environmentService.environment$.pipe(
concatMap(async (env) => {
@@ -58,7 +59,7 @@ export class DefaultSdkService implements SdkService {
private userAgent: string = null,
) {}
userClient$(userId: UserId): Observable<BitwardenClient | undefined> {
userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined> {
// TODO: Figure out what happens when the user logs out
if (this.sdkClientCache.has(userId)) {
return this.sdkClientCache.get(userId);
@@ -88,32 +89,31 @@ export class DefaultSdkService implements SdkService {
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => {
// Create our own observable to be able to implement clean-up logic
return new Observable<BitwardenClient>((subscriber) => {
let client: BitwardenClient;
return new Observable<Rc<BitwardenClient>>((subscriber) => {
const createAndInitializeClient = async () => {
if (privateKey == null || userKey == null) {
return undefined;
}
const settings = this.toSettings(env);
client = await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info);
const client = await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info);
await this.initializeClient(client, account, kdfParams, privateKey, userKey, orgKeys);
return client;
};
let client: Rc<BitwardenClient>;
createAndInitializeClient()
.then((c) => {
client = c;
subscriber.next(c);
client = c === undefined ? undefined : new Rc(c);
subscriber.next(client);
})
.catch((e) => {
subscriber.error(e);
});
return () => client?.free();
return () => client?.markForDisposal();
});
}),
tap({

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgIf, NgClass } from "@angular/common";
import { NgClass } from "@angular/common";
import { Component, Input, OnChanges } from "@angular/core";
import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
@@ -18,9 +18,11 @@ const SizeClasses: Record<SizeTypes, string[]> = {
@Component({
selector: "bit-avatar",
template: `<img *ngIf="src" [src]="src" title="{{ title || text }}" [ngClass]="classList" />`,
template: `@if (src) {
<img [src]="src" title="{{ title || text }}" [ngClass]="classList" />
}`,
standalone: true,
imports: [NgIf, NgClass],
imports: [NgClass],
})
export class AvatarComponent implements OnChanges {
@Input() border = false;

View File

@@ -1,11 +1,15 @@
<div class="tw-inline-flex tw-flex-wrap tw-gap-2">
<ng-container *ngFor="let item of filteredItems; let last = last">
@for (item of filteredItems; track item; let last = $last) {
<span bitBadge [variant]="variant" [truncate]="truncate">
{{ item }}
</span>
<span class="tw-sr-only" *ngIf="!last || isFiltered">, </span>
</ng-container>
<span *ngIf="isFiltered" bitBadge [variant]="variant">
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }}
</span>
@if (!last || isFiltered) {
<span class="tw-sr-only">, </span>
}
}
@if (isFiltered) {
<span bitBadge [variant]="variant">
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }}
</span>
}
</div>

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -11,7 +11,7 @@ import { BadgeModule, BadgeVariant } from "../badge";
selector: "bit-badge-list",
templateUrl: "badge-list.component.html",
standalone: true,
imports: [CommonModule, BadgeModule, I18nPipe],
imports: [BadgeModule, I18nPipe],
})
export class BadgeListComponent implements OnChanges {
private _maxItems: number;

View File

@@ -4,21 +4,24 @@
[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>
@if (icon) {
<i class="bwi tw-align-middle tw-text-base" [ngClass]="icon" aria-hidden="true"></i>
}
<!-- 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="main"
size="default"
(click)="onClose.emit()"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
@if (showClose) {
<button
class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main"
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
(click)="onClose.emit()"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
}
</div>

View File

@@ -1,3 +1,6 @@
<ng-template>
<i *ngIf="icon" class="bwi {{ icon }} !tw-mr-2" aria-hidden="true"></i><ng-content></ng-content>
@if (icon) {
<i class="bwi {{ icon }} !tw-mr-2" aria-hidden="true"></i>
}
<ng-content></ng-content>
</ng-template>

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgIf } from "@angular/common";
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core";
import { QueryParamsHandling } from "@angular/router";
@@ -8,7 +8,6 @@ import { QueryParamsHandling } from "@angular/router";
selector: "bit-breadcrumb",
templateUrl: "./breadcrumb.component.html",
standalone: true,
imports: [NgIf],
})
export class BreadcrumbComponent {
@Input()

View File

@@ -1,5 +1,5 @@
<ng-container *ngFor="let breadcrumb of beforeOverflow; let last = last">
<ng-container *ngIf="breadcrumb.route">
@for (breadcrumb of beforeOverflow; track breadcrumb; let last = $last) {
@if (breadcrumb.route) {
<a
bitLink
linkType="primary"
@@ -10,8 +10,8 @@
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>
</ng-container>
<ng-container *ngIf="!breadcrumb.route">
}
@if (!breadcrumb.route) {
<button
type="button"
bitLink
@@ -21,13 +21,16 @@
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</button>
</ng-container>
<i *ngIf="!last" class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
</ng-container>
<ng-container *ngIf="hasOverflow">
<i *ngIf="beforeOverflow.length > 0" class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
}
@if (!last) {
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
}
}
@if (hasOverflow) {
@if (beforeOverflow.length > 0) {
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
}
<button
type="button"
bitIconButton="bwi-ellipsis-h"
@@ -35,10 +38,9 @@
size="small"
aria-haspopup
></button>
<bit-menu #overflowMenu>
<ng-container *ngFor="let breadcrumb of overflow">
<ng-container *ngIf="breadcrumb.route">
@for (breadcrumb of overflow; track breadcrumb) {
@if (breadcrumb.route) {
<a
bitMenuItem
linkType="primary"
@@ -48,18 +50,17 @@
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>
</ng-container>
<ng-container *ngIf="!breadcrumb.route">
}
@if (!breadcrumb.route) {
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</button>
</ng-container>
</ng-container>
}
}
</bit-menu>
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
<ng-container *ngFor="let breadcrumb of afterOverflow; let last = last">
<ng-container *ngIf="breadcrumb.route">
@for (breadcrumb of afterOverflow; track breadcrumb; let last = $last) {
@if (breadcrumb.route) {
<a
bitLink
linkType="primary"
@@ -70,8 +71,8 @@
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>
</ng-container>
<ng-container *ngIf="!breadcrumb.route">
}
@if (!breadcrumb.route) {
<button
type="button"
bitLink
@@ -81,7 +82,9 @@
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</button>
</ng-container>
<i *ngIf="!last" class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
</ng-container>
</ng-container>
}
@if (!last) {
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
}
}
}

View File

@@ -86,16 +86,15 @@ export const DisabledWithAttribute: Story = {
render: (args) => ({
props: args,
template: `
<ng-container *ngIf="disabled">
@if (disabled) {
<button bitButton disabled [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton disabled [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
</ng-container>
<ng-container *ngIf="!disabled">
} @else {
<button bitButton [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
</ng-container>
}
`,
}),
args: {

View File

@@ -3,10 +3,14 @@
[ngClass]="calloutClass"
[attr.aria-labelledby]="titleId"
>
<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>
@if (title) {
<header id="{{ titleId }}" class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold">
@if (icon) {
<i class="bwi" [ngClass]="[icon, headerClass]" aria-hidden="true"></i>
}
{{ title }}
</header>
}
<div bitTypography="body2">
<ng-content></ng-content>
</div>

View File

@@ -1,10 +1,8 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
@Component({
selector: "bit-card",
standalone: true,
imports: [CommonModule],
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {

View File

@@ -30,78 +30,80 @@
<i class="bwi !tw-text-[inherit]" [ngClass]="icon"></i>
<span class="tw-truncate">{{ label }}</span>
</span>
<i
*ngIf="!selectedOption"
class="bwi tw-mt-0.5"
[ngClass]="menuTrigger.isOpen ? 'bwi-angle-up' : 'bwi-angle-down'"
></i>
@if (!selectedOption) {
<i
class="bwi tw-mt-0.5"
[ngClass]="menuTrigger.isOpen ? 'bwi-angle-up' : 'bwi-angle-down'"
></i>
}
</button>
<!-- Close button -->
<button
*ngIf="selectedOption"
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-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,
}"
(click)="clear()"
>
<i class="bwi bwi-close tw-text-xs"></i>
</button>
@if (selectedOption) {
<button
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-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,
}"
(click)="clear()"
>
<i class="bwi bwi-close tw-text-xs"></i>
</button>
}
</div>
<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>
{{ "backTo" | i18n: parent.label ?? placeholderText }}
</button>
<button
type="button"
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 }}
</button>
</ng-container>
<button
type="button"
bitMenuItem
*ngFor="let option of renderedOptions.children"
(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"
@if (renderedOptions) {
<div
class="tw-max-h-80 tw-min-w-32 tw-max-w-80 tw-text-sm"
[ngStyle]="menuWidth && { width: menuWidth + 'px' }"
>
<i
*ngIf="option.icon"
slot="start"
class="bwi"
[ngClass]="option.icon"
aria-hidden="true"
></i>
{{ option.label }}
<i *ngIf="option.children?.length" slot="end" class="bwi bwi-angle-right"></i>
</button>
</div>
@if (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>
{{ "backTo" | i18n: parent.label ?? placeholderText }}
</button>
<button
type="button"
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 }}
</button>
}
@for (option of renderedOptions.children; track option) {
<button
type="button"
bitMenuItem
(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"
>
@if (option.icon) {
<i slot="start" class="bwi" [ngClass]="option.icon" aria-hidden="true"></i>
}
{{ option.label }}
@if (option.children?.length) {
<i slot="end" class="bwi bwi-angle-right"></i>
}
</button>
}
</div>
}
</bit-menu>

View File

@@ -46,6 +46,7 @@ export type ChipSelectOption<T> = Option<T> & {
multi: true,
},
],
preserveWhitespaces: false,
})
export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, AfterViewInit {
@ViewChild(MenuComponent) menu: MenuComponent;

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgFor, NgIf } from "@angular/common";
import { Component, HostBinding, Input } from "@angular/core";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -14,18 +14,16 @@ enum CharacterType {
@Component({
selector: "bit-color-password",
template: `<span
*ngFor="let character of passwordArray; index as i"
[class]="getCharacterClass(character)"
>
<span>{{ character }}</span>
<span *ngIf="showCount" class="tw-whitespace-nowrap tw-text-xs tw-leading-5 tw-text-main">{{
i + 1
}}</span>
</span>`,
template: `@for (character of passwordArray; track character; let i = $index) {
<span [class]="getCharacterClass(character)">
<span>{{ character }}</span>
@if (showCount) {
<span class="tw-whitespace-nowrap tw-text-xs tw-leading-5 tw-text-main">{{ i + 1 }}</span>
}
</span>
}`,
preserveWhitespaces: false,
standalone: true,
imports: [NgFor, NgIf],
})
export class ColorPasswordComponent {
@Input() password: string = null;

View File

@@ -1,4 +1,3 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
/**
@@ -7,7 +6,6 @@ import { Component } from "@angular/core";
@Component({
selector: "bit-container",
templateUrl: "container.component.html",
imports: [CommonModule],
standalone: true,
})
export class ContainerComponent {}

View File

@@ -13,9 +13,11 @@
class="tw-text-main tw-mb-0 tw-truncate"
>
{{ title }}
<span *ngIf="subtitle" class="tw-text-muted tw-font-normal tw-text-sm">
{{ subtitle }}
</span>
@if (subtitle) {
<span class="tw-text-muted tw-font-normal tw-text-sm">
{{ subtitle }}
</span>
}
<ng-content select="[bitDialogTitle]"></ng-content>
</h1>
<button
@@ -35,9 +37,11 @@
'tw-min-h-60': loading,
}"
>
<div *ngIf="loading" class="tw-absolute tw-flex tw-size-full tw-items-center tw-justify-center">
<i class="bwi bwi-spinner bwi-spin bwi-lg" [attr.aria-label]="'loading' | i18n"></i>
</div>
@if (loading) {
<div class="tw-absolute tw-flex tw-size-full tw-items-center tw-justify-center">
<i class="bwi bwi-spinner bwi-spin bwi-lg" [attr.aria-label]="'loading' | i18n"></i>
</div>
}
<div
[ngClass]="{
'tw-p-4': !disablePadding,

View File

@@ -11,16 +11,17 @@
{{ acceptButtonText }}
</button>
<button
*ngIf="showCancelButton"
type="button"
bitButton
bitFormButton
buttonType="secondary"
(click)="dialogRef.close(false)"
>
{{ cancelButtonText }}
</button>
@if (showCancelButton) {
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
(click)="dialogRef.close(false)"
>
{{ cancelButtonText }}
</button>
}
</ng-container>
</bit-simple-dialog>
</form>

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { NgIf } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { FormGroup, ReactiveFormsModule } from "@angular/forms";
@@ -39,7 +38,6 @@ const DEFAULT_COLOR: Record<SimpleDialogType, string> = {
IconDirective,
ButtonComponent,
BitFormButtonDirective,
NgIf,
],
})
export class SimpleConfigurableDialogComponent {

View File

@@ -12,23 +12,24 @@ import { DialogModule } from "../../dialog.module";
@Component({
template: `
<div *ngFor="let group of dialogs">
<h2>{{ group.title }}</h2>
<div class="tw-mb-4 tw-flex tw-flex-row tw-gap-2">
<button
type="button"
*ngFor="let dialog of group.dialogs"
bitButton
(click)="openSimpleConfigurableDialog(dialog)"
>
{{ dialog.title }}
</button>
@for (group of dialogs; track group) {
<div>
<h2>{{ group.title }}</h2>
<div class="tw-mb-4 tw-flex tw-flex-row tw-gap-2">
@for (dialog of group.dialogs; track dialog) {
<button type="button" bitButton (click)="openSimpleConfigurableDialog(dialog)">
{{ dialog.title }}
</button>
}
</div>
</div>
</div>
}
<bit-callout *ngIf="showCallout" [type]="calloutType" title="Dialog Close Result">
{{ dialogCloseResult }}
</bit-callout>
@if (showCallout) {
<bit-callout [type]="calloutType" title="Dialog Close Result">
{{ dialogCloseResult }}
</bit-callout>
}
`,
})
class StoryDialogComponent {

View File

@@ -3,12 +3,11 @@
@fadeIn
>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
<ng-container *ngIf="hasIcon; else elseBlock">
@if (hasIcon) {
<ng-content select="[bitDialogIcon]"></ng-content>
</ng-container>
<ng-template #elseBlock>
} @else {
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
</ng-template>
}
<h1
bitDialogTitleContainer
bitTypography="h3"

View File

@@ -1,4 +1,3 @@
import { NgIf } from "@angular/common";
import { Component, ContentChild, Directive } from "@angular/core";
import { TypographyDirective } from "../../typography/typography.directive";
@@ -16,7 +15,7 @@ export class IconDirective {}
templateUrl: "./simple-dialog.component.html",
animations: [fadeIn],
standalone: true,
imports: [NgIf, DialogTitleContainerDirective, TypographyDirective],
imports: [DialogTitleContainerDirective, TypographyDirective],
})
export class SimpleDialogComponent {
@ContentChild(IconDirective) icon!: IconDirective;

View File

@@ -9,11 +9,17 @@
>
<span bitTypography="body2">
<ng-content select="bit-label"></ng-content>
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
@if (required) {
<span class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
}
</span>
<ng-content select="bit-hint" *ngIf="!hasError"></ng-content>
@if (!hasError) {
<ng-content select="bit-hint"></ng-content>
}
</span>
</label>
<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>
@if (hasError) {
<div class="tw-mt-1 tw-text-danger tw-text-xs tw-ml-0.5">
<i class="bwi bwi-error"></i> {{ displayError }}
</div>
}

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { NgClass, NgIf } from "@angular/common";
import { NgClass } from "@angular/common";
import { Component, ContentChild, HostBinding, Input } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -15,7 +15,7 @@ import { BitFormControlAbstraction } from "./form-control.abstraction";
selector: "bit-form-control",
templateUrl: "form-control.component.html",
standalone: true,
imports: [NgClass, TypographyDirective, NgIf, I18nPipe],
imports: [NgClass, TypographyDirective, I18nPipe],
})
export class FormControlComponent {
@Input() label: string;

View File

@@ -5,10 +5,10 @@
<!-- labels inside a form control (checkbox, radio button) should not truncate -->
<span [ngClass]="{ 'tw-truncate': !isInsideFormControl }">
<ng-content></ng-content>
<ng-container *ngIf="isInsideFormControl">
@if (isInsideFormControl) {
<ng-container *ngTemplateOutlet="endSlotContent"></ng-container>
</ng-container>
}
</span>
<ng-container *ngIf="!isInsideFormControl">
@if (!isInsideFormControl) {
<ng-container *ngTemplateOutlet="endSlotContent"></ng-container>
</ng-container>
}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgIf } from "@angular/common";
import { Component, Input } from "@angular/core";
import { AbstractControl, UntypedFormGroup } from "@angular/forms";
@@ -8,15 +8,15 @@ import { I18nPipe } from "@bitwarden/ui-common";
@Component({
selector: "bit-error-summary",
template: ` <ng-container *ngIf="errorCount > 0">
template: ` @if (errorCount > 0) {
<i class="bwi bwi-error"></i> {{ "fieldsNeedAttention" | i18n: errorString }}
</ng-container>`,
}`,
host: {
class: "tw-block tw-text-danger tw-mt-2",
"aria-live": "assertive",
},
standalone: true,
imports: [NgIf, I18nPipe],
imports: [I18nPipe],
})
export class BitErrorSummary {
@Input()

View File

@@ -15,63 +15,65 @@
<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-size-full tw-top-0 tw-pointer-events-none tw-z-20">
<div class="tw-size-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"
@if (!readOnly) {
<div class="tw-w-full tw-relative tw-group/bit-form-field">
<div class="tw-absolute tw-size-full tw-top-0 tw-pointer-events-none tw-z-20">
<div class="tw-size-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"
>
<ng-container *ngTemplateOutlet="labelContent"></ng-container>
<span *ngIf="input.required" class="tw-text-[0.625rem] tw-relative tw-bottom-[-1px]">
({{ "required" | i18n }})</span
<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"
>
</label>
<ng-container *ngTemplateOutlet="labelContent"></ng-container>
@if (input.required) {
<span 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="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>
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>
<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>
} @else {
<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"
@@ -107,9 +109,13 @@
</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>
</ng-container>
@switch (input.hasError) {
@case (false) {
<ng-content select="bit-hint"></ng-content>
}
@case (true) {
<bit-error [error]="input.error"></bit-error>
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { NgClass } from "@angular/common";
import {
AfterContentChecked,
ChangeDetectionStrategy,
@@ -16,7 +17,7 @@ import { TypographyModule } from "../typography";
@Component({
selector: "bit-item-content, [bit-item-content]",
standalone: true,
imports: [CommonModule, TypographyModule],
imports: [TypographyModule, NgClass],
templateUrl: `item-content.component.html`,
host: {
class:

View File

@@ -1,4 +1,3 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
@@ -14,7 +13,7 @@ import { ItemActionComponent } from "./item-action.component";
@Component({
selector: "bit-item",
standalone: true,
imports: [CommonModule, ItemActionComponent],
imports: [ItemActionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "item.component.html",
providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }],

View File

@@ -23,19 +23,21 @@
<ng-content></ng-content>
<!-- overlay backdrop for side-nav -->
<div
*ngIf="{
@if (
{
open: sideNavService.open$ | async,
} as data"
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
};
as data
) {
<div
*ngIf="data.open"
(click)="sideNavService.toggle()"
class="tw-pointer-events-auto tw-size-full"
></div>
</div>
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
}
</main>
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
</div>

View File

@@ -31,7 +31,9 @@
[disabled]="disabled"
(click)="clear(item)"
>
<i *ngIf="item.icon != null" class="bwi bwi-fw {{ item.icon }}" aria-hidden="true"></i>
@if (item.icon != null) {
<i class="bwi bwi-fw {{ item.icon }}" aria-hidden="true"></i>
}
<span class="tw-truncate">
{{ item.labelName }}
</span>
@@ -41,10 +43,14 @@
<ng-template ng-option-tmp let-item="item">
<div class="tw-flex">
<div class="tw-w-7 tw-flex-none">
<i *ngIf="isSelected(item)" class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
@if (isSelected(item)) {
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
}
</div>
<div class="tw-mr-2 tw-flex-initial">
<i *ngIf="item.icon != null" class="bwi bwi-fw {{ item.icon }}" aria-hidden="true"></i>
@if (item.icon != null) {
<i class="bwi bwi-fw {{ item.icon }}" aria-hidden="true"></i>
}
</div>
<div class="tw-flex-1">
{{ item.listName }}

View File

@@ -2,7 +2,6 @@
// @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { hasModifierKey } from "@angular/cdk/keycodes";
import { NgIf } from "@angular/common";
import {
Component,
Input,
@@ -39,7 +38,7 @@ let nextId = 0;
templateUrl: "./multi-select.component.html",
providers: [{ provide: BitFormFieldControl, useExisting: MultiSelectComponent }],
standalone: true,
imports: [NgSelectModule, ReactiveFormsModule, FormsModule, BadgeModule, NgIf, I18nPipe],
imports: [NgSelectModule, ReactiveFormsModule, FormsModule, BadgeModule, I18nPipe],
})
/**
* This component has been implemented to only support Multi-select list events

View File

@@ -1 +1,3 @@
<div *ngIf="sideNavService.open$ | async" class="tw-h-px tw-w-full tw-bg-secondary-300"></div>
@if (sideNavService.open$ | async) {
<div class="tw-h-px tw-w-full tw-bg-secondary-300"></div>
}

View File

@@ -1,5 +1,5 @@
<!-- This a higher order component that composes `NavItemComponent` -->
<ng-container *ngIf="!hideIfEmpty || nestedNavComponents.length > 0">
@if (!hideIfEmpty || nestedNavComponents.length > 0) {
<bit-nav-item
[text]="text"
[icon]="icon"
@@ -29,28 +29,29 @@
[attr.aria-label]="['toggleCollapse' | i18n, text].join(' ')"
></button>
</ng-template>
<!-- Show toggle to the left for trees otherwise to the right -->
<ng-container slot="start" *ngIf="variant === 'tree'">
<ng-container *ngTemplateOutlet="button"></ng-container>
</ng-container>
<ng-container slot="end">
<ng-content select="[slot=end]"></ng-content>
<ng-container *ngIf="variant !== 'tree'">
@if (variant === "tree") {
<ng-container slot="start">
<ng-container *ngTemplateOutlet="button"></ng-container>
</ng-container>
}
<ng-container slot="end">
<ng-content select="[slot=end]"></ng-content>
@if (variant !== "tree") {
<ng-container *ngTemplateOutlet="button"></ng-container>
}
</ng-container>
</bit-nav-item>
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->
<ng-container *ngIf="sideNavService.open$ | async">
<div
*ngIf="open"
[attr.id]="contentId"
[attr.aria-label]="[text, 'submenu' | i18n].join(' ')"
role="group"
>
<ng-content></ng-content>
</div>
</ng-container>
</ng-container>
@if (sideNavService.open$ | async) {
@if (open) {
<div
[attr.id]="contentId"
[attr.aria-label]="[text, 'submenu' | i18n].join(' ')"
role="group"
>
<ng-content></ng-content>
</div>
}
}
}

View File

@@ -29,6 +29,7 @@ import { SideNavService } from "./side-nav.service";
],
standalone: true,
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
preserveWhitespaces: false,
})
export class NavGroupComponent extends NavBaseComponent implements AfterContentInit {
@ContentChildren(NavBaseComponent, {

View File

@@ -1,20 +1,23 @@
<div *ngIf="sideNavService.open" class="tw-sticky tw-top-0 tw-z-50">
<a
[routerLink]="route"
class="tw-px-5 tw-pb-5 tw-pt-7 tw-block tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2"
[attr.aria-label]="label"
[title]="label"
routerLinkActive
[ariaCurrentWhenActive]="'page'"
>
<bit-icon [icon]="openIcon"></bit-icon>
</a>
</div>
<bit-nav-item
class="tw-block tw-pt-7"
[hideActiveStyles]="true"
[route]="route"
[icon]="closedIcon"
*ngIf="!sideNavService.open"
[text]="label"
></bit-nav-item>
@if (sideNavService.open) {
<div class="tw-sticky tw-top-0 tw-z-50">
<a
[routerLink]="route"
class="tw-px-5 tw-pb-5 tw-pt-7 tw-block tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2"
[attr.aria-label]="label"
[title]="label"
routerLinkActive
[ariaCurrentWhenActive]="'page'"
>
<bit-icon [icon]="openIcon"></bit-icon>
</a>
</div>
}
@if (!sideNavService.open) {
<bit-nav-item
class="tw-block tw-pt-7"
[hideActiveStyles]="true"
[route]="route"
[icon]="closedIcon"
[text]="label"
></bit-nav-item>
}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgIf } from "@angular/common";
import { Component, Input } from "@angular/core";
import { RouterLinkActive, RouterLink } from "@angular/router";
@@ -14,7 +14,7 @@ import { SideNavService } from "./side-nav.service";
selector: "bit-nav-logo",
templateUrl: "./nav-logo.component.html",
standalone: true,
imports: [NgIf, RouterLinkActive, RouterLink, BitIconComponent, NavItemComponent],
imports: [RouterLinkActive, RouterLink, BitIconComponent, NavItemComponent],
})
export class NavLogoComponent {
/** Icon that is displayed when the side nav is closed */

View File

@@ -1,42 +1,46 @@
<nav
*ngIf="{
@if (
{
open: sideNavService.open$ | async,
isOverlay: sideNavService.isOverlay$ | async,
} as data"
id="bit-side-nav"
class="tw-fixed md:tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-screen tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
[ngClass]="{ 'tw-w-60': data.open }"
[ngStyle]="
variant === 'secondary' && {
'--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)',
}
"
[cdkTrapFocus]="data.isOverlay"
[attr.role]="data.isOverlay ? 'dialog' : null"
[attr.aria-modal]="data.isOverlay ? 'true' : null"
(keydown)="handleKeyDown($event)"
>
<ng-content></ng-content>
<div class="tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3">
<bit-nav-divider></bit-nav-divider>
<ng-container *ngIf="data.open">
<ng-content select="[slot=footer]"></ng-content>
</ng-container>
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
<button
#toggleButton
type="button"
class="tw-mx-auto tw-block tw-max-w-fit"
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
buttonType="light"
size="small"
(click)="sideNavService.toggle()"
[attr.aria-label]="'toggleSideNavigation' | i18n"
[attr.aria-expanded]="data.open"
aria-controls="bit-side-nav"
></button>
};
as data
) {
<nav
id="bit-side-nav"
class="tw-fixed md:tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-screen tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
[ngClass]="{ 'tw-w-60': data.open }"
[ngStyle]="
variant === 'secondary' && {
'--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)',
}
"
[cdkTrapFocus]="data.isOverlay"
[attr.role]="data.isOverlay ? 'dialog' : null"
[attr.aria-modal]="data.isOverlay ? 'true' : null"
(keydown)="handleKeyDown($event)"
>
<ng-content></ng-content>
<div class="tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3">
<bit-nav-divider></bit-nav-divider>
@if (data.open) {
<ng-content select="[slot=footer]"></ng-content>
}
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
<button
#toggleButton
type="button"
class="tw-mx-auto tw-block tw-max-w-fit"
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
buttonType="light"
size="small"
(click)="sideNavService.toggle()"
[attr.aria-label]="'toggleSideNavigation' | i18n"
[attr.aria-expanded]="data.open"
aria-controls="bit-side-nav"
></button>
</div>
</div>
</div>
</nav>
</nav>
}

View File

@@ -7,13 +7,12 @@
attr.aria-valuenow="{{ barWidth }}"
[ngStyle]="{ width: barWidth + '%' }"
>
<div
*ngIf="displayText"
class="tw-flex tw-h-full tw-flex-wrap tw-items-center tw-overflow-hidden"
>
<!-- If text is too long to fit, wrap it below to hide -->
<div class="tw-h-full">&nbsp;</div>
<div class="tw-pr-1">{{ textContent }}</div>
</div>
@if (displayText) {
<div class="tw-flex tw-h-full tw-flex-wrap tw-items-center tw-overflow-hidden">
<!-- If text is too long to fit, wrap it below to hide -->
<div class="tw-h-full">&nbsp;</div>
<div class="tw-pr-1">{{ textContent }}</div>
</div>
}
</div>
</div>

View File

@@ -1,16 +1,18 @@
<ng-container *ngIf="label">
@if (label) {
<fieldset>
<legend class="tw-mb-1 tw-block tw-text-sm tw-font-semibold tw-text-main">
<ng-content select="bit-label"></ng-content>
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
@if (required) {
<span class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
}
</legend>
<ng-container *ngTemplateOutlet="content"></ng-container>
</fieldset>
</ng-container>
}
<ng-container *ngIf="!label">
@if (!label) {
<ng-container *ngTemplateOutlet="content"></ng-container>
</ng-container>
}
<ng-template #content>
<div>

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgIf, NgTemplateOutlet } from "@angular/common";
import { NgTemplateOutlet } from "@angular/common";
import { Component, ContentChild, HostBinding, Input, Optional, Self } from "@angular/core";
import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
@@ -14,7 +14,7 @@ let nextId = 0;
selector: "bit-radio-group",
templateUrl: "radio-group.component.html",
standalone: true,
imports: [NgIf, NgTemplateOutlet, I18nPipe],
imports: [NgTemplateOutlet, I18nPipe],
})
export class RadioGroupComponent implements ControlValueAccessor {
selected: unknown;

View File

@@ -13,7 +13,9 @@
<ng-template ng-option-tmp let-item="item">
<div class="tw-flex" [title]="item.label">
<div class="tw-mr-2 tw-flex-initial">
<i *ngIf="item.icon != null" class="bwi bwi-fw {{ item.icon }}" aria-hidden="true"></i>
@if (item.icon != null) {
<i class="bwi bwi-fw {{ item.icon }}" aria-hidden="true"></i>
}
</div>
<div class="tw-flex-1 tw-text-ellipsis tw-overflow-hidden">
{{ item.label }}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgIf } from "@angular/common";
import {
Component,
ContentChildren,
@@ -36,7 +36,7 @@ let nextId = 0;
templateUrl: "select.component.html",
providers: [{ provide: BitFormFieldControl, useExisting: SelectComponent }],
standalone: true,
imports: [NgSelectModule, ReactiveFormsModule, FormsModule, NgIf],
imports: [NgSelectModule, ReactiveFormsModule, FormsModule],
})
export class SelectComponent<T> implements BitFormFieldControl, ControlValueAccessor {
@ViewChild(NgSelectComponent) select: NgSelectComponent;

View File

@@ -49,11 +49,9 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
<bit-form-field>
<bit-label>Your favorite color</bit-label>
<bit-select formControlName="favColor">
<bit-option
*ngFor="let color of colors"
[value]="color.value"
[label]="color.name"
></bit-option>
@for (color of colors; track color) {
<bit-option [value]="color.value" [label]="color.name"></bit-option>
}
</bit-select>
</bit-form-field>

View File

@@ -36,9 +36,11 @@ class KitchenSinkDialog {
<p class="tw-mt-4">
<bit-breadcrumbs>
<bit-breadcrumb *ngFor="let item of navItems" [icon]="item.icon" [route]="[item.route]">
{{ item.name }}
</bit-breadcrumb>
@for (item of navItems; track item) {
<bit-breadcrumb [icon]="item.icon" [route]="[item.route]">
{{ item.name }}
</bit-breadcrumb>
}
</bit-breadcrumbs>
</p>

View File

@@ -16,11 +16,15 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
<bit-toggle value="small"> Mid-sized <span bitBadge variant="info">1</span> </bit-toggle>
</bit-toggle-group>
</div>
<ul *ngFor="let company of companyList">
<li *ngIf="company.size === selectedToggle || selectedToggle === 'all'">
{{ company.name }}
</li>
</ul>
@for (company of companyList; track company) {
<ul>
@if (company.size === selectedToggle || selectedToggle === "all") {
<li>
{{ company.name }}
</li>
}
</ul>
}
`,
})
export class KitchenSinkToggleList {

View File

@@ -5,40 +5,41 @@
[attr.aria-label]="label"
(keydown)="keyManager.onKeydown($event)"
>
<button
bitTabListItem
*ngFor="let tab of tabs; let i = index"
type="button"
role="tab"
[id]="getTabLabelId(i)"
[active]="tab.isActive"
[disabled]="tab.disabled"
[attr.aria-selected]="selectedIndex === i"
[attr.tabindex]="selectedIndex === i ? 0 : -1"
(click)="selectTab(i)"
>
<ng-container [ngTemplateOutlet]="content"></ng-container>
<ng-template #content>
<ng-template [ngIf]="tab.templateLabel" [ngIfElse]="tabTextLabel">
<ng-container [ngTemplateOutlet]="tab.templateLabel.templateRef"></ng-container>
@for (tab of tabs; track tab; let i = $index) {
<button
bitTabListItem
type="button"
role="tab"
[id]="getTabLabelId(i)"
[active]="tab.isActive"
[disabled]="tab.disabled"
[attr.aria-selected]="selectedIndex === i"
[attr.tabindex]="selectedIndex === i ? 0 : -1"
(click)="selectTab(i)"
>
<ng-container [ngTemplateOutlet]="content"></ng-container>
<ng-template #content>
@if (tab.templateLabel) {
<ng-container [ngTemplateOutlet]="tab.templateLabel.templateRef"></ng-container>
} @else {
{{ tab.textLabel }}
}
</ng-template>
<ng-template #tabTextLabel>{{ tab.textLabel }}</ng-template>
</ng-template>
</button>
</button>
}
</div>
</bit-tab-header>
<div class="tw-px-4 tw-pt-5">
<bit-tab-body
role="tabpanel"
*ngFor="let tab of tabs; let i = index"
[id]="getTabContentId(i)"
[attr.tabindex]="tab.contentTabIndex"
[attr.labeledby]="getTabLabelId(i)"
[active]="tab.isActive"
[content]="tab.content"
[preserveContent]="preserveContent"
>
</bit-tab-body>
@for (tab of tabs; track tab; let i = $index) {
<bit-tab-body
role="tabpanel"
[id]="getTabContentId(i)"
[attr.tabindex]="tab.contentTabIndex"
[attr.labeledby]="getTabLabelId(i)"
[active]="tab.isActive"
[content]="tab.content"
[preserveContent]="preserveContent"
>
</bit-tab-body>
}
</div>

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { FocusKeyManager } from "@angular/cdk/a11y";
import { coerceNumberProperty } from "@angular/cdk/coercion";
import { CommonModule } from "@angular/common";
import { NgTemplateOutlet } from "@angular/common";
import {
AfterContentChecked,
AfterContentInit,
@@ -33,7 +33,7 @@ let nextId = 0;
templateUrl: "./tab-group.component.html",
standalone: true,
imports: [
CommonModule,
NgTemplateOutlet,
TabHeaderComponent,
TabListContainerDirective,
TabListItemDirective,

View File

@@ -7,15 +7,14 @@
<i aria-hidden="true" class="bwi tw-text-xl tw-py-1.5 tw-px-2.5 {{ iconClass }}"></i>
<div>
<span class="tw-sr-only">{{ variant | i18n }}</span>
<p *ngIf="title" data-testid="toast-title" class="tw-font-semibold tw-mb-0">{{ title }}</p>
<p
bitTypography="body2"
*ngFor="let m of messageArray"
data-testid="toast-message"
class="tw-mb-2 last:tw-mb-0"
>
{{ m }}
</p>
@if (title) {
<p data-testid="toast-title" class="tw-font-semibold tw-mb-0">{{ title }}</p>
}
@for (m of messageArray; track m) {
<p bitTypography="body2" data-testid="toast-message" class="tw-mb-2 last:tw-mb-0">
{{ m }}
</p>
}
</div>
<!-- Overriding hover and focus-visible colors for a11y against colored background -->
<button

2
libs/eslint/empty.ts Normal file
View File

@@ -0,0 +1,2 @@
// This file is used to avoid TS errors. This package only uses `tsconfig.json` for dynamically generated test files but
// TS doesn't know that in the CI.

View File

@@ -0,0 +1,10 @@
const sharedConfig = require("../../libs/shared/jest.config.angular");
/** @type {import('jest').Config} */
module.exports = {
...sharedConfig,
testMatch: ["**/+(*.)+(spec).+(mjs)"],
displayName: "libs/eslint tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.mjs"],
};

View File

@@ -0,0 +1,3 @@
import requiredUsing from "./required-using.mjs";
export default { rules: { "required-using": requiredUsing } };

View File

@@ -0,0 +1,83 @@
import { ESLintUtils } from "@typescript-eslint/utils";
export const errorMessage = "'using' keyword is required but not used";
export default {
meta: {
type: "problem",
docs: {
description: "Ensure objects implementing UsingRequired are used with the using keyword",
category: "Best Practices",
recommended: false,
},
schema: [],
},
create(context) {
const parserServices = ESLintUtils.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
// Function to check if a type implements the `UsingRequired` interface
function implementsUsingRequired(type) {
const symbol = type.getSymbol();
if (!symbol) {
return false;
}
const declarations = symbol.getDeclarations() || [];
for (const declaration of declarations) {
const heritageClauses = declaration.heritageClauses || [];
for (const clause of heritageClauses) {
if (
clause.types.some(
(typeExpression) =>
checker.typeToString(checker.getTypeAtLocation(typeExpression.expression)) ===
"UsingRequired",
)
) {
return true;
}
}
}
return false;
}
// Function to check if a function call returns a `UsingRequired`
function returnsUsingRequired(node) {
if (node.type === "CallExpression") {
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
const returnType = checker.getTypeAtLocation(tsNode);
return implementsUsingRequired(returnType);
}
return false;
}
return {
VariableDeclarator(node) {
// Skip if `using` is already present
if (node.parent.type === "VariableDeclaration" && node.parent.kind === "using") {
return;
}
// Check if the initializer returns a `UsingRequired`
if (node.init && returnsUsingRequired(node.init)) {
context.report({
node,
message: errorMessage,
});
}
},
AssignmentExpression(node) {
// Check if the right-hand side returns a `UsingRequired`
if (returnsUsingRequired(node.right)) {
context.report({
node,
message: errorMessage,
});
}
},
};
},
};

View File

@@ -0,0 +1,98 @@
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule, { errorMessage } from "./required-using.mjs";
const ruleTester = new RuleTester({
languageOptions: {
parserOptions: {
project: [__dirname + "/../tsconfig.spec.json"],
projectService: {
allowDefaultProject: ["*.ts*"],
},
tsconfigRootDir: __dirname + "/..",
},
},
});
const setup = `
interface UsingRequired {}
class Ref implements UsingRequired {}
const rc = {
take(): Ref {
return new Ref();
},
};
`;
ruleTester.run("required-using", rule.default, {
valid: [
{
name: "Direct declaration with `using`",
code: `
${setup}
using client = rc.take();
`,
},
{
name: "Function reference with `using`",
code: `
${setup}
const t = rc.take;
using client = t();
`,
},
],
invalid: [
{
name: "Direct declaration without `using`",
code: `
${setup}
const client = rc.take();
`,
errors: [
{
message: errorMessage,
},
],
},
{
name: "Assignment without `using`",
code: `
${setup}
let client;
client = rc.take();
`,
errors: [
{
message: errorMessage,
},
],
},
{
name: "Function reference without `using`",
code: `
${setup}
const t = rc.take;
const client = t();
`,
errors: [
{
message: errorMessage,
},
],
},
{
name: "Destructuring without `using`",
code: `
${setup}
const { value } = rc.take();
`,
errors: [
{
message: errorMessage,
},
],
},
],
});

View File

@@ -0,0 +1,8 @@
/* eslint-disable no-undef */
import { clearImmediate, setImmediate } from "node:timers";
Object.defineProperties(globalThis, {
clearImmediate: { value: clearImmediate },
setImmediate: { value: setImmediate },
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../shared/tsconfig",
"compilerOptions": {},
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.json"
}

View File

@@ -1 +1,3 @@
export * from "./security-task";
export * from "./security-task.data";
export * from "./security-task.response";

8881
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,8 @@
"@types/proper-lockfile": "4.1.4",
"@types/retry": "0.12.5",
"@types/zxcvbn": "4.4.5",
"@typescript-eslint/rule-tester": "8.22.0",
"@typescript-eslint/utils": "8.22.0",
"@webcomponents/custom-elements": "1.6.0",
"@yao-pkg/pkg": "5.16.1",
"angular-eslint": "18.4.3",