@nauticana/sail 1.1.8 → 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.
Files changed (79) hide show
  1. package/README.md +36 -3
  2. package/package.json +5 -1
  3. package/src/component/abstract/base_async.ts +9 -11
  4. package/src/component/abstract/base_form.ts +6 -5
  5. package/src/component/abstract/base_table.ts +131 -58
  6. package/src/component/abstract/base_view.ts +43 -27
  7. package/src/component/account/profile_editor.ts +2 -2
  8. package/src/component/billing/payment_methods.ts +1 -2
  9. package/src/component/billing/price_selector.html +2 -1
  10. package/src/component/billing/price_selector.ts +28 -13
  11. package/src/component/billing/trial_banner.ts +7 -1
  12. package/src/component/billing/usage_meter.ts +2 -2
  13. package/src/component/dashboard/data_card.html +1 -1
  14. package/src/component/dashboard/entity_selector.ts +21 -23
  15. package/src/component/form/form_field.html +6 -5
  16. package/src/component/form/form_field.ts +39 -21
  17. package/src/component/form/form_record.html +3 -3
  18. package/src/component/form/form_record.ts +23 -8
  19. package/src/component/form/form_table.html +2 -2
  20. package/src/component/form/form_table.ts +6 -5
  21. package/src/component/login/account_deletion.ts +6 -6
  22. package/src/component/login/chpass_component.html +4 -4
  23. package/src/component/login/chpass_component.ts +0 -1
  24. package/src/component/login/confirm_chpass_component.html +4 -4
  25. package/src/component/login/confirm_chpass_component.ts +1 -4
  26. package/src/component/login/consent_gate.html +6 -6
  27. package/src/component/login/consent_gate.ts +17 -24
  28. package/src/component/login/login_component.html +7 -4
  29. package/src/component/login/login_component.ts +9 -5
  30. package/src/component/login/otp_input.html +13 -5
  31. package/src/component/login/otp_input.ts +34 -27
  32. package/src/component/login/register_component.html +31 -14
  33. package/src/component/login/register_component.ts +80 -89
  34. package/src/component/login/social_login.html +2 -2
  35. package/src/component/login/social_login.ts +20 -13
  36. package/src/component/navigation/navigation.html +2 -2
  37. package/src/component/navigation/navigation.ts +2 -0
  38. package/src/component/oauth_connection/oauth_connection_panel.html +68 -0
  39. package/src/component/oauth_connection/oauth_connection_panel.ts +252 -0
  40. package/src/component/payment/user_payment_methods.html +2 -2
  41. package/src/component/payment/user_payment_methods.ts +12 -23
  42. package/src/component/payout/bank_info_form.html +4 -4
  43. package/src/component/payout/bank_info_form.ts +22 -26
  44. package/src/component/payout/provider_onboarding.html +5 -5
  45. package/src/component/payout/provider_onboarding.ts +28 -48
  46. package/src/component/security/trusted_devices.html +1 -1
  47. package/src/component/security/trusted_devices.ts +0 -1
  48. package/src/component/security/twofactor_setup.html +8 -4
  49. package/src/component/security/twofactor_setup.ts +21 -3
  50. package/src/component/security/twofactor_verify.html +2 -1
  51. package/src/component/security/twofactor_verify.ts +2 -8
  52. package/src/component/table/table_detail.html +15 -14
  53. package/src/component/table/table_detail.ts +62 -41
  54. package/src/component/table/table_edit.html +15 -11
  55. package/src/component/table/table_edit.ts +94 -67
  56. package/src/component/table/table_list.html +45 -12
  57. package/src/component/table/table_list.ts +98 -26
  58. package/src/component/table/table_lookup.html +11 -8
  59. package/src/component/table/table_lookup.ts +45 -37
  60. package/src/component/table/table_report.html +2 -2
  61. package/src/component/table/table_report.ts +37 -31
  62. package/src/component/table/table_search.html +4 -4
  63. package/src/component/table/table_search.ts +36 -45
  64. package/src/config.ts +1 -1
  65. package/src/index.ts +4 -0
  66. package/src/model/appdata.ts +5 -9
  67. package/src/model/common.ts +27 -27
  68. package/src/model/decorator.ts +25 -21
  69. package/src/model/oauth_connection.ts +50 -0
  70. package/src/service/api-response.interceptor.ts +4 -1
  71. package/src/service/auth.interceptor.ts +27 -7
  72. package/src/service/auth.service.ts +188 -134
  73. package/src/service/label.service.ts +6 -5
  74. package/src/service/oauth_connection.service.ts +56 -0
  75. package/src/service/rest_service.ts +57 -13
  76. package/src/service/rest_url.ts +23 -7
  77. package/src/service/script_loader.ts +12 -2
  78. package/src/util/errors.ts +20 -0
  79. package/src/util/money.ts +19 -7
@@ -1,7 +1,9 @@
1
- import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
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: string[] = [];
31
+ readonly searchColumns = this.metadataColumns;
30
32
 
31
33
  private readonly router = inject(Router);
32
34
  private readonly route = inject(ActivatedRoute);
33
- private readonly cdr = inject(ChangeDetectorRef);
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 = this.getCaption() + ' Search';
48
+ this.title.set(this.getCaption() + ' Search');
47
49
  if (!this.targetRoute() && (this.apiName() || this.tableName())) {
48
- this.cacheService.getMenus().subscribe((menus: ApplicationMenu[]) => {
49
- const apiName = this.apiName();
50
- const tableName = this.tableName();
51
- for (const menu of menus) {
52
- for (const page of menu.ApplicationMenuItems!) {
53
- if (apiName.endsWith(page.RestUri!) ||
54
- (tableName && page.RestUri!.includes(tableName))
55
- ) {
56
- if (page.FilterOnList) {
57
- this.targetRoute.set('/' + menu.Id!.toLowerCase() + '/' + page.ItemId!);
58
- } else {
59
- this.targetRoute.set('/' + menu.Id!.toLowerCase() + '/' + page.ItemId! + '/list');
60
- }
61
- this.cdr.markForCheck();
62
- return;
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 = this.emptySearchRecord();
85
+ this.editableRecord.set(this.emptySearchRecord());
83
86
  }
84
87
 
85
88
  /**
86
- * Search-form variant of `emptyRecord()`. Boolean columns are forced to
87
- * null so an unmodified Search click does not coerce filters from the
88
- * table's HasDefault value (e.g. an `is_primary` column with a `FALSE`
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(): any {
93
+ private emptySearchRecord(): Record<string, unknown> {
99
94
  const rec = this.emptyRecord();
100
- const tableDef = this.cacheService.getTableDefinition(this.tableName());
101
- if (tableDef?.Columns) {
102
- for (const col of tableDef.Columns) {
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<any>;
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
@@ -3,6 +3,7 @@ export * from './model/appdata';
3
3
  export * from './model/auth';
4
4
  export * from './model/common';
5
5
  export * from './model/dashboard';
6
+ export * from './model/oauth_connection';
6
7
  export * from './model/decorator';
7
8
  export * from './component/abstract/base_table';
8
9
  export * from './component/dashboard/dashboard_shell';
@@ -11,6 +12,7 @@ export * from './component/dashboard/locked_overlay';
11
12
  export * from './component/dashboard/action_center';
12
13
  export * from './component/dashboard/entity_selector';
13
14
  export * from './component/dashboard/verification_flow';
15
+ export * from './component/oauth_connection/oauth_connection_panel';
14
16
  export * from './component/abstract/base_form';
15
17
  export * from './component/abstract/base_view';
16
18
  export * from './component/abstract/base_async';
@@ -56,6 +58,7 @@ export * from './service/auth.service';
56
58
  export * from './service/sca_confirmer';
57
59
  export * from './service/billing.service';
58
60
  export * from './service/payout.service';
61
+ export * from './service/oauth_connection.service';
59
62
  export * from './service/user_payment_method.service';
60
63
  export * from './service/script_loader';
61
64
  export * from './service/auth.interceptor';
@@ -66,3 +69,4 @@ export * from './service/label.service';
66
69
  export * from './service/base_rest.service';
67
70
  export * from './util/text';
68
71
  export * from './util/money';
72
+ export * from './util/errors';
@@ -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
- Latitude: number;
199
- Longitude: number;
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
  }
@@ -1,4 +1,4 @@
1
- import { IsString, IsNumeric } from './decorator';
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
- @IsString(30)
46
+ @IsSailString(30)
47
47
  Id?: string;
48
48
 
49
- @IsNumeric(9, 0)
49
+ @IsSailNumeric(9, 0)
50
50
  PolicyValue?: number;
51
51
 
52
52
  }
53
53
 
54
54
 
55
55
  export class ApplicationMenuItem extends SiudAction {
56
- @IsString(30)
56
+ @IsSailString(30)
57
57
  MenuId?: string;
58
58
 
59
- @IsString(30)
59
+ @IsSailString(30)
60
60
  ItemId?: string;
61
61
 
62
- @IsString(80)
62
+ @IsSailString(80)
63
63
  Caption?: string;
64
64
 
65
- @IsNumeric(9, 0)
65
+ @IsSailNumeric(9, 0)
66
66
  DisplayOrder?: number;
67
67
 
68
- @IsString(255)
68
+ @IsSailString(255)
69
69
  RestUri?: string;
70
70
 
71
- @IsString(80)
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
- @IsString(30)
82
+ @IsSailString(30)
83
83
  Id?: string;
84
84
 
85
- @IsString(80)
85
+ @IsSailString(80)
86
86
  Caption?: string;
87
87
 
88
- @IsNumeric(9, 0)
88
+ @IsSailNumeric(9, 0)
89
89
  DisplayOrder?: number;
90
90
 
91
- @IsString(80)
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
- @IsString(30)
101
+ @IsSailString(30)
102
102
  RoleId?: string;
103
103
 
104
- @IsString(30)
104
+ @IsSailString(30)
105
105
  AuthorizationObjectId?: string;
106
106
 
107
- @IsString(30)
107
+ @IsSailString(30)
108
108
  Action?: string;
109
109
 
110
- @IsString(80)
110
+ @IsSailString(80)
111
111
  LowLimit?: string;
112
112
 
113
- @IsString(80)
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
- @IsString(60)
122
+ @IsSailString(60)
123
123
  ConstantId?: string;
124
124
 
125
- @IsString(80)
125
+ @IsSailString(80)
126
126
  Value?: string;
127
127
 
128
- @IsString(80)
128
+ @IsSailString(80)
129
129
  Caption?: string;
130
130
 
131
131
  }
132
132
 
133
133
 
134
134
  export class UserAccount extends SiudAction {
135
- @IsNumeric(9, 0)
135
+ @IsSailNumeric(9, 0)
136
136
  Id?: number;
137
137
 
138
- @IsString(80)
138
+ @IsSailString(80)
139
139
  FirstName?: string;
140
140
 
141
- @IsString(80)
141
+ @IsSailString(80)
142
142
  LastName?: string;
143
143
 
144
- @IsString(80)
144
+ @IsSailString(80)
145
145
  UserName?: string;
146
146
 
147
- @IsString(255)
147
+ @IsSailString(255)
148
148
  UserEmail?: string;
149
149
 
150
- @IsString(1)
150
+ @IsSailString(1)
151
151
  Status?: string;
152
152
 
153
153
  }
@@ -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
- @ValidatorConstraint({ name: 'isNumeric', async: false })
8
- export class IsNumericConstraint implements ValidatorConstraintInterface {
9
- validate(value: any, args: ValidationArguments) {
10
- if (typeof value !== 'number') {
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
- // Check total digits vs precision and decimal digits vs scale
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: 'IsString', async: false })
34
- export class IsStringConstraint implements ValidatorConstraintInterface {
35
- validate(value: any, args: ValidationArguments) {
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
- import { registerDecorator, ValidationOptions } from 'class-validator';
51
-
52
- export function IsNumeric(precision: number, scale: number, validationOptions?: ValidationOptions) {
53
- return function (object: Record<string, any>, propertyName: string) {
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: IsNumericConstraint,
63
+ validator: IsSailNumericConstraint,
60
64
  });
61
65
  };
62
66
  }
63
67
 
64
- export function IsString(maxLength: number, validationOptions?: ValidationOptions) {
65
- return function (object: Record<string, any>, propertyName: string) {
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: IsStringConstraint,
75
+ validator: IsSailStringConstraint,
72
76
  });
73
77
  };
74
78
  }
@@ -0,0 +1,50 @@
1
+ // Partner→external-provider connections (keel oauth/connect). The app supplies a
2
+ // OAuthProviderDescriptor[] catalog; sail's ConnectionPanel renders/manages them.
3
+
4
+ /** Mirrors keel partner_credential (the browser never sees the sealed cred_ref). */
5
+ export interface PartnerCredential {
6
+ Id?: number;
7
+ PartnerId?: number;
8
+ /** 0 = tenant-wide; >0 = a specific business/entity. */
9
+ EntityId?: number;
10
+ Provider: string;
11
+ /** 'O' = OAuth, 'A' = API key. */
12
+ ConnectionType: 'O' | 'A';
13
+ /** 'A' active, 'E' error, 'P' pending, 'I' inactive. */
14
+ Status: 'A' | 'E' | 'P' | 'I';
15
+ ApiEndpoint?: string;
16
+ LastChecked?: string;
17
+ }
18
+
19
+ // credential = sealed secret; endpoint = api_endpoint; param = OAuth authorize query param.
20
+ export type OAuthProviderFieldRole = 'credential' | 'endpoint' | 'param';
21
+
22
+ // role is explicit so field order can't mistake a URL for the credential.
23
+ export interface OAuthProviderField {
24
+ key: string;
25
+ label: string;
26
+ role: OAuthProviderFieldRole;
27
+ placeholder?: string;
28
+ type?: 'text' | 'password';
29
+ required?: boolean;
30
+ }
31
+
32
+ /** App-supplied catalog entry the ConnectionPanel renders. */
33
+ export interface OAuthProviderDescriptor {
34
+ code: string;
35
+ name: string;
36
+ icon?: string; // Material icon name or an image URL (assets/icons/x.svg)
37
+ authType: 'oauth' | 'api_key';
38
+ description?: string;
39
+ connectNote?: string;
40
+ fields?: OAuthProviderField[]; // API-key: credential (+ endpoint); OAuth: param fields (Shopify shop)
41
+ }
42
+
43
+ export type OAuthConnectionStatus = 'connected' | 'disconnected' | 'error' | 'pending';
44
+
45
+ /** A descriptor joined with its current stored connection + derived status. */
46
+ export interface OAuthConnectionView {
47
+ provider: OAuthProviderDescriptor;
48
+ connection?: PartnerCredential;
49
+ status: OAuthConnectionStatus;
50
+ }
@@ -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
- if (body && typeof body === 'object' && 'data' in body) {
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: 0,
27
- statusText: 'auth circuit open: too many 401s — request stopped to prevent a loop',
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
- recordAuthFailure();
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
- function recordAuthFailure(): void {
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
- } else {
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 {