@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.0",
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<{ url?: string }>(action.method, body).subscribe({
121
+ this.backendService.executeAction<Record<string, unknown>>(action.method, body).subscribe({
118
122
  next: (resp) => {
119
- // A 'redirect' action POSTs then follows the returned {url}
120
- // (provider-hosted checkout / portal / any external handoff
121
- // declared as basis table_action metadata — no component).
122
- if (action.kind === 'redirect' && resp?.url) {
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
+ }
@@ -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?: 'post' | 'redirect'; // 'redirect' = POST then follow the returned {url}; default 'post'
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
- if (token && isKeelApiUrl(req.url)) {
7
- req = req.clone({
8
- setHeaders: { Authorization: `Bearer ${token}` },
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
- return next(req);
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
+ }