@nauticana/sail 1.1.9 → 1.1.10
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 +4 -2
- package/package.json +5 -1
- package/src/component/abstract/base_async.ts +9 -11
- package/src/component/abstract/base_form.ts +6 -5
- package/src/component/abstract/base_table.ts +131 -58
- package/src/component/abstract/base_view.ts +43 -27
- package/src/component/account/profile_editor.ts +2 -2
- package/src/component/billing/payment_methods.ts +1 -2
- package/src/component/billing/price_selector.html +2 -1
- package/src/component/billing/price_selector.ts +28 -13
- package/src/component/billing/trial_banner.ts +7 -1
- package/src/component/billing/usage_meter.ts +2 -2
- package/src/component/dashboard/data_card.html +1 -1
- package/src/component/dashboard/entity_selector.ts +21 -23
- package/src/component/form/form_field.html +6 -5
- package/src/component/form/form_field.ts +39 -21
- package/src/component/form/form_record.html +3 -3
- package/src/component/form/form_record.ts +23 -8
- package/src/component/form/form_table.html +2 -2
- package/src/component/form/form_table.ts +6 -5
- package/src/component/login/account_deletion.ts +6 -6
- package/src/component/login/chpass_component.html +4 -4
- package/src/component/login/chpass_component.ts +0 -1
- package/src/component/login/confirm_chpass_component.html +4 -4
- package/src/component/login/confirm_chpass_component.ts +1 -4
- package/src/component/login/consent_gate.html +6 -6
- package/src/component/login/consent_gate.ts +17 -24
- package/src/component/login/login_component.html +7 -4
- package/src/component/login/login_component.ts +9 -5
- package/src/component/login/otp_input.html +13 -5
- package/src/component/login/otp_input.ts +34 -27
- package/src/component/login/register_component.html +31 -14
- package/src/component/login/register_component.ts +80 -89
- package/src/component/login/social_login.html +2 -2
- package/src/component/login/social_login.ts +20 -13
- package/src/component/navigation/navigation.html +2 -2
- package/src/component/navigation/navigation.ts +2 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
- package/src/component/payment/user_payment_methods.html +2 -2
- package/src/component/payment/user_payment_methods.ts +12 -23
- package/src/component/payout/bank_info_form.html +4 -4
- package/src/component/payout/bank_info_form.ts +22 -26
- package/src/component/payout/provider_onboarding.html +5 -5
- package/src/component/payout/provider_onboarding.ts +28 -48
- package/src/component/security/trusted_devices.html +1 -1
- package/src/component/security/trusted_devices.ts +0 -1
- package/src/component/security/twofactor_setup.html +8 -4
- package/src/component/security/twofactor_setup.ts +21 -3
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +2 -8
- package/src/component/table/table_detail.html +15 -14
- package/src/component/table/table_detail.ts +62 -41
- package/src/component/table/table_edit.html +15 -11
- package/src/component/table/table_edit.ts +94 -67
- package/src/component/table/table_list.html +45 -12
- package/src/component/table/table_list.ts +98 -26
- package/src/component/table/table_lookup.html +11 -8
- package/src/component/table/table_lookup.ts +45 -37
- package/src/component/table/table_report.html +2 -2
- package/src/component/table/table_report.ts +37 -31
- package/src/component/table/table_search.html +4 -4
- package/src/component/table/table_search.ts +36 -45
- package/src/config.ts +1 -1
- package/src/index.ts +1 -0
- package/src/model/appdata.ts +5 -9
- package/src/model/common.ts +27 -27
- package/src/model/decorator.ts +25 -21
- package/src/service/api-response.interceptor.ts +4 -1
- package/src/service/auth.interceptor.ts +27 -7
- package/src/service/auth.service.ts +188 -134
- package/src/service/label.service.ts +6 -5
- package/src/service/oauth_connection.service.ts +2 -2
- package/src/service/rest_service.ts +57 -13
- package/src/service/rest_url.ts +23 -7
- package/src/service/script_loader.ts +12 -2
- package/src/util/errors.ts +20 -0
- package/src/util/money.ts +19 -7
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
|
+
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
2
3
|
import { MatButtonModule } from "@angular/material/button";
|
|
3
4
|
import { MatIconModule } from "@angular/material/icon";
|
|
4
5
|
import { ActivatedRoute, Router } from "@angular/router";
|
|
6
|
+
import { take } from "rxjs";
|
|
5
7
|
import { BaseForm } from "../abstract/base_form";
|
|
6
8
|
import { RecordForm } from "../form/form_record";
|
|
7
9
|
import { ApplicationMenu } from "../../model/common";
|
|
@@ -26,11 +28,11 @@ export class TableSearch extends BaseForm implements OnInit {
|
|
|
26
28
|
readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
27
29
|
readonly targetRoute = linkedSignal<string, string>({ source: () => this.targetRouteInput(), computation: (v, p) => v || p?.value || '' });
|
|
28
30
|
|
|
29
|
-
searchColumns
|
|
31
|
+
readonly searchColumns = this.metadataColumns;
|
|
30
32
|
|
|
31
33
|
private readonly router = inject(Router);
|
|
32
34
|
private readonly route = inject(ActivatedRoute);
|
|
33
|
-
private readonly
|
|
35
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* Search screen surfaces only table-level actions; record-specific actions
|
|
@@ -43,30 +45,31 @@ export class TableSearch extends BaseForm implements OnInit {
|
|
|
43
45
|
if (!this.tableName() && data['tableName']) this.tableName.set(data['tableName']);
|
|
44
46
|
if (!this.apiName() && data['apiName']) this.apiName.set(data['apiName']);
|
|
45
47
|
if (!this.targetRoute() && data['targetRoute']) this.targetRoute.set(data['targetRoute']);
|
|
46
|
-
this.title
|
|
48
|
+
this.title.set(this.getCaption() + ' Search');
|
|
47
49
|
if (!this.targetRoute() && (this.apiName() || this.tableName())) {
|
|
48
|
-
this.cacheService.getMenus().
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
this.cacheService.getMenus().pipe(
|
|
51
|
+
take(1),
|
|
52
|
+
takeUntilDestroyed(this.destroyRef),
|
|
53
|
+
).subscribe((menus: ApplicationMenu[]) => this.resolveTargetRoute(menus));
|
|
54
|
+
}
|
|
55
|
+
this.editableRecord.set(this.emptySearchRecord());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Exact RestUri match only — substring matching routed `user` to `user_payment_method`. */
|
|
59
|
+
private resolveTargetRoute(menus: ApplicationMenu[]) {
|
|
60
|
+
const apiName = this.apiName();
|
|
61
|
+
const tableName = this.tableName();
|
|
62
|
+
for (const menu of menus) {
|
|
63
|
+
if (!menu.Id) continue;
|
|
64
|
+
for (const page of menu.ApplicationMenuItems ?? []) {
|
|
65
|
+
if (!page.ItemId || !page.RestUri) continue;
|
|
66
|
+
if (page.RestUri === apiName || (!apiName && tableName && page.RestUri === tableName)) {
|
|
67
|
+
const base = '/' + menu.Id.toLowerCase() + '/' + page.ItemId;
|
|
68
|
+
this.targetRoute.set(page.FilterOnList ? base : base + '/list');
|
|
69
|
+
return;
|
|
65
70
|
}
|
|
66
|
-
}
|
|
71
|
+
}
|
|
67
72
|
}
|
|
68
|
-
this.editableRecord = this.emptySearchRecord();
|
|
69
|
-
this.searchColumns = this.getDisplayedColumns();
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
onSearch() {
|
|
@@ -75,35 +78,23 @@ export class TableSearch extends BaseForm implements OnInit {
|
|
|
75
78
|
alert('Navigation target not found for this table');
|
|
76
79
|
return;
|
|
77
80
|
}
|
|
78
|
-
this.router.navigate([targetRoute], {queryParams: this.buildSearchTerms(this.editableRecord)});
|
|
81
|
+
this.router.navigate([targetRoute], {queryParams: this.buildSearchTerms(this.editableRecord())});
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
onClear() {
|
|
82
|
-
this.editableRecord
|
|
85
|
+
this.editableRecord.set(this.emptySearchRecord());
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* default would otherwise have the form submit `?IsPrimary=false` and
|
|
90
|
-
* silently exclude every primary-flag row).
|
|
91
|
-
*
|
|
92
|
-
* The user opts into a boolean filter by clicking the checkbox: first
|
|
93
|
-
* click sets it to `true`, a second click sets it to `false`, and the
|
|
94
|
-
* Clear button resets back to `null` (no filter). `buildSearchTerms`
|
|
95
|
-
* already skips `null` / `undefined` values, so the URL only carries
|
|
96
|
-
* filters the user explicitly toggled.
|
|
89
|
+
* emptyRecord() with every seeded default blanked (booleans to null), so an
|
|
90
|
+
* unmodified Search never ships implicit filters like `?Status=A`.
|
|
91
|
+
* buildSearchTerms skips null/'' — only user-set values reach the URL.
|
|
97
92
|
*/
|
|
98
|
-
private emptySearchRecord():
|
|
93
|
+
private emptySearchRecord(): Record<string, unknown> {
|
|
99
94
|
const rec = this.emptyRecord();
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (col.DataType === 'boolean') {
|
|
104
|
-
rec[col.PascalName] = null;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
95
|
+
for (const key of Object.keys(rec)) {
|
|
96
|
+
if (key === this.config.opField || Array.isArray(rec[key])) continue;
|
|
97
|
+
rec[key] = this.isBoolean(key) ? null : '';
|
|
107
98
|
}
|
|
108
99
|
return rec;
|
|
109
100
|
}
|
package/src/config.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface SailGuiConfig {
|
|
|
31
31
|
restUrls?: { [key: string]: string };
|
|
32
32
|
publicRoutes?: Routes;
|
|
33
33
|
extraRoutes?: (data: ApplicationData) => Routes;
|
|
34
|
-
dashboardComponent?: Type<
|
|
34
|
+
dashboardComponent?: Type<unknown>;
|
|
35
35
|
appTitle?: string;
|
|
36
36
|
/** Public asset URL for the application logo shown in the navigation home link, e.g. 'logo.png'. */
|
|
37
37
|
appLogoUrl?: string;
|
package/src/index.ts
CHANGED
package/src/model/appdata.ts
CHANGED
|
@@ -18,13 +18,6 @@ export interface LoginResponse {
|
|
|
18
18
|
token: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export interface AuthSummary {
|
|
22
|
-
Obj: string;
|
|
23
|
-
Act: string;
|
|
24
|
-
Val: string;
|
|
25
|
-
Reg: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
21
|
export interface ApplicationData {
|
|
29
22
|
MainMenu: ApplicationMenu[];
|
|
30
23
|
Permissions: AuthorizationRolePermission[];
|
|
@@ -32,6 +25,8 @@ export interface ApplicationData {
|
|
|
32
25
|
TableCache: {[key: string]: {[key: string]: string}};
|
|
33
26
|
TableDefinitions: {[key: string]: TableDefinition};
|
|
34
27
|
Apis: {[key: string]: DictionaryPath};
|
|
28
|
+
/** keel emits Reports as map[string]*RestReport — a JSON object keyed by report id. */
|
|
29
|
+
Reports?: {[key: string]: RestReport};
|
|
35
30
|
[key: string]: unknown;
|
|
36
31
|
}
|
|
37
32
|
|
|
@@ -195,8 +190,9 @@ export interface PartnerRegistration {
|
|
|
195
190
|
Zipcode: string;
|
|
196
191
|
Country: string;
|
|
197
192
|
Phone: string;
|
|
198
|
-
|
|
199
|
-
|
|
193
|
+
// null when geocoding is disabled or the address wasn't verified.
|
|
194
|
+
Latitude: number | null;
|
|
195
|
+
Longitude: number | null;
|
|
200
196
|
DomainURL: string;
|
|
201
197
|
PlanID: string;
|
|
202
198
|
}
|
package/src/model/common.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { IsSailString, IsSailNumeric } from './decorator';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* SIUD operation codes that travel on every row through keel's generic CRUD.
|
|
@@ -43,32 +43,32 @@ export class SiudAction {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export class UserAccountPolicy extends SiudAction {
|
|
46
|
-
@
|
|
46
|
+
@IsSailString(30)
|
|
47
47
|
Id?: string;
|
|
48
48
|
|
|
49
|
-
@
|
|
49
|
+
@IsSailNumeric(9, 0)
|
|
50
50
|
PolicyValue?: number;
|
|
51
51
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
export class ApplicationMenuItem extends SiudAction {
|
|
56
|
-
@
|
|
56
|
+
@IsSailString(30)
|
|
57
57
|
MenuId?: string;
|
|
58
58
|
|
|
59
|
-
@
|
|
59
|
+
@IsSailString(30)
|
|
60
60
|
ItemId?: string;
|
|
61
61
|
|
|
62
|
-
@
|
|
62
|
+
@IsSailString(80)
|
|
63
63
|
Caption?: string;
|
|
64
64
|
|
|
65
|
-
@
|
|
65
|
+
@IsSailNumeric(9, 0)
|
|
66
66
|
DisplayOrder?: number;
|
|
67
67
|
|
|
68
|
-
@
|
|
68
|
+
@IsSailString(255)
|
|
69
69
|
RestUri?: string;
|
|
70
70
|
|
|
71
|
-
@
|
|
71
|
+
@IsSailString(80)
|
|
72
72
|
Icon?: string;
|
|
73
73
|
|
|
74
74
|
IsActive?: boolean;
|
|
@@ -79,16 +79,16 @@ export class ApplicationMenuItem extends SiudAction {
|
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
export class ApplicationMenu extends SiudAction {
|
|
82
|
-
@
|
|
82
|
+
@IsSailString(30)
|
|
83
83
|
Id?: string;
|
|
84
84
|
|
|
85
|
-
@
|
|
85
|
+
@IsSailString(80)
|
|
86
86
|
Caption?: string;
|
|
87
87
|
|
|
88
|
-
@
|
|
88
|
+
@IsSailNumeric(9, 0)
|
|
89
89
|
DisplayOrder?: number;
|
|
90
90
|
|
|
91
|
-
@
|
|
91
|
+
@IsSailString(80)
|
|
92
92
|
Icon?: string;
|
|
93
93
|
|
|
94
94
|
IsActive?: boolean;
|
|
@@ -98,19 +98,19 @@ export class ApplicationMenu extends SiudAction {
|
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
export class AuthorizationRolePermission extends SiudAction {
|
|
101
|
-
@
|
|
101
|
+
@IsSailString(30)
|
|
102
102
|
RoleId?: string;
|
|
103
103
|
|
|
104
|
-
@
|
|
104
|
+
@IsSailString(30)
|
|
105
105
|
AuthorizationObjectId?: string;
|
|
106
106
|
|
|
107
|
-
@
|
|
107
|
+
@IsSailString(30)
|
|
108
108
|
Action?: string;
|
|
109
109
|
|
|
110
|
-
@
|
|
110
|
+
@IsSailString(80)
|
|
111
111
|
LowLimit?: string;
|
|
112
112
|
|
|
113
|
-
@
|
|
113
|
+
@IsSailString(80)
|
|
114
114
|
HighLimit?: string;
|
|
115
115
|
|
|
116
116
|
IsActive?: boolean;
|
|
@@ -119,35 +119,35 @@ export class AuthorizationRolePermission extends SiudAction {
|
|
|
119
119
|
|
|
120
120
|
|
|
121
121
|
export class ConstantValue extends SiudAction {
|
|
122
|
-
@
|
|
122
|
+
@IsSailString(60)
|
|
123
123
|
ConstantId?: string;
|
|
124
124
|
|
|
125
|
-
@
|
|
125
|
+
@IsSailString(80)
|
|
126
126
|
Value?: string;
|
|
127
127
|
|
|
128
|
-
@
|
|
128
|
+
@IsSailString(80)
|
|
129
129
|
Caption?: string;
|
|
130
130
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
export class UserAccount extends SiudAction {
|
|
135
|
-
@
|
|
135
|
+
@IsSailNumeric(9, 0)
|
|
136
136
|
Id?: number;
|
|
137
137
|
|
|
138
|
-
@
|
|
138
|
+
@IsSailString(80)
|
|
139
139
|
FirstName?: string;
|
|
140
140
|
|
|
141
|
-
@
|
|
141
|
+
@IsSailString(80)
|
|
142
142
|
LastName?: string;
|
|
143
143
|
|
|
144
|
-
@
|
|
144
|
+
@IsSailString(80)
|
|
145
145
|
UserName?: string;
|
|
146
146
|
|
|
147
|
-
@
|
|
147
|
+
@IsSailString(255)
|
|
148
148
|
UserEmail?: string;
|
|
149
149
|
|
|
150
|
-
@
|
|
150
|
+
@IsSailString(1)
|
|
151
151
|
Status?: string;
|
|
152
152
|
|
|
153
153
|
}
|
package/src/model/decorator.ts
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
|
+
registerDecorator,
|
|
3
|
+
ValidationOptions,
|
|
2
4
|
ValidatorConstraint,
|
|
3
5
|
ValidatorConstraintInterface,
|
|
4
6
|
ValidationArguments,
|
|
5
7
|
} from 'class-validator';
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// Plain decimal only — exponential notation ('1e21') must not slip past
|
|
10
|
+
// the precision/scale digit counts.
|
|
11
|
+
const DECIMAL = /^-?\d+(\.\d+)?$/;
|
|
12
|
+
|
|
13
|
+
@ValidatorConstraint({ name: 'isSailNumeric', async: false })
|
|
14
|
+
export class IsSailNumericConstraint implements ValidatorConstraintInterface {
|
|
15
|
+
validate(value: unknown, args: ValidationArguments) {
|
|
16
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
11
17
|
return false;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
const [precision, scale] = args.constraints;
|
|
15
21
|
const valueString = value.toString();
|
|
22
|
+
if (!DECIMAL.test(valueString)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
16
25
|
const parts = valueString.split('.');
|
|
17
26
|
const integerPart = parts[0].startsWith('-') ? parts[0].substring(1) : parts[0];
|
|
18
27
|
const decimalPart = parts[1] || '';
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
if (integerPart.length + decimalPart.length > precision || decimalPart.length > scale) {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return true;
|
|
29
|
+
return integerPart.length + decimalPart.length <= precision && decimalPart.length <= scale;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
defaultMessage(_args: ValidationArguments) {
|
|
@@ -30,9 +34,9 @@ export class IsNumericConstraint implements ValidatorConstraintInterface {
|
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
@ValidatorConstraint({ name: '
|
|
34
|
-
export class
|
|
35
|
-
validate(value:
|
|
37
|
+
@ValidatorConstraint({ name: 'isSailString', async: false })
|
|
38
|
+
export class IsSailStringConstraint implements ValidatorConstraintInterface {
|
|
39
|
+
validate(value: unknown, args: ValidationArguments) {
|
|
36
40
|
const [maxLength] = args.constraints;
|
|
37
41
|
|
|
38
42
|
if (typeof value !== 'string') {
|
|
@@ -47,28 +51,28 @@ export class IsStringConstraint implements ValidatorConstraintInterface {
|
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export function
|
|
53
|
-
return function (object:
|
|
54
|
+
// Named IsSail* so they can't be confused with class-validator's own
|
|
55
|
+
// IsString/IsNumber on import.
|
|
56
|
+
export function IsSailNumeric(precision: number, scale: number, validationOptions?: ValidationOptions) {
|
|
57
|
+
return function (object: object, propertyName: string) {
|
|
54
58
|
registerDecorator({
|
|
55
59
|
target: object.constructor,
|
|
56
60
|
propertyName: propertyName,
|
|
57
61
|
options: validationOptions,
|
|
58
62
|
constraints: [precision, scale],
|
|
59
|
-
validator:
|
|
63
|
+
validator: IsSailNumericConstraint,
|
|
60
64
|
});
|
|
61
65
|
};
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
export function
|
|
65
|
-
return function (object:
|
|
68
|
+
export function IsSailString(maxLength: number, validationOptions?: ValidationOptions) {
|
|
69
|
+
return function (object: object, propertyName: string) {
|
|
66
70
|
registerDecorator({
|
|
67
71
|
target: object.constructor,
|
|
68
72
|
propertyName: propertyName,
|
|
69
73
|
options: validationOptions,
|
|
70
74
|
constraints: [maxLength],
|
|
71
|
-
validator:
|
|
75
|
+
validator: IsSailStringConstraint,
|
|
72
76
|
});
|
|
73
77
|
};
|
|
74
78
|
}
|
|
@@ -34,7 +34,10 @@ export const apiResponseInterceptor: HttpInterceptorFn = (req, next) => {
|
|
|
34
34
|
map(event => {
|
|
35
35
|
if (isFinalResponse(event)) {
|
|
36
36
|
const body = event.body as Record<string, unknown> | null;
|
|
37
|
-
|
|
37
|
+
// keel's common.APIResponse is `{data, pagination?, meta}` with `meta`
|
|
38
|
+
// always present. Requiring both keys keeps a legitimate body that
|
|
39
|
+
// merely contains a `data` field from being truncated to it.
|
|
40
|
+
if (body && typeof body === 'object' && 'data' in body && 'meta' in body) {
|
|
38
41
|
return event.clone({ body: body['data'] });
|
|
39
42
|
}
|
|
40
43
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
|
|
2
|
+
import { inject, Injector } from '@angular/core';
|
|
2
3
|
import { catchError, tap, throwError } from 'rxjs';
|
|
3
4
|
import { isKeelApiUrl } from './rest_url';
|
|
5
|
+
import { BaseAuthService } from './auth.service';
|
|
4
6
|
|
|
5
7
|
// Auth-loop breaker: repeated 401s on token-bearing API calls (a stale session) can
|
|
6
8
|
// drive the app to hammer the API/CDN. After AUTH_FAIL_THRESHOLD within
|
|
@@ -13,7 +15,18 @@ const AUTH_CIRCUIT_COOLDOWN_MS = 30_000;
|
|
|
13
15
|
const FAIL_KEY = 'keel_auth_401s';
|
|
14
16
|
const OPEN_KEY = 'keel_auth_circuit_until';
|
|
15
17
|
|
|
18
|
+
/** Error-body code carried by the synthetic circuit-open rejection, so
|
|
19
|
+
* consumers can tell "session expired, request refused locally" apart from a
|
|
20
|
+
* real 401 and from a network failure (status 0). */
|
|
21
|
+
export const AUTH_CIRCUIT_OPEN_CODE = 'sail_auth_circuit_open';
|
|
22
|
+
|
|
16
23
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
24
|
+
// Resolved lazily per event to avoid a DI cycle (BaseAuthService → HttpClient
|
|
25
|
+
// → interceptors) and so an open circuit clears the dead session even when it
|
|
26
|
+
// was opened by a previous page load.
|
|
27
|
+
const injector = inject(Injector);
|
|
28
|
+
const expireSession = () => injector.get(BaseAuthService, null, { optional: true })?.sessionExpired();
|
|
29
|
+
|
|
17
30
|
const token = localStorage.getItem('jwt');
|
|
18
31
|
const authedApi = !!token && isKeelApiUrl(req.url);
|
|
19
32
|
if (authedApi) {
|
|
@@ -21,10 +34,15 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
if (authedApi && authCircuitOpen()) {
|
|
37
|
+
expireSession();
|
|
24
38
|
return throwError(() => new HttpErrorResponse({
|
|
25
39
|
url: req.url,
|
|
26
|
-
status:
|
|
27
|
-
statusText: '
|
|
40
|
+
status: 401,
|
|
41
|
+
statusText: 'Unauthorized',
|
|
42
|
+
error: {
|
|
43
|
+
code: AUTH_CIRCUIT_OPEN_CODE,
|
|
44
|
+
detail: 'auth circuit open: too many 401s — request stopped to prevent a loop',
|
|
45
|
+
},
|
|
28
46
|
}));
|
|
29
47
|
}
|
|
30
48
|
|
|
@@ -35,8 +53,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
|
35
53
|
}
|
|
36
54
|
}),
|
|
37
55
|
catchError((err: HttpErrorResponse) => {
|
|
38
|
-
if (authedApi && err.status === 401) {
|
|
39
|
-
|
|
56
|
+
if (authedApi && err.status === 401 && recordAuthFailure()) {
|
|
57
|
+
expireSession();
|
|
40
58
|
}
|
|
41
59
|
return throwError(() => err);
|
|
42
60
|
}),
|
|
@@ -47,7 +65,8 @@ function authCircuitOpen(): boolean {
|
|
|
47
65
|
return Date.now() < Number(sessionStorage.getItem(OPEN_KEY) ?? 0);
|
|
48
66
|
}
|
|
49
67
|
|
|
50
|
-
|
|
68
|
+
/** Returns true when this failure crossed the threshold and opened the circuit. */
|
|
69
|
+
function recordAuthFailure(): boolean {
|
|
51
70
|
const now = Date.now();
|
|
52
71
|
let times: number[];
|
|
53
72
|
try {
|
|
@@ -60,9 +79,10 @@ function recordAuthFailure(): void {
|
|
|
60
79
|
if (times.length >= AUTH_FAIL_THRESHOLD) {
|
|
61
80
|
sessionStorage.setItem(OPEN_KEY, String(now + AUTH_CIRCUIT_COOLDOWN_MS));
|
|
62
81
|
sessionStorage.removeItem(FAIL_KEY);
|
|
63
|
-
|
|
64
|
-
sessionStorage.setItem(FAIL_KEY, JSON.stringify(times));
|
|
82
|
+
return true;
|
|
65
83
|
}
|
|
84
|
+
sessionStorage.setItem(FAIL_KEY, JSON.stringify(times));
|
|
85
|
+
return false;
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
function clearAuthFailures(): void {
|