@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,6 +1,6 @@
1
- import { ChangeDetectionStrategy, Component, inject, effect, DestroyRef, signal, ViewEncapsulation, OnInit } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, inject, DestroyRef, signal, ViewEncapsulation, OnInit } from '@angular/core';
2
2
  import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
3
- import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
3
+ import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
4
4
  import { Router, RouterLink } from '@angular/router';
5
5
  import { BaseAuthService } from '../../service/auth.service';
6
6
  import { BillingService } from '../../service/billing.service';
@@ -9,11 +9,20 @@ import { MatFormFieldModule } from "@angular/material/form-field";
9
9
  import { MatInputModule } from '@angular/material/input';
10
10
  import { MatCheckboxModule } from '@angular/material/checkbox';
11
11
  import { MatSelectModule } from '@angular/material/select';
12
- import { firstValueFrom, map, merge } from 'rxjs';
12
+ import { catchError, debounceTime, map, merge, of, switchMap, tap } from 'rxjs';
13
13
  import { HttpClient } from '@angular/common/http';
14
14
  import { PartnerRegistration, PublicPlan } from '../../model/appdata';
15
15
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
16
16
  import { fromPriceLabel } from '../../util/money';
17
+ import { errorDetail } from '../../util/errors';
18
+
19
+ const passwordsMatch = (group: AbstractControl): ValidationErrors | null =>
20
+ group.get('Password')?.value === group.get('ConfirmPassword')?.value ? null : { passwordMismatch: true };
21
+
22
+ interface GeocodingResponse {
23
+ results: { geometry: { location: { lat: number; lng: number } } }[];
24
+ status: string;
25
+ }
17
26
 
18
27
  @Component({
19
28
  selector: 'sail-register',
@@ -43,14 +52,16 @@ export class RegisterComponent implements OnInit {
43
52
  protected readonly geocodeEnabled = !!this.guiConfig.googleMapsApiKey;
44
53
 
45
54
  readonly registerError = signal('');
55
+ readonly plansError = signal('');
46
56
  readonly plans = signal<PublicPlan[]>([]);
47
57
 
48
58
  readonly registerForm = this.fb.group({
49
59
  FirstName: ['', Validators.required],
50
60
  LastName: ['', Validators.required],
51
61
  UserName: ['', Validators.required],
52
- Email: ['', Validators.required],
53
- Password: ['', Validators.required],
62
+ Email: ['', [Validators.required, Validators.email]],
63
+ Password: ['', [Validators.required, Validators.minLength(6)]],
64
+ ConfirmPassword: ['', Validators.required],
54
65
  PartnerCaption: ['', Validators.required],
55
66
  Address: ['', Validators.required],
56
67
  City: ['', Validators.required],
@@ -63,7 +74,7 @@ export class RegisterComponent implements OnInit {
63
74
  Latitude: [null as number | null],
64
75
  Longitude: [null as number | null],
65
76
  consentLocation: [false, this.geocodeEnabled ? Validators.requiredTrue : []],
66
- });
77
+ }, { validators: passwordsMatch });
67
78
 
68
79
  readonly geocodeStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
69
80
  readonly geocodeError = signal('');
@@ -89,106 +100,86 @@ export class RegisterComponent implements OnInit {
89
100
  ngOnInit() {
90
101
  this.billing.listPlans().subscribe({
91
102
  next: (list) => this.plans.set(list ?? []),
92
- error: () => this.plans.set([]),
103
+ error: () => this.plansError.set('Could not load subscription plans. Please reload the page.'),
93
104
  });
94
105
  }
95
106
 
96
107
  constructor() {
97
- effect(() => {
98
- const consent = this.consentLocation();
99
- if (this.geocodeEnabled && consent) {
100
- this.tryGeocode();
101
- } else {
102
- this.registerForm.patchValue({ Latitude: null, Longitude: null });
103
- this.geocodeStatus.set('idle');
104
- this.geocodeError.set('');
105
- }
106
- });
107
-
108
+ // Debounced + switchMapped so per-keystroke edits don't fan out billable
109
+ // geocode calls and a stale response can't overwrite a newer address.
108
110
  merge(
109
111
  ...this.addressFields.map((f) => this.registerForm.controls[f].valueChanges),
110
- )
111
- .pipe(takeUntilDestroyed(this.destroyRef))
112
- .subscribe(() => {
113
- this.registerForm.patchValue({ Latitude: null, Longitude: null });
114
- if (this.geocodeEnabled && this.consentLocation()) {
115
- this.tryGeocode();
116
- }
117
- });
112
+ this.registerForm.controls.consentLocation.valueChanges,
113
+ ).pipe(
114
+ takeUntilDestroyed(this.destroyRef),
115
+ tap(() => this.registerForm.patchValue({ Latitude: null, Longitude: null })),
116
+ debounceTime(400),
117
+ switchMap(() => this.geocode$()),
118
+ ).subscribe();
118
119
  }
119
120
 
120
- private async tryGeocode() {
121
+ private geocode$() {
122
+ if (!this.geocodeEnabled || !this.consentLocation()) {
123
+ this.geocodeStatus.set('idle');
124
+ this.geocodeError.set('');
125
+ return of(null);
126
+ }
121
127
  const { Address, City, State, Zipcode, Country } = this.registerForm.getRawValue();
122
128
  if (!Address || !City || !State || !Zipcode || !Country) {
123
129
  this.geocodeStatus.set('idle');
124
130
  this.geocodeError.set('');
125
- return;
131
+ return of(null);
126
132
  }
127
133
  this.geocodeStatus.set('loading');
128
134
  this.geocodeError.set('');
129
- try {
130
- const coords = await this.getCoordinates(`${Address}, ${City}, ${State} ${Zipcode}, ${Country}`);
131
- this.registerForm.patchValue({ Latitude: coords.lat, Longitude: coords.lng });
132
- this.geocodeStatus.set('success');
133
- } catch {
134
- this.registerForm.patchValue({ Latitude: null, Longitude: null });
135
- this.geocodeStatus.set('error');
136
- this.geocodeError.set('Could not verify this address. Please check and try again.');
137
- }
135
+ const address = `${Address}, ${City}, ${State} ${Zipcode}, ${Country}`;
136
+ const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${this.getGoogleMapsApiKey()}`;
137
+ return this.http.get<GeocodingResponse>(url).pipe(
138
+ map((data) => {
139
+ if (data.status !== 'OK' || data.results.length === 0) {
140
+ throw new Error(`Geocoding failed: ${data.status}`);
141
+ }
142
+ return data.results[0].geometry.location;
143
+ }),
144
+ tap((coords) => {
145
+ this.registerForm.patchValue({ Latitude: coords.lat, Longitude: coords.lng });
146
+ this.geocodeStatus.set('success');
147
+ }),
148
+ catchError(() => {
149
+ this.registerForm.patchValue({ Latitude: null, Longitude: null });
150
+ this.geocodeStatus.set('error');
151
+ this.geocodeError.set('Could not verify this address. Please check and try again.');
152
+ return of(null);
153
+ }),
154
+ );
138
155
  }
139
156
 
140
157
  register() {
141
- if (this.registerForm.valid) {
142
- this.registerError.set('');
143
- const formValue = this.registerForm.getRawValue();
144
- const partnerReg: PartnerRegistration = {
145
- FirstName: formValue.FirstName!,
146
- LastName: formValue.LastName!,
147
- UserName: formValue.UserName!,
148
- Email: formValue.Email!,
149
- Password: formValue.Password!,
150
- PartnerCaption: formValue.PartnerCaption!,
151
- Address: formValue.Address!,
152
- City: formValue.City!,
153
- State: formValue.State!,
154
- Zipcode: formValue.Zipcode!,
155
- Country: formValue.Country!,
156
- Phone: formValue.Phone!,
157
- DomainURL: formValue.DomainURL!,
158
- Latitude: formValue.Latitude!,
159
- Longitude: formValue.Longitude!,
160
- PlanID: formValue.PlanID!,
161
- };
162
- this.auth.register(partnerReg).subscribe({
163
- next: () => this.router.navigate(['/confirm/register']),
164
- error: (err) => this.registerError.set(err?.error?.message ?? 'Registration failed. Please try again.'),
165
- });
166
- }
167
- }
168
-
169
- private async getCoordinates(address: string): Promise<{ lat: number; lng: number }> {
170
- const apiKey = this.getGoogleMapsApiKey();
171
- const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`;
172
-
173
- interface GeocodingResponse {
174
- results: { geometry: { location: { lat: number; lng: number } } }[];
175
- status: string;
176
- }
177
- try {
178
- const response = await firstValueFrom(
179
- this.http.get<GeocodingResponse>(url).pipe(
180
- map((data) => {
181
- if (data.status === 'OK' && data.results.length > 0) {
182
- return data.results[0].geometry.location;
183
- } else {
184
- throw new Error(`Geocoding failed: ${data.status}`);
185
- }
186
- }),
187
- ),
188
- );
189
- return response;
190
- } catch (error) {
191
- throw new Error(`Geocoding failed: ${error}`);
192
- }
158
+ if (!this.registerForm.valid) return;
159
+ this.registerError.set('');
160
+ const formValue = this.registerForm.getRawValue();
161
+ const partnerReg: PartnerRegistration = {
162
+ FirstName: formValue.FirstName!,
163
+ LastName: formValue.LastName!,
164
+ UserName: formValue.UserName!,
165
+ Email: formValue.Email!,
166
+ Password: formValue.Password!,
167
+ PartnerCaption: formValue.PartnerCaption!,
168
+ Address: formValue.Address!,
169
+ City: formValue.City!,
170
+ State: formValue.State!,
171
+ Zipcode: formValue.Zipcode!,
172
+ Country: formValue.Country!,
173
+ Phone: formValue.Phone!,
174
+ DomainURL: formValue.DomainURL!,
175
+ Latitude: formValue.Latitude,
176
+ Longitude: formValue.Longitude,
177
+ PlanID: formValue.PlanID!,
178
+ };
179
+ this.auth.register(partnerReg).subscribe({
180
+ // Pass the email along so the confirm page pre-fills it.
181
+ next: () => this.router.navigate(['/confirm/register'], { queryParams: { email: formValue.Email } }),
182
+ error: (err) => this.registerError.set(errorDetail(err, 'Registration failed. Please try again.')),
183
+ });
193
184
  }
194
185
  }
@@ -1,8 +1,8 @@
1
1
  <div class="social-login">
2
- @if (googleEnabled) {
2
+ @if (googleEnabled()) {
3
3
  <div #googleBtn class="social-google"></div>
4
4
  }
5
- @if (appleEnabled) {
5
+ @if (appleEnabled()) {
6
6
  <button type="button" class="social-apple-btn" (click)="onAppleClick()" [disabled]="loading()">
7
7
  Sign in with Apple
8
8
  </button>
@@ -1,6 +1,6 @@
1
1
  import {
2
- ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, ViewEncapsulation,
3
- inject, input, output,
2
+ ChangeDetectionStrategy, Component, computed, ElementRef, OnInit, ViewEncapsulation,
3
+ inject, input, output, viewChild,
4
4
  } from '@angular/core';
5
5
  import { BaseAsync } from '../abstract/base_async';
6
6
  import { BaseAuthService } from '../../service/auth.service';
@@ -45,15 +45,14 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
45
45
  readonly loginSuccess = output<LoginResponseSocial>();
46
46
  readonly loginError = output<Error>();
47
47
 
48
- @ViewChild('googleBtn', { static: false }) googleBtn?: ElementRef<HTMLDivElement>;
48
+ private readonly googleBtn = viewChild<ElementRef<HTMLDivElement>>('googleBtn');
49
49
 
50
- protected googleEnabled = false;
51
- protected appleEnabled = false;
50
+ protected readonly googleEnabled = computed(() =>
51
+ this.providers().includes('google') && !!this.guiConfig.googleClientId);
52
+ protected readonly appleEnabled = computed(() =>
53
+ this.providers().includes('apple') && !!this.guiConfig.appleServiceId);
52
54
 
53
55
  ngOnInit() {
54
- this.googleEnabled = this.providers().includes('google') && !!this.guiConfig.googleClientId;
55
- this.appleEnabled = this.providers().includes('apple') && !!this.guiConfig.appleServiceId;
56
-
57
56
  if (this.providers().includes('google') && !this.guiConfig.googleClientId) {
58
57
  console.warn('SocialLoginComponent: googleClientId missing from SailGuiConfig — Google button hidden');
59
58
  }
@@ -61,8 +60,15 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
61
60
  console.warn('SocialLoginComponent: appleServiceId missing from SailGuiConfig — Apple button hidden');
62
61
  }
63
62
 
64
- if (this.googleEnabled) this.initGoogle();
65
- if (this.appleEnabled) this.initApple();
63
+ // SDK load failures (CSP, ad-blockers) must surface, not leave a blank area.
64
+ if (this.googleEnabled()) this.initGoogle().catch((err) => this.onInitError('Google', err));
65
+ if (this.appleEnabled()) this.initApple().catch((err) => this.onInitError('Apple', err));
66
+ }
67
+
68
+ private onInitError(provider: string, err: unknown) {
69
+ console.error(`${provider} sign-in init failed:`, err);
70
+ this.errorMessage.set(`${provider} sign-in is unavailable right now.`);
71
+ this.loginError.emit(err instanceof Error ? err : new Error(String(err)));
66
72
  }
67
73
 
68
74
  private async initGoogle() {
@@ -75,8 +81,9 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
75
81
  });
76
82
  // Render into the template anchor once the view is ready.
77
83
  queueMicrotask(() => {
78
- if (this.googleBtn?.nativeElement) {
79
- google.accounts.id.renderButton(this.googleBtn.nativeElement, { theme: 'outline', size: 'large' });
84
+ const anchor = this.googleBtn()?.nativeElement;
85
+ if (anchor) {
86
+ google.accounts.id.renderButton(anchor, { theme: 'outline', size: 'large' });
80
87
  }
81
88
  });
82
89
  }
@@ -98,7 +105,7 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
98
105
  const res = await AppleID.auth.signIn();
99
106
  const token = res?.authorization?.id_token;
100
107
  if (token) this.onIdToken('apple', token);
101
- } catch (err: any) {
108
+ } catch (err: unknown) {
102
109
  this.loginError.emit(err instanceof Error ? err : new Error(String(err)));
103
110
  }
104
111
  }
@@ -9,7 +9,7 @@
9
9
  [attr.aria-label]="guiConfig.appTitle ? guiConfig.appTitle + ' home' : 'Home'">
10
10
  @if (guiConfig.appLogoUrl) {
11
11
  <img class="sail-navigation-logo"
12
- [src]="guiConfig.appLogoUrl"
12
+ [ngSrc]="guiConfig.appLogoUrl"
13
13
  width="40"
14
14
  height="40"
15
15
  alt="">
@@ -44,7 +44,7 @@
44
44
  <mat-toolbar class="primary">
45
45
  @if (isHandset()) {
46
46
  <button type="button" aria-label="Toggle sidenav" matIconButton (click)="sidenav.toggle()">
47
- <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
47
+ <mat-icon>menu</mat-icon>
48
48
  </button>
49
49
  }
50
50
  <ng-content select="[toolbar-title]"></ng-content>
@@ -1,4 +1,5 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from "@angular/core";
2
+ import { NgOptimizedImage } from "@angular/common";
2
3
  import { toSignal } from "@angular/core/rxjs-interop";
3
4
  import { RouterLink, RouterOutlet } from "@angular/router";
4
5
  import { MatSidenavModule } from '@angular/material/sidenav';
@@ -24,6 +25,7 @@ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
24
25
  MatListModule,
25
26
  MatIconModule,
26
27
  MatButtonModule,
28
+ NgOptimizedImage,
27
29
  RouterOutlet,
28
30
  RouterLink,
29
31
  MatExpansionModule,
@@ -0,0 +1,68 @@
1
+ @if (error()) {
2
+ <p class="connection-panel__error" role="alert">{{ error() }}</p>
3
+ }
4
+
5
+ @if (loading()) {
6
+ <p>Loading connections…</p>
7
+ } @else {
8
+ <div class="connections">
9
+ @for (v of views(); track v.provider.code) {
10
+ <mat-card class="connection-card">
11
+ <div class="connection-card__head">
12
+ @if (v.provider.icon) {
13
+ @if (isIconUrl(v.provider.icon)) {
14
+ <img class="connection-card__icon" [ngSrc]="v.provider.icon" alt="" width="24" height="24" />
15
+ } @else {
16
+ <mat-icon>{{ v.provider.icon }}</mat-icon>
17
+ }
18
+ }
19
+ <h3 class="connection-card__title">{{ v.provider.name }}</h3>
20
+ <span
21
+ class="connection-card__status"
22
+ [class.status--connected]="v.status === 'connected'"
23
+ [class.status--error]="v.status === 'error'"
24
+ [class.status--pending]="v.status === 'pending'"
25
+ >{{ v.status }}</span>
26
+ </div>
27
+
28
+ @if (v.provider.description) {
29
+ <p class="connection-card__desc">{{ v.provider.description }}</p>
30
+ }
31
+
32
+ <div class="connection-card__actions">
33
+ @if (v.status === 'disconnected') {
34
+ <button matButton="filled" [disabled]="busy() === v.provider.code" (click)="connect(v)">
35
+ Connect
36
+ </button>
37
+ } @else {
38
+ <button matButton [disabled]="busy() === v.provider.code" (click)="test(v)">Test</button>
39
+ <button matButton [disabled]="busy() === v.provider.code" (click)="connect(v)">Reconnect</button>
40
+ <button matButton [disabled]="busy() === v.provider.code" (click)="disconnect(v)">Disconnect</button>
41
+ }
42
+ </div>
43
+
44
+ @if (expanded() === v.provider.code) {
45
+ @if (v.provider.connectNote) {
46
+ <p class="connection-card__note">{{ v.provider.connectNote }}</p>
47
+ }
48
+ <div class="connection-form">
49
+ @for (f of v.provider.fields; track f.key) {
50
+ <mat-form-field>
51
+ <mat-label>{{ f.label }}</mat-label>
52
+ <input
53
+ matInput
54
+ [type]="f.type ?? 'text'"
55
+ [placeholder]="f.placeholder ?? ''"
56
+ (input)="setField(v.provider.code, f.key, $event)"
57
+ />
58
+ </mat-form-field>
59
+ }
60
+ <button matButton="filled" [disabled]="busy() === v.provider.code" (click)="submit(v)">
61
+ {{ v.provider.authType === 'oauth' ? 'Continue' : 'Save' }}
62
+ </button>
63
+ </div>
64
+ }
65
+ </mat-card>
66
+ }
67
+ </div>
68
+ }
@@ -0,0 +1,252 @@
1
+ import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, effect, inject, input, output, signal } from '@angular/core';
2
+ import { NgOptimizedImage } from '@angular/common';
3
+ import { Subscription } from 'rxjs';
4
+ import { MatButtonModule } from '@angular/material/button';
5
+ import { MatCardModule } from '@angular/material/card';
6
+ import { MatIconModule } from '@angular/material/icon';
7
+ import { MatFormFieldModule } from '@angular/material/form-field';
8
+ import { MatInputModule } from '@angular/material/input';
9
+ import { OAuthConnectionService } from '../../service/oauth_connection.service';
10
+ import { OAuthConnectionStatus, OAuthConnectionView, PartnerCredential, OAuthProviderDescriptor, OAuthProviderFieldRole } from '../../model/oauth_connection';
11
+
12
+ /**
13
+ * ConnectionPanel — drop-in UI for keel oauth/connect. Renders a card per
14
+ * app-supplied OAuthProviderDescriptor with connect / disconnect / test. Providers
15
+ * that need inputs (API-key credentials, Shopify's shop) open an inline form;
16
+ * field-less OAuth providers redirect on one click. Ships no CSS — style the
17
+ * documented `.connection-*` hooks. `entityId` scopes per-business connections
18
+ * (0 = tenant-wide). Selector: <sail-oauth-connection-panel>.
19
+ */
20
+ @Component({
21
+ selector: 'sail-oauth-connection-panel',
22
+ changeDetection: ChangeDetectionStrategy.OnPush,
23
+ imports: [MatButtonModule, MatCardModule, MatIconModule, MatFormFieldModule, MatInputModule, NgOptimizedImage],
24
+ templateUrl: './oauth_connection_panel.html',
25
+ })
26
+ export class OAuthConnectionPanelComponent implements OnInit {
27
+ readonly providers = input<OAuthProviderDescriptor[]>([]);
28
+ readonly entityId = input(0);
29
+
30
+ readonly connected = output<string>();
31
+ readonly disconnected = output<string>();
32
+
33
+ private readonly svc = inject(OAuthConnectionService);
34
+
35
+ readonly connections = signal<PartnerCredential[]>([]);
36
+ readonly loading = signal(true);
37
+ readonly error = signal('');
38
+ readonly expanded = signal(''); // provider code whose input form is open
39
+ readonly busy = signal(''); // provider code with an action in flight
40
+ private readonly fieldValues = signal<Record<string, Record<string, string>>>({});
41
+ private readonly pendingSuccess = signal<Set<string>>(new Set());
42
+ private reloadSub?: Subscription;
43
+ private successRetries = 0;
44
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
45
+
46
+ // The list is already scoped to entityId(), so a per-provider match is correct.
47
+ readonly views = computed<OAuthConnectionView[]>(() => {
48
+ const byProvider = new Map(this.connections().map((c) => [c.Provider, c]));
49
+ return this.providers().map((provider) => ({
50
+ provider,
51
+ connection: byProvider.get(provider.code),
52
+ status: this.toStatus(byProvider.get(provider.code)),
53
+ }));
54
+ });
55
+
56
+ constructor() {
57
+ // Reload on init and whenever the entity scope changes.
58
+ effect(() => this.reload(this.entityId()));
59
+ inject(DestroyRef).onDestroy(() => {
60
+ this.reloadSub?.unsubscribe();
61
+ if (this.retryTimer) clearTimeout(this.retryTimer);
62
+ });
63
+ }
64
+
65
+ // After an OAuth round-trip keel redirects back with ?<provider>=success. Record
66
+ // the codes (configured providers only), strip them — preserving the hash — and
67
+ // emit `connected` once the reload confirms the row exists (confirmSuccess).
68
+ ngOnInit(): void {
69
+ const q = new URLSearchParams(window.location.search);
70
+ const known = new Set(this.providers().map((p) => p.code));
71
+ const succeeded = new Set<string>();
72
+ q.forEach((value, key) => {
73
+ if (value === 'success' && known.has(key)) succeeded.add(key);
74
+ });
75
+ if (!succeeded.size) return;
76
+ this.pendingSuccess.set(succeeded);
77
+ for (const code of succeeded) q.delete(code);
78
+ const qs = q.toString();
79
+ window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash);
80
+ }
81
+
82
+ private reload(entityId: number): void {
83
+ this.reloadSub?.unsubscribe(); // cancel any in-flight load so a slow old entity can't overwrite the new one
84
+ this.loading.set(true);
85
+ this.expanded.set('');
86
+ this.fieldValues.set({}); // never carry a typed credential across a reload / entity change
87
+ this.reloadSub = this.svc.list(entityId).subscribe({
88
+ next: (rows) => {
89
+ this.connections.set(rows ?? []);
90
+ this.loading.set(false);
91
+ this.confirmSuccess();
92
+ },
93
+ error: (e) => {
94
+ this.error.set(this.msg(e, 'Could not load your connections.'));
95
+ this.loading.set(false);
96
+ },
97
+ });
98
+ }
99
+
100
+ // Emit `connected` only for a returned provider whose row is actually present.
101
+ // Rows can lag the redirect (webhook/commit); retry twice before giving up loudly.
102
+ private confirmSuccess(): void {
103
+ const pending = this.pendingSuccess();
104
+ if (!pending.size) return;
105
+ const present = new Set(this.connections().map((c) => c.Provider));
106
+ const missing = [...pending].filter((code) => !present.has(code));
107
+ for (const code of pending) if (present.has(code)) this.connected.emit(code);
108
+ if (!missing.length) {
109
+ this.pendingSuccess.set(new Set());
110
+ this.successRetries = 0;
111
+ return;
112
+ }
113
+ this.pendingSuccess.set(new Set(missing));
114
+ if (this.successRetries < 2) {
115
+ this.successRetries++;
116
+ this.retryTimer = setTimeout(() => this.reload(this.entityId()), 1500 * this.successRetries);
117
+ } else {
118
+ this.pendingSuccess.set(new Set());
119
+ this.successRetries = 0;
120
+ this.error.set('The connection completed but is not visible yet — refresh to check.');
121
+ }
122
+ }
123
+
124
+ isIconUrl(icon?: string): boolean {
125
+ return !!icon && (icon.includes('/') || icon.includes('.'));
126
+ }
127
+
128
+ private toStatus(c?: PartnerCredential): OAuthConnectionStatus {
129
+ if (!c) return 'disconnected';
130
+ if (c.Status === 'A') return 'connected';
131
+ if (c.Status === 'E') return 'error';
132
+ if (c.Status === 'P') return 'pending';
133
+ return 'disconnected';
134
+ }
135
+
136
+ // Providers with inputs or a consent note open a form first; a bare field-less
137
+ // OAuth provider connects on one click.
138
+ connect(v: OAuthConnectionView): void {
139
+ const needsForm = (v.provider.fields ?? []).length > 0 || !!v.provider.connectNote;
140
+ if (needsForm && this.expanded() !== v.provider.code) {
141
+ this.clearFields(v.provider.code); // open blank — never a stale credential
142
+ this.expanded.set(v.provider.code);
143
+ this.error.set('');
144
+ return;
145
+ }
146
+ this.submit(v);
147
+ }
148
+
149
+ private clearFields(code: string): void {
150
+ const all = { ...this.fieldValues() };
151
+ delete all[code];
152
+ this.fieldValues.set(all);
153
+ }
154
+
155
+ // Submit collected inputs: OAuth → authorize redirect (fields ride as params);
156
+ // API-key → save the role-tagged credential + endpoint.
157
+ submit(v: OAuthConnectionView): void {
158
+ const fields = v.provider.fields ?? [];
159
+ const vals = this.fieldValues()[v.provider.code] ?? {};
160
+ const missing = fields.filter((f) => f.required !== false && !vals[f.key]);
161
+ if (missing.length) {
162
+ this.error.set('Missing ' + missing.map((f) => f.label).join(', '));
163
+ return;
164
+ }
165
+ this.busy.set(v.provider.code);
166
+ this.error.set('');
167
+
168
+ if (v.provider.authType === 'oauth') {
169
+ // Only 'param' fields belong in the authorize URL — never a credential/endpoint.
170
+ const extra: Record<string, string> = { entity_id: String(this.entityId()) };
171
+ for (const f of fields) if (f.role === 'param') extra[f.key] = vals[f.key] ?? '';
172
+ this.svc.startOAuth(v.provider.code, extra).subscribe({
173
+ next: (url) => this.redirect(url),
174
+ error: (e) => {
175
+ this.busy.set('');
176
+ this.error.set(this.msg(e, 'Could not start the connection.'));
177
+ },
178
+ });
179
+ return;
180
+ }
181
+
182
+ const credRef = this.roleValue(v, vals, 'credential');
183
+ const apiEndpoint = this.roleValue(v, vals, 'endpoint');
184
+ this.svc.saveApiKey(v.provider.code, credRef, apiEndpoint, this.entityId()).subscribe({
185
+ next: () => {
186
+ this.busy.set('');
187
+ this.connected.emit(v.provider.code);
188
+ this.reload(this.entityId());
189
+ },
190
+ error: (e) => {
191
+ this.busy.set('');
192
+ this.error.set(this.msg(e, 'Could not save the key.'));
193
+ },
194
+ });
195
+ }
196
+
197
+ private roleValue(v: OAuthConnectionView, vals: Record<string, string>, role: OAuthProviderFieldRole): string {
198
+ const f = (v.provider.fields ?? []).find((x) => x.role === role);
199
+ return f ? (vals[f.key] ?? '') : '';
200
+ }
201
+
202
+ setField(code: string, key: string, ev: Event): void {
203
+ const value = (ev.target as HTMLInputElement).value;
204
+ const all = { ...this.fieldValues() };
205
+ all[code] = { ...(all[code] ?? {}), [key]: value };
206
+ this.fieldValues.set(all);
207
+ }
208
+
209
+ test(v: OAuthConnectionView): void {
210
+ if (!v.connection) return;
211
+ this.busy.set(v.provider.code);
212
+ this.error.set('');
213
+ this.svc.testConnection(v.provider.code, this.entityId()).subscribe({
214
+ // keel returns HTTP 200 with {status:'error', message} for a failed test —
215
+ // surface the message, then reload to reflect the status it just recorded.
216
+ next: (res) => {
217
+ this.busy.set('');
218
+ if (res.status !== 'ok') this.error.set(res.message || 'Connection test failed.');
219
+ this.reload(this.entityId());
220
+ },
221
+ error: (e) => {
222
+ this.busy.set('');
223
+ this.error.set(this.msg(e, 'Could not test the connection.'));
224
+ },
225
+ });
226
+ }
227
+
228
+ disconnect(v: OAuthConnectionView): void {
229
+ if (!v.connection?.Id) return;
230
+ this.busy.set(v.provider.code);
231
+ this.svc.disconnect(v.connection.Id).subscribe({
232
+ next: () => {
233
+ this.busy.set('');
234
+ this.disconnected.emit(v.provider.code);
235
+ this.reload(this.entityId());
236
+ },
237
+ error: (e) => {
238
+ this.busy.set('');
239
+ this.error.set(this.msg(e, 'Could not disconnect.'));
240
+ },
241
+ });
242
+ }
243
+
244
+ protected redirect(url: string): void {
245
+ window.location.href = url;
246
+ }
247
+
248
+ private msg(e: unknown, fallback: string): string {
249
+ const err = e as { error?: { detail?: string; message?: string } };
250
+ return err?.error?.detail ?? err?.error?.message ?? fallback;
251
+ }
252
+ }