@nauticana/sail 1.1.11 → 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/config.ts +5 -0
- package/src/index.ts +1 -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 });
|
package/src/config.ts
CHANGED
|
@@ -74,6 +74,11 @@ export interface SailGuiConfig {
|
|
|
74
74
|
/** Hostnames a post-login `?return=` may redirect to (open-redirect allowlist),
|
|
75
75
|
* e.g. ['mcp.example.com']. Anything not listed is ignored. */
|
|
76
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;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
export const SAIL_GUI_CONFIG = new InjectionToken<SailGuiConfig>('SailGuiConfig');
|
package/src/index.ts
CHANGED
|
@@ -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
|
+
}
|