@nauticana/sail 1.1.0 → 1.1.2
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
CHANGED
|
@@ -140,6 +140,8 @@ bootstrapApplication(App, {
|
|
|
140
140
|
});
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
> **Auth-loop breaker.** `authInterceptor` carries a built-in circuit breaker: after 5 `401`/`403`s on token-bearing keel API calls within 10s it opens for 30s and refuses those requests **locally** (never sent), so a stale/rejected session can't drive the app to hammer the API and the edge/CDN. A later success closes it; login (no bearer) is never blocked, so recovery works. No wiring needed beyond registering `authInterceptor`.
|
|
144
|
+
|
|
143
145
|
### 5. Create the root component
|
|
144
146
|
|
|
145
147
|
```typescript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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": {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { inject, signal, WritableSignal } from "@angular/core";
|
|
1
|
+
import { inject, signal, WritableSignal, Injector } from "@angular/core";
|
|
2
2
|
import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
|
|
3
3
|
import { ConstantValue, LookupStyle, OpCode } from "../../model/common";
|
|
4
4
|
import { BaseAuthService } from "../../service/auth.service";
|
|
5
5
|
import { BackendService } from "../../service/rest_service";
|
|
6
6
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
|
|
7
7
|
import { titleCase } from "../../util/text";
|
|
8
|
+
import { MatDialog } from "@angular/material/dialog";
|
|
9
|
+
import { RevealDialog } from "../table/reveal_dialog";
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
/** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
|
|
@@ -14,6 +16,8 @@ export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
|
|
|
14
16
|
export abstract class BaseTable {
|
|
15
17
|
protected readonly cacheService = inject(BaseAuthService);
|
|
16
18
|
protected readonly backendService = inject(BackendService);
|
|
19
|
+
// Reveal dialogs are rare — resolve MatDialog lazily so every BaseTable subclass doesn't eagerly depend on it.
|
|
20
|
+
readonly #injector = inject(Injector);
|
|
17
21
|
protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
18
22
|
protected readonly dialogWidth: string = '400px';
|
|
19
23
|
readonly tableName: WritableSignal<string> = signal('');
|
|
@@ -114,15 +118,18 @@ export abstract class BaseTable {
|
|
|
114
118
|
if (!this.beforeExecuteAction(action, record)) return;
|
|
115
119
|
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
116
120
|
const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
117
|
-
this.backendService.executeAction<
|
|
121
|
+
this.backendService.executeAction<Record<string, unknown>>(action.method, body).subscribe({
|
|
118
122
|
next: (resp) => {
|
|
119
|
-
|
|
120
|
-
// (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
window.location.href = resp.url;
|
|
123
|
+
const url = resp?.['url'];
|
|
124
|
+
// R = redirect: POST then follow the returned {url} (hosted checkout / portal).
|
|
125
|
+
if (action.kind === 'R' && typeof url === 'string') {
|
|
126
|
+
window.location.href = url;
|
|
124
127
|
return;
|
|
125
128
|
}
|
|
129
|
+
// V = reveal: show the returned secret once (e.g. a freshly minted key).
|
|
130
|
+
if (action.kind === 'V') {
|
|
131
|
+
this.#injector.get(MatDialog).open(RevealDialog, { data: resp ?? {}, width: '480px' });
|
|
132
|
+
}
|
|
126
133
|
this.onActionSuccess(action, record);
|
|
127
134
|
},
|
|
128
135
|
error: (err) => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<h2 mat-dialog-title>Copy now</h2>
|
|
2
|
+
<mat-dialog-content>
|
|
3
|
+
<p>This won't be shown again.</p>
|
|
4
|
+
@for (entry of entries; track entry[0]) {
|
|
5
|
+
<p style="margin:12px 0 2px; font-size:12px; opacity:0.7;">{{ entry[0] }}</p>
|
|
6
|
+
<div style="display:flex; align-items:center; gap:8px;">
|
|
7
|
+
<code style="flex:1; word-break:break-all;">{{ entry[1] }}</code>
|
|
8
|
+
<button matIconButton (click)="copy(entry[1])" [attr.aria-label]="'Copy ' + entry[0]">
|
|
9
|
+
<mat-icon>content_copy</mat-icon>
|
|
10
|
+
</button>
|
|
11
|
+
</div>
|
|
12
|
+
}
|
|
13
|
+
</mat-dialog-content>
|
|
14
|
+
<mat-dialog-actions align="end">
|
|
15
|
+
<button matButton mat-dialog-close>Done</button>
|
|
16
|
+
</mat-dialog-actions>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
|
2
|
+
import { MAT_DIALOG_DATA, MatDialogModule } from "@angular/material/dialog";
|
|
3
|
+
import { MatButtonModule } from "@angular/material/button";
|
|
4
|
+
import { MatIconModule } from "@angular/material/icon";
|
|
5
|
+
import { Clipboard } from "@angular/cdk/clipboard";
|
|
6
|
+
|
|
7
|
+
/** Shows the one-time secret a kind:'V' reveal action returned (e.g. a minted API key), with copy buttons. */
|
|
8
|
+
@Component({
|
|
9
|
+
selector: "sail-reveal-dialog",
|
|
10
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
11
|
+
imports: [MatDialogModule, MatButtonModule, MatIconModule],
|
|
12
|
+
templateUrl: "./reveal_dialog.html",
|
|
13
|
+
})
|
|
14
|
+
export class RevealDialog {
|
|
15
|
+
private readonly clipboard = inject(Clipboard);
|
|
16
|
+
readonly entries = Object.entries(inject<Record<string, unknown>>(MAT_DIALOG_DATA))
|
|
17
|
+
.filter(([, v]) => typeof v === "string" && v.length > 0) as [string, string][];
|
|
18
|
+
|
|
19
|
+
copy(value: string): void {
|
|
20
|
+
this.clipboard.copy(value);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/model/appdata.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface TableAction {
|
|
|
83
83
|
confirmMessage?: string;
|
|
84
84
|
authorityObject: string; // uppercased table_name — for canExecute()
|
|
85
85
|
authorityCheck: string; // uppercased action_name — for canExecute()
|
|
86
|
-
kind?: '
|
|
86
|
+
kind?: 'P' | 'R' | 'V'; // P post (default) / R redirect (follow {url}) / V reveal (show returned secret once)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export interface ForeignKey {
|
|
@@ -1,12 +1,72 @@
|
|
|
1
|
-
import { HttpInterceptorFn } from '@angular/common/http';
|
|
1
|
+
import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
|
|
2
|
+
import { catchError, tap, throwError } from 'rxjs';
|
|
2
3
|
import { isKeelApiUrl } from './rest_url';
|
|
3
4
|
|
|
5
|
+
// Auth-loop breaker: repeated 401/403s on token-bearing API calls (a stale or
|
|
6
|
+
// rejected session) can drive the app to hammer the API — and the edge/CDN in
|
|
7
|
+
// front of it — indefinitely. After AUTH_FAIL_THRESHOLD failures within
|
|
8
|
+
// AUTH_FAIL_WINDOW_MS the circuit opens for AUTH_CIRCUIT_COOLDOWN_MS: authed API
|
|
9
|
+
// requests are refused locally (never sent) so the storm stops at the browser. A
|
|
10
|
+
// later success closes it; login (no bearer) is never blocked, so recovery works.
|
|
11
|
+
const AUTH_FAIL_WINDOW_MS = 10_000;
|
|
12
|
+
const AUTH_FAIL_THRESHOLD = 5;
|
|
13
|
+
const AUTH_CIRCUIT_COOLDOWN_MS = 30_000;
|
|
14
|
+
const FAIL_KEY = 'keel_auth_401s';
|
|
15
|
+
const OPEN_KEY = 'keel_auth_circuit_until';
|
|
16
|
+
|
|
4
17
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
5
18
|
const token = localStorage.getItem('jwt');
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
19
|
+
const authedApi = !!token && isKeelApiUrl(req.url);
|
|
20
|
+
if (authedApi) {
|
|
21
|
+
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (authedApi && authCircuitOpen()) {
|
|
25
|
+
return throwError(() => new HttpErrorResponse({
|
|
26
|
+
url: req.url,
|
|
27
|
+
status: 0,
|
|
28
|
+
statusText: 'auth circuit open: too many 401s — request stopped to prevent a loop',
|
|
29
|
+
}));
|
|
10
30
|
}
|
|
11
|
-
|
|
31
|
+
|
|
32
|
+
return next(req).pipe(
|
|
33
|
+
tap((event) => {
|
|
34
|
+
if (authedApi && event.type === HttpEventType.Response) {
|
|
35
|
+
clearAuthFailures();
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
catchError((err: HttpErrorResponse) => {
|
|
39
|
+
if (authedApi && (err.status === 401 || err.status === 403)) {
|
|
40
|
+
recordAuthFailure();
|
|
41
|
+
}
|
|
42
|
+
return throwError(() => err);
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
12
45
|
};
|
|
46
|
+
|
|
47
|
+
function authCircuitOpen(): boolean {
|
|
48
|
+
return Date.now() < Number(sessionStorage.getItem(OPEN_KEY) ?? 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function recordAuthFailure(): void {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
let times: number[];
|
|
54
|
+
try {
|
|
55
|
+
times = (JSON.parse(sessionStorage.getItem(FAIL_KEY) ?? '[]') as number[])
|
|
56
|
+
.filter((t) => now - t < AUTH_FAIL_WINDOW_MS);
|
|
57
|
+
} catch {
|
|
58
|
+
times = [];
|
|
59
|
+
}
|
|
60
|
+
times.push(now);
|
|
61
|
+
if (times.length >= AUTH_FAIL_THRESHOLD) {
|
|
62
|
+
sessionStorage.setItem(OPEN_KEY, String(now + AUTH_CIRCUIT_COOLDOWN_MS));
|
|
63
|
+
sessionStorage.removeItem(FAIL_KEY);
|
|
64
|
+
} else {
|
|
65
|
+
sessionStorage.setItem(FAIL_KEY, JSON.stringify(times));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clearAuthFailures(): void {
|
|
70
|
+
sessionStorage.removeItem(FAIL_KEY);
|
|
71
|
+
sessionStorage.removeItem(OPEN_KEY);
|
|
72
|
+
}
|