@nauticana/sail 1.1.10 → 1.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/package.json +1 -1
- package/src/component/login/chpass_component.html +4 -0
- package/src/component/login/chpass_component.ts +15 -3
- package/src/component/login/confirm_chpass_component.html +4 -0
- package/src/component/login/confirm_chpass_component.ts +15 -2
- package/src/component/login/sms_consent.html +11 -0
- package/src/component/login/sms_consent.ts +38 -0
- package/src/config.ts +9 -0
- package/src/index.ts +2 -0
- package/src/service/auth.service.ts +17 -0
- package/src/util/password_policy.ts +43 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
4
4
|
|
|
5
5
|
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x** (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
6
6
|
|
|
7
|
-
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows.
|
|
7
|
+
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
|
|
8
8
|
|
|
9
9
|
> **v1.1.10 is a breaking release.** The base classes migrated to signals (`records()`, `editableRecord()`, `isNew()` …), `login()`/`loginWithGoogle()` now return Observables, CRUD errors are typed `SailApiError`, `BaseView` split into read-only `BaseView` + `AbstractEditableView`, the decorators are renamed `IsSailString`/`IsSailNumeric`, and `configureRestUrls()` is finally safe to call from a subclass constructor. The complete old→new mapping is machine-readable in [`migration_guide.json`](migration_guide.json). Consumers must fully migrate before pushing to test or prod.
|
|
10
10
|
|
|
@@ -277,9 +277,19 @@ interface SailGuiConfig {
|
|
|
277
277
|
defaultPolicyVersion?: string; // Content hash of the deployed policy
|
|
278
278
|
defaultPolicyLanguage?: string; // ISO 639-1 fallback language
|
|
279
279
|
accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
|
|
280
|
+
passwordPolicyUrl?: string; // Public route serving keel's password policy (see below)
|
|
280
281
|
}
|
|
281
282
|
```
|
|
282
283
|
|
|
284
|
+
### Password-policy validation
|
|
285
|
+
|
|
286
|
+
Set `passwordPolicyUrl` to the app's public route for keel's `PublicHandler.GetPasswordPolicy`
|
|
287
|
+
(e.g. `'/public/v1/auth/password/policy'`). `BaseAuthService.ensurePasswordPolicy()` fetches it
|
|
288
|
+
once into the `passwordRules` signal, and `passwordPolicyValidator(() => auth.passwordRules())`
|
|
289
|
+
validates new-password inputs against the real policy before submit (`passwordPolicyHint(rules)`
|
|
290
|
+
builds the matching hint text). Built into `chpass` / `confirm-chpass`; reuse in app signup forms.
|
|
291
|
+
Omit the URL to skip client-side policy checks. The server stays the authoritative gate.
|
|
292
|
+
|
|
283
293
|
## Backend endpoints (keel v1.0)
|
|
284
294
|
|
|
285
295
|
| Endpoint | Purpose |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.12",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
<mat-form-field appearance="outline">
|
|
22
22
|
<mat-label>New Password</mat-label>
|
|
23
23
|
<input matInput type="password" formControlName="new_password" autocomplete="new-password" required>
|
|
24
|
+
@if (passwordHint(); as hint) { <mat-hint>{{ hint }}</mat-hint> }
|
|
25
|
+
@if (chpassForm.controls.new_password.hasError('passwordPolicy')) {
|
|
26
|
+
<mat-error>{{ chpassForm.controls.new_password.getError('passwordPolicy').message }}</mat-error>
|
|
27
|
+
}
|
|
24
28
|
</mat-form-field>
|
|
25
29
|
|
|
26
30
|
<mat-form-field appearance="outline">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewEncapsulation } from '@angular/core';
|
|
2
2
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
3
|
import { BaseAuthService } from '../../service/auth.service';
|
|
4
4
|
import { Router, RouterLink } from '@angular/router';
|
|
@@ -8,6 +8,7 @@ import { MatInputModule } from '@angular/material/input';
|
|
|
8
8
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
9
9
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
10
10
|
import { BaseAsync } from '../abstract/base_async';
|
|
11
|
+
import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password_policy';
|
|
11
12
|
|
|
12
13
|
@Component({
|
|
13
14
|
selector: 'sail-chpass',
|
|
@@ -34,10 +35,21 @@ export class ChpassComponent extends BaseAsync {
|
|
|
34
35
|
chpassForm = this.fb.group({
|
|
35
36
|
username: ['', Validators.required],
|
|
36
37
|
old_password: ['', Validators.required],
|
|
37
|
-
new_password: ['', [Validators.required,
|
|
38
|
+
new_password: ['', [Validators.required, passwordPolicyValidator(() => this.auth.passwordRules())]],
|
|
38
39
|
confirm_password: ['', Validators.required],
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
protected readonly passwordHint = computed(() => passwordPolicyHint(this.auth.passwordRules()));
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
super();
|
|
46
|
+
this.auth.ensurePasswordPolicy();
|
|
47
|
+
effect(() => {
|
|
48
|
+
this.auth.passwordRules();
|
|
49
|
+
this.chpassForm.controls.new_password.updateValueAndValidity({ emitEvent: false });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
toggleForgotPassword(checked: boolean) {
|
|
42
54
|
this.forgotPassword.set(checked);
|
|
43
55
|
const oldPw = this.chpassForm.get('old_password')!;
|
|
@@ -52,7 +64,7 @@ export class ChpassComponent extends BaseAsync {
|
|
|
52
64
|
confirmPw.setValue('');
|
|
53
65
|
} else {
|
|
54
66
|
oldPw.setValidators(Validators.required);
|
|
55
|
-
newPw.setValidators([Validators.required,
|
|
67
|
+
newPw.setValidators([Validators.required, passwordPolicyValidator(() => this.auth.passwordRules())]);
|
|
56
68
|
confirmPw.setValidators(Validators.required);
|
|
57
69
|
}
|
|
58
70
|
oldPw.updateValueAndValidity();
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
<mat-form-field appearance="outline">
|
|
22
22
|
<mat-label>New Password</mat-label>
|
|
23
23
|
<input matInput type="password" formControlName="new_password" autocomplete="new-password" required>
|
|
24
|
+
@if (passwordHint(); as hint) { <mat-hint>{{ hint }}</mat-hint> }
|
|
25
|
+
@if (confirmForm.controls.new_password.hasError('passwordPolicy')) {
|
|
26
|
+
<mat-error>{{ confirmForm.controls.new_password.getError('passwordPolicy').message }}</mat-error>
|
|
27
|
+
}
|
|
24
28
|
</mat-form-field>
|
|
25
29
|
|
|
26
30
|
<mat-form-field appearance="outline">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, ViewEncapsulation } from '@angular/core';
|
|
2
2
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
3
|
import { BaseAuthService } from '../../service/auth.service';
|
|
4
4
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
@@ -7,6 +7,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
|
|
7
7
|
import { MatInputModule } from '@angular/material/input';
|
|
8
8
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
9
9
|
import { BaseAsync } from '../abstract/base_async';
|
|
10
|
+
import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password_policy';
|
|
10
11
|
|
|
11
12
|
@Component({
|
|
12
13
|
selector: 'sail-confirm-chpass',
|
|
@@ -31,11 +32,23 @@ export class ConfirmChpassComponent extends BaseAsync implements OnInit {
|
|
|
31
32
|
confirmForm = this.fb.group({
|
|
32
33
|
username: ['', Validators.required],
|
|
33
34
|
code: ['', Validators.required],
|
|
34
|
-
new_password: ['', [Validators.required,
|
|
35
|
+
new_password: ['', [Validators.required, passwordPolicyValidator(() => this.auth.passwordRules())]],
|
|
35
36
|
confirm_password: ['', Validators.required],
|
|
36
37
|
});
|
|
37
38
|
|
|
39
|
+
protected readonly passwordHint = computed(() => passwordPolicyHint(this.auth.passwordRules()));
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
super();
|
|
43
|
+
// Re-validate the new-password field once the policy arrives.
|
|
44
|
+
effect(() => {
|
|
45
|
+
this.auth.passwordRules();
|
|
46
|
+
this.confirmForm.controls.new_password.updateValueAndValidity({ emitEvent: false });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
ngOnInit() {
|
|
51
|
+
this.auth.ensurePasswordPolicy();
|
|
39
52
|
const username = this.route.snapshot.queryParamMap.get('username');
|
|
40
53
|
if (username) {
|
|
41
54
|
this.confirmForm.patchValue({ username });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<p class="sail-sms-consent">
|
|
2
|
+
By providing your phone number and tapping “{{ action() }},” you agree to receive
|
|
3
|
+
recurring automated text messages ({{ messagePurpose() }}) from {{ brandName() }} at that number.
|
|
4
|
+
Message frequency varies. Msg & data rates may apply. Reply STOP to opt out, HELP for help.
|
|
5
|
+
@if (terms() || privacy()) {
|
|
6
|
+
See our
|
|
7
|
+
@if (terms()) { <a [href]="terms()" target="_blank" rel="noopener">Terms</a> }
|
|
8
|
+
@if (terms() && privacy()) { and }
|
|
9
|
+
@if (privacy()) { <a [href]="privacy()" target="_blank" rel="noopener">Privacy Policy</a> }.
|
|
10
|
+
}
|
|
11
|
+
</p>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input } from '@angular/core';
|
|
2
|
+
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SmsConsent — the shared 10DLC / A2P SMS opt-in disclosure.
|
|
6
|
+
*
|
|
7
|
+
* Presentational only: renders the carrier-required consent language (recurring
|
|
8
|
+
* automated messages, frequency, rates, STOP/HELP, Terms + Privacy links) with
|
|
9
|
+
* the host app's brand and message purpose. Every SMS-sending app renders the
|
|
10
|
+
* same carrier-compliant copy — which must match the wording registered in its
|
|
11
|
+
* 10DLC campaign sample. Consent is *captured* server-side via keel's
|
|
12
|
+
* ConsentService (with the phone number); this component is only the disclosure
|
|
13
|
+
* shown at the point of opt-in. Styling is the consuming app's (sail ships no CSS).
|
|
14
|
+
*/
|
|
15
|
+
@Component({
|
|
16
|
+
selector: 'sail-sms-consent',
|
|
17
|
+
templateUrl: './sms_consent.html',
|
|
18
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
19
|
+
encapsulation: ViewEncapsulation.None,
|
|
20
|
+
})
|
|
21
|
+
export class SmsConsentComponent {
|
|
22
|
+
private readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
|
|
23
|
+
|
|
24
|
+
/** Brand name in the disclosure. Defaults to config.appName. */
|
|
25
|
+
readonly brand = input<string>('');
|
|
26
|
+
/** What the messages are — e.g. "one-time verification codes" or "verification codes and trip updates". */
|
|
27
|
+
readonly messagePurpose = input<string>('one-time verification codes');
|
|
28
|
+
/** The action label the disclosure references (the opt-in button's text). */
|
|
29
|
+
readonly action = input<string>('the button below');
|
|
30
|
+
/** Terms URL. Defaults to config.termsUrl. */
|
|
31
|
+
readonly termsUrl = input<string>('');
|
|
32
|
+
/** Privacy URL. Defaults to config.privacyPolicyUrl. */
|
|
33
|
+
readonly privacyUrl = input<string>('');
|
|
34
|
+
|
|
35
|
+
protected readonly brandName = computed(() => this.brand() || this.guiConfig.appName || 'us');
|
|
36
|
+
protected readonly terms = computed(() => this.termsUrl() || this.guiConfig.termsUrl || '');
|
|
37
|
+
protected readonly privacy = computed(() => this.privacyUrl() || this.guiConfig.privacyPolicyUrl || '');
|
|
38
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -55,6 +55,10 @@ export interface SailGuiConfig {
|
|
|
55
55
|
appleRedirectUri?: string;
|
|
56
56
|
/** Public URL of your privacy policy. Shown as a link in ConsentGateComponent. */
|
|
57
57
|
privacyPolicyUrl?: string;
|
|
58
|
+
/** Public URL of your terms of service. Shown in the SMS-consent disclosure. */
|
|
59
|
+
termsUrl?: string;
|
|
60
|
+
/** Brand name shown in the SMS/10DLC consent disclosure (e.g. "Daxoom"). */
|
|
61
|
+
appName?: string;
|
|
58
62
|
/** Content hash / version of the currently-deployed privacy policy. */
|
|
59
63
|
defaultPolicyVersion?: string;
|
|
60
64
|
/** ISO 639-1 language code of the policy variant shown ('en', 'fr', ...). */
|
|
@@ -70,6 +74,11 @@ export interface SailGuiConfig {
|
|
|
70
74
|
/** Hostnames a post-login `?return=` may redirect to (open-redirect allowlist),
|
|
71
75
|
* e.g. ['mcp.example.com']. Anything not listed is ignored. */
|
|
72
76
|
allowedReturnHosts?: string[];
|
|
77
|
+
|
|
78
|
+
/** Public route serving the password policy (keel PublicHandler.GetPasswordPolicy),
|
|
79
|
+
* e.g. '/public/v1/auth/password/policy'. When set, sail fetches it once and
|
|
80
|
+
* password inputs validate against it. Omitted → policy validation is skipped. */
|
|
81
|
+
passwordPolicyUrl?: string;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
export const SAIL_GUI_CONFIG = new InjectionToken<SailGuiConfig>('SailGuiConfig');
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export * from './component/login/chpass_component';
|
|
|
32
32
|
export * from './component/login/confirm_register_component';
|
|
33
33
|
export * from './component/login/confirm_chpass_component';
|
|
34
34
|
export * from './component/login/consent_gate';
|
|
35
|
+
export * from './component/login/sms_consent';
|
|
35
36
|
export * from './component/login/otp_input';
|
|
36
37
|
export * from './component/login/social_login';
|
|
37
38
|
export * from './component/login/account_deletion';
|
|
@@ -70,3 +71,4 @@ export * from './service/base_rest.service';
|
|
|
70
71
|
export * from './util/text';
|
|
71
72
|
export * from './util/money';
|
|
72
73
|
export * from './util/errors';
|
|
74
|
+
export * from './util/password_policy';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Injectable, WritableSignal, inject, signal } from '@angular/core';
|
|
2
2
|
import { HttpErrorResponse, HttpParams } from '@angular/common/http';
|
|
3
3
|
import { filter, map, mergeMap, take, tap } from 'rxjs/operators';
|
|
4
|
+
import { PasswordRules } from '../util/password_policy';
|
|
4
5
|
import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerRegistration, RestReport, TableDefinition, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
|
|
5
6
|
import {
|
|
6
7
|
LoginResponseSocial,
|
|
@@ -94,6 +95,22 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
94
95
|
token: string | null = null;
|
|
95
96
|
readonly isLoggedIn = signal(false);
|
|
96
97
|
|
|
98
|
+
/** keel password policy for pre-submit validation; undefined until loaded
|
|
99
|
+
* (or when guiConfig.passwordPolicyUrl is unset). Fed via ensurePasswordPolicy(). */
|
|
100
|
+
readonly passwordRules = signal<PasswordRules | undefined>(undefined);
|
|
101
|
+
private passwordPolicyLoading = false;
|
|
102
|
+
|
|
103
|
+
/** Fetch the policy once (public route, safe pre-login). No-op if already
|
|
104
|
+
* loaded/loading or no URL configured. */
|
|
105
|
+
ensurePasswordPolicy(): void {
|
|
106
|
+
if (this.passwordRules() || this.passwordPolicyLoading || !this.guiConfig.passwordPolicyUrl) return;
|
|
107
|
+
this.passwordPolicyLoading = true;
|
|
108
|
+
this.http.get<PasswordRules>(this.url(this.guiConfig.passwordPolicyUrl)).subscribe({
|
|
109
|
+
next: (rules) => this.passwordRules.set(rules),
|
|
110
|
+
error: () => { this.passwordPolicyLoading = false; },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
/** Last appdata load failure (null when none). */
|
|
98
115
|
readonly appDataError = signal<unknown>(null);
|
|
99
116
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
|
|
2
|
+
|
|
3
|
+
// keel PasswordPolicyView — the composition rules for pre-submit validation.
|
|
4
|
+
export interface PasswordRules {
|
|
5
|
+
minLength: number;
|
|
6
|
+
minUpper: number;
|
|
7
|
+
minLower: number;
|
|
8
|
+
minDigit: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type RulesSource = PasswordRules | (() => PasswordRules | undefined);
|
|
12
|
+
|
|
13
|
+
// Validator for keel's password policy. Empty is left to Validators.required;
|
|
14
|
+
// undefined rules (not loaded) skip the check. Char classes match keel's ASCII rules.
|
|
15
|
+
export function passwordPolicyValidator(source: RulesSource): ValidatorFn {
|
|
16
|
+
return (control: AbstractControl): ValidationErrors | null => {
|
|
17
|
+
const rules = typeof source === 'function' ? source() : source;
|
|
18
|
+
const value: string = control.value ?? '';
|
|
19
|
+
if (!rules || !value) return null;
|
|
20
|
+
let upper = 0, lower = 0, digit = 0;
|
|
21
|
+
for (const ch of value) {
|
|
22
|
+
if (ch >= 'A' && ch <= 'Z') upper++;
|
|
23
|
+
else if (ch >= 'a' && ch <= 'z') lower++;
|
|
24
|
+
else if (ch >= '0' && ch <= '9') digit++;
|
|
25
|
+
}
|
|
26
|
+
const unmet: string[] = [];
|
|
27
|
+
if (value.length < rules.minLength) unmet.push(`${rules.minLength} characters`);
|
|
28
|
+
if (upper < rules.minUpper) unmet.push('an uppercase letter');
|
|
29
|
+
if (lower < rules.minLower) unmet.push('a lowercase letter');
|
|
30
|
+
if (digit < rules.minDigit) unmet.push('a number');
|
|
31
|
+
return unmet.length ? { passwordPolicy: { message: `Password needs ${unmet.join(', ')}.` } } : null;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Human-readable requirement string built from the loaded rules ('' when unknown).
|
|
36
|
+
export function passwordPolicyHint(rules?: PasswordRules): string {
|
|
37
|
+
if (!rules) return '';
|
|
38
|
+
const parts = [`at least ${rules.minLength} characters`];
|
|
39
|
+
if (rules.minUpper > 0) parts.push('an uppercase letter');
|
|
40
|
+
if (rules.minLower > 0) parts.push('a lowercase letter');
|
|
41
|
+
if (rules.minDigit > 0) parts.push('a number');
|
|
42
|
+
return `Use ${parts.join(', ')}.`;
|
|
43
|
+
}
|