@nauticana/sail 1.1.8 → 1.1.9

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
4
4
 
5
- > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.0.x ↔ keel v1.0.x**. Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x** (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
6
6
 
7
7
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows.
8
8
 
@@ -772,6 +772,37 @@ Horizontal building blocks for an entitlement-aware dashboard. All are presentat
772
772
 
773
773
  Models: `DataCardState`, `ActionItem`, `EntityOption`, `VerificationStep`.
774
774
 
775
+ ## Provider connections (v1.1.9)
776
+
777
+ `ConnectionPanel` is the drop-in UI for keel's `oauth/connect` layer — connect a
778
+ partner to external providers (Google, GBP, Meta, Shopify, or any API-key service),
779
+ then test / disconnect. Presentational and CSS-free; the app owns the provider
780
+ catalog and any routing via the outputs.
781
+
782
+ - **`OAuthConnectionPanelComponent`** (`sail-oauth-connection-panel`) — renders a card per
783
+ `[providers]: OAuthProviderDescriptor[]` with connect / test / reconnect / disconnect.
784
+ Providers with `fields` or a `connectNote` open an inline form/consent step first (API-key
785
+ credentials, a Shopify `shop`, or a "you'll be redirected to…" note); a bare field-less OAuth
786
+ provider redirects straight to consent. `[entityId]` scopes per-business connections and
787
+ reloads on change (0 = tenant-wide). Emits `connected` / `disconnected`.
788
+ Ships no CSS — style `.connections`, `.connection-card`, `.connection-card__{head,title,status,actions}`,
789
+ `.connection-form`, `.connection-panel__error`, and `.status--{connected,error,pending}`.
790
+ - Each `OAuthProviderField` has a **`role`**: `credential` (sealed secret), `endpoint` (stored
791
+ as `api_endpoint`), or `param` (OAuth authorize query param) — so field order is
792
+ irrelevant. `icon` is a Material name or an image URL.
793
+ - **`OAuthConnectionService`** — `startOAuth(provider, extra)` → consent URL,
794
+ `testConnection`, `list(entityId)` / `disconnect` (generic `partner_credential` REST), and
795
+ `saveApiKey` (POSTs the sealed `/api/oauth/apikey`).
796
+
797
+ ```html
798
+ <sail-oauth-connection-panel [providers]="providers" [entityId]="businessId()"
799
+ (connected)="reload()" (disconnected)="reload()"></sail-oauth-connection-panel>
800
+ ```
801
+
802
+ The app supplies the descriptor list (which providers, icons, copy, api-key fields);
803
+ keel owns the flow and sealed storage. Models: `OAuthProviderDescriptor`,
804
+ `PartnerCredential`, `OAuthConnectionView`, `OAuthConnectionStatus`.
805
+
775
806
  ## Adding a new domain table
776
807
 
777
808
  When your project owns a table that isn't part of basis (e.g. `favorite_location`, `support_ticket`, `vehicle`), follow the pattern below so the row plays nicely with keel's generic CRUD endpoints and the sail `<table-edit>` / `<table-list>` components.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
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": {
@@ -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,231 @@
1
+ import { ChangeDetectionStrategy, Component, 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
+
44
+ // The list is already scoped to entityId(), so a per-provider match is correct.
45
+ readonly views = computed<OAuthConnectionView[]>(() => {
46
+ const byProvider = new Map(this.connections().map((c) => [c.Provider, c]));
47
+ return this.providers().map((provider) => ({
48
+ provider,
49
+ connection: byProvider.get(provider.code),
50
+ status: this.toStatus(byProvider.get(provider.code)),
51
+ }));
52
+ });
53
+
54
+ constructor() {
55
+ // Reload on init and whenever the entity scope changes.
56
+ effect(() => this.reload(this.entityId()));
57
+ }
58
+
59
+ // After an OAuth round-trip keel redirects back with ?<provider>=success. Record
60
+ // the codes (configured providers only), strip them — preserving the hash — and
61
+ // emit `connected` once the reload confirms the row exists (confirmSuccess).
62
+ ngOnInit(): void {
63
+ const q = new URLSearchParams(window.location.search);
64
+ const known = new Set(this.providers().map((p) => p.code));
65
+ const succeeded = new Set<string>();
66
+ q.forEach((value, key) => {
67
+ if (value === 'success' && known.has(key)) succeeded.add(key);
68
+ });
69
+ if (!succeeded.size) return;
70
+ this.pendingSuccess.set(succeeded);
71
+ for (const code of succeeded) q.delete(code);
72
+ const qs = q.toString();
73
+ window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash);
74
+ }
75
+
76
+ private reload(entityId: number): void {
77
+ this.reloadSub?.unsubscribe(); // cancel any in-flight load so a slow old entity can't overwrite the new one
78
+ this.loading.set(true);
79
+ this.expanded.set('');
80
+ this.fieldValues.set({}); // never carry a typed credential across a reload / entity change
81
+ this.reloadSub = this.svc.list(entityId).subscribe({
82
+ next: (rows) => {
83
+ this.connections.set(rows ?? []);
84
+ this.loading.set(false);
85
+ this.confirmSuccess();
86
+ },
87
+ error: (e) => {
88
+ this.error.set(this.msg(e, 'Could not load your connections.'));
89
+ this.loading.set(false);
90
+ },
91
+ });
92
+ }
93
+
94
+ // Emit `connected` only for a returned provider whose row is actually present now.
95
+ private confirmSuccess(): void {
96
+ const pending = this.pendingSuccess();
97
+ if (!pending.size) return;
98
+ const present = new Set(this.connections().map((c) => c.Provider));
99
+ for (const code of pending) if (present.has(code)) this.connected.emit(code);
100
+ this.pendingSuccess.set(new Set());
101
+ }
102
+
103
+ isIconUrl(icon?: string): boolean {
104
+ return !!icon && (icon.includes('/') || icon.includes('.'));
105
+ }
106
+
107
+ private toStatus(c?: PartnerCredential): OAuthConnectionStatus {
108
+ if (!c) return 'disconnected';
109
+ if (c.Status === 'A') return 'connected';
110
+ if (c.Status === 'E') return 'error';
111
+ if (c.Status === 'P') return 'pending';
112
+ return 'disconnected';
113
+ }
114
+
115
+ // Providers with inputs or a consent note open a form first; a bare field-less
116
+ // OAuth provider connects on one click.
117
+ connect(v: OAuthConnectionView): void {
118
+ const needsForm = (v.provider.fields ?? []).length > 0 || !!v.provider.connectNote;
119
+ if (needsForm && this.expanded() !== v.provider.code) {
120
+ this.clearFields(v.provider.code); // open blank — never a stale credential
121
+ this.expanded.set(v.provider.code);
122
+ this.error.set('');
123
+ return;
124
+ }
125
+ this.submit(v);
126
+ }
127
+
128
+ private clearFields(code: string): void {
129
+ const all = { ...this.fieldValues() };
130
+ delete all[code];
131
+ this.fieldValues.set(all);
132
+ }
133
+
134
+ // Submit collected inputs: OAuth → authorize redirect (fields ride as params);
135
+ // API-key → save the role-tagged credential + endpoint.
136
+ submit(v: OAuthConnectionView): void {
137
+ const fields = v.provider.fields ?? [];
138
+ const vals = this.fieldValues()[v.provider.code] ?? {};
139
+ const missing = fields.filter((f) => f.required !== false && !vals[f.key]);
140
+ if (missing.length) {
141
+ this.error.set('Missing ' + missing.map((f) => f.label).join(', '));
142
+ return;
143
+ }
144
+ this.busy.set(v.provider.code);
145
+ this.error.set('');
146
+
147
+ if (v.provider.authType === 'oauth') {
148
+ // Only 'param' fields belong in the authorize URL — never a credential/endpoint.
149
+ const extra: Record<string, string> = { entity_id: String(this.entityId()) };
150
+ for (const f of fields) if (f.role === 'param') extra[f.key] = vals[f.key] ?? '';
151
+ this.svc.startOAuth(v.provider.code, extra).subscribe({
152
+ next: (url) => this.redirect(url),
153
+ error: (e) => {
154
+ this.busy.set('');
155
+ this.error.set(this.msg(e, 'Could not start the connection.'));
156
+ },
157
+ });
158
+ return;
159
+ }
160
+
161
+ const credRef = this.roleValue(v, vals, 'credential');
162
+ const apiEndpoint = this.roleValue(v, vals, 'endpoint');
163
+ this.svc.saveApiKey(v.provider.code, credRef, apiEndpoint, this.entityId()).subscribe({
164
+ next: () => {
165
+ this.busy.set('');
166
+ this.connected.emit(v.provider.code);
167
+ this.reload(this.entityId());
168
+ },
169
+ error: (e) => {
170
+ this.busy.set('');
171
+ this.error.set(this.msg(e, 'Could not save the key.'));
172
+ },
173
+ });
174
+ }
175
+
176
+ private roleValue(v: OAuthConnectionView, vals: Record<string, string>, role: OAuthProviderFieldRole): string {
177
+ const f = (v.provider.fields ?? []).find((x) => x.role === role);
178
+ return f ? (vals[f.key] ?? '') : '';
179
+ }
180
+
181
+ setField(code: string, key: string, ev: Event): void {
182
+ const value = (ev.target as HTMLInputElement).value;
183
+ const all = { ...this.fieldValues() };
184
+ all[code] = { ...(all[code] ?? {}), [key]: value };
185
+ this.fieldValues.set(all);
186
+ }
187
+
188
+ test(v: OAuthConnectionView): void {
189
+ if (!v.connection) return;
190
+ this.busy.set(v.provider.code);
191
+ this.error.set('');
192
+ this.svc.testConnection(v.provider.code, this.entityId()).subscribe({
193
+ // keel returns HTTP 200 with {status:'error', message} for a failed test —
194
+ // surface the message, then reload to reflect the status it just recorded.
195
+ next: (res) => {
196
+ this.busy.set('');
197
+ if (res.status !== 'ok') this.error.set(res.message || 'Connection test failed.');
198
+ this.reload(this.entityId());
199
+ },
200
+ error: (e) => {
201
+ this.busy.set('');
202
+ this.error.set(this.msg(e, 'Could not test the connection.'));
203
+ },
204
+ });
205
+ }
206
+
207
+ disconnect(v: OAuthConnectionView): void {
208
+ if (!v.connection?.Id) return;
209
+ this.busy.set(v.provider.code);
210
+ this.svc.disconnect(v.connection.Id).subscribe({
211
+ next: () => {
212
+ this.busy.set('');
213
+ this.disconnected.emit(v.provider.code);
214
+ this.reload(this.entityId());
215
+ },
216
+ error: (e) => {
217
+ this.busy.set('');
218
+ this.error.set(this.msg(e, 'Could not disconnect.'));
219
+ },
220
+ });
221
+ }
222
+
223
+ protected redirect(url: string): void {
224
+ window.location.href = url;
225
+ }
226
+
227
+ private msg(e: unknown, fallback: string): string {
228
+ const err = e as { error?: { detail?: string; message?: string } };
229
+ return err?.error?.detail ?? err?.error?.message ?? fallback;
230
+ }
231
+ }
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';
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ import { Injectable, inject } from '@angular/core';
2
+ import { Observable } from 'rxjs';
3
+ import { map } from 'rxjs/operators';
4
+ import { BaseRestService } from './base_rest.service';
5
+ import { BackendService } from './rest_service';
6
+ import { PartnerCredential } from '../model/oauth_connection';
7
+
8
+ /**
9
+ * OAuthConnectionService — frontend client for keel oauth/connect.
10
+ *
11
+ * OAuth flow endpoints live under /api/oauth/*; the stored connection rows are
12
+ * read/removed through the generic REST `partner_credential` API. entityId (0 =
13
+ * tenant-wide, >0 = a specific business) scopes per-entity connections.
14
+ */
15
+ @Injectable({ providedIn: 'root' })
16
+ export class OAuthConnectionService extends BaseRestService {
17
+ private readonly backend = inject(BackendService);
18
+ private readonly crudApi = 'partner_credential';
19
+
20
+ /** Start an OAuth connect flow; resolves to the provider consent URL to redirect to. */
21
+ startOAuth(provider: string, extra: Record<string, string> = {}): Observable<string> {
22
+ const qs = new URLSearchParams(extra).toString();
23
+ const path = `/api/oauth/${provider}/authorize${qs ? '?' + qs : ''}`;
24
+ return this.http.get<{ url: string }>(this.url(path)).pipe(map((r) => r.url));
25
+ }
26
+
27
+ /** Re-validate a stored connection (POST — it makes an outbound call and updates
28
+ * status). entityId selects which one (0 = tenant-wide). */
29
+ testConnection(provider: string, entityId = 0): Observable<{ status: string; message?: string }> {
30
+ return this.http.post<{ status: string; message?: string }>(
31
+ this.url(`/api/oauth/${provider}/test?entity_id=${entityId}`),
32
+ {},
33
+ );
34
+ }
35
+
36
+ /** The partner's stored connections for one entity scope (0 = tenant-wide).
37
+ * Filters by entity so the panel never shows another business's connections. */
38
+ list(entityId = 0): Observable<PartnerCredential[]> {
39
+ return this.backend.list<PartnerCredential>(this.crudApi, { EntityId: String(entityId) });
40
+ }
41
+
42
+ /** Remove a stored connection by id. */
43
+ disconnect(id: number): Observable<{ message: string }> {
44
+ return this.backend.delete(this.crudApi, { Id: String(id) });
45
+ }
46
+
47
+ /** Save an API-key connection (sealed server-side by the connect handler). */
48
+ saveApiKey(provider: string, credRef: string, apiEndpoint = '', entityId = 0): Observable<{ status: string }> {
49
+ return this.http.post<{ status: string }>(this.url('/api/oauth/apikey'), {
50
+ provider,
51
+ cred_ref: credRef,
52
+ api_endpoint: apiEndpoint,
53
+ entity_id: entityId,
54
+ });
55
+ }
56
+ }