@nauticana/sail 1.1.7 → 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 +36 -3
- package/package.json +1 -1
- package/src/component/account/my_account.html +1 -1
- package/src/component/account/my_account.ts +6 -2
- package/src/component/account/profile_editor.html +22 -2
- package/src/component/account/profile_editor.ts +44 -6
- package/src/component/dashboard/entity_selector.ts +8 -6
- package/src/component/login/login_component.html +3 -0
- package/src/component/login/login_component.ts +8 -9
- package/src/component/oauth_connection/oauth_connection_panel.html +68 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +231 -0
- package/src/index.ts +3 -0
- package/src/model/appdata.ts +1 -1
- package/src/model/oauth_connection.ts +50 -0
- package/src/service/auth.interceptor.ts +12 -7
- package/src/service/auth.service.ts +14 -2
- package/src/service/oauth_connection.service.ts +56 -0
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.
|
|
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
|
|
|
@@ -144,7 +144,9 @@ bootstrapApplication(App, {
|
|
|
144
144
|
});
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
> **Auth-loop breaker.** `authInterceptor` carries a built-in circuit breaker: after 5 `401
|
|
147
|
+
> **Auth-loop breaker.** `authInterceptor` carries a built-in circuit breaker: after 5 `401`s on token-bearing keel API calls within 10s it opens for 30s and refuses those requests **locally** (never sent), so a stale/rejected session can't drive the app to hammer the API and the edge/CDN. Only `401` counts — a `403` is a legitimate permission denial, not a session failure. A later success **or accepting a new token** (login) closes it, so a freshly authenticated session is never blocked. No wiring needed beyond registering `authInterceptor`.
|
|
148
|
+
|
|
149
|
+
> **Phone-number change (opt-in).** `ProfileEditorComponent` and `MyAccountComponent` gate phone change behind a `phoneChangeEnabled` input (default `false`, since it needs an SMS provider — keel returns 503 without `Notify`). Enable it once your backend has SMS wired: `<sail-my-account [phoneChangeEnabled]="true" />` or `<sail-profile-editor [phoneChangeEnabled]="true" />`.
|
|
148
150
|
|
|
149
151
|
### 5. Create the root component
|
|
150
152
|
|
|
@@ -506,7 +508,7 @@ readonly meters = toSignal(this.billing.listUsage(), { initialValue: [] });
|
|
|
506
508
|
|
|
507
509
|
### Redirect-action (v1.0.0)
|
|
508
510
|
|
|
509
|
-
A `TableAction` with `kind: '
|
|
511
|
+
A `TableAction` with `kind: 'R'` (redirect — the `constant_value` code) makes `BaseTable.executeAction` POST and then follow the returned `{ url }` instead of refreshing — so checkout / portal / any provider-hosted handoff can be declared as a basis `table_action` row with **no bespoke component**. The backend action handler returns `{ "url": "https://…" }`.
|
|
510
512
|
|
|
511
513
|
> **Response-shape contract.** The generic redirect-action path reads a bare `url` because it's polymorphic — one dispatch for checkout / portal / export / etc., where the action's identity lives in the metadata, not the payload. keel's `WrapTableAction` enforces no shape; your inner handler must return `{ url }`. The *typed* billing endpoints keep specific names instead (`createCheckout()` → `checkoutUrl`, `createPortalSession()` → `portalUrl`). So the same portal concept has two response shapes depending on the path: wire it as `<sail-portal-button>` (typed → `portalUrl`) **or** as a `table_action` redirect (generic → `url`), not both.
|
|
512
514
|
|
|
@@ -770,6 +772,37 @@ Horizontal building blocks for an entitlement-aware dashboard. All are presentat
|
|
|
770
772
|
|
|
771
773
|
Models: `DataCardState`, `ActionItem`, `EntityOption`, `VerificationStep`.
|
|
772
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
|
+
|
|
773
806
|
## Adding a new domain table
|
|
774
807
|
|
|
775
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.
|
|
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": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
|
2
2
|
import { RouterLink } from '@angular/router';
|
|
3
3
|
import { MatButtonModule } from '@angular/material/button';
|
|
4
4
|
import { ProfileEditorComponent } from './profile_editor';
|
|
@@ -12,4 +12,8 @@ import { ProfileEditorComponent } from './profile_editor';
|
|
|
12
12
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
13
|
imports: [RouterLink, MatButtonModule, ProfileEditorComponent],
|
|
14
14
|
})
|
|
15
|
-
export class MyAccountComponent {
|
|
15
|
+
export class MyAccountComponent {
|
|
16
|
+
/** Opt-in: set true once the host's backend has an SMS provider, to expose
|
|
17
|
+
* phone-number change in the embedded profile editor. */
|
|
18
|
+
readonly phoneChangeEnabled = input(false);
|
|
19
|
+
}
|
|
@@ -45,6 +45,26 @@
|
|
|
45
45
|
|
|
46
46
|
<section class="account-section">
|
|
47
47
|
<h3>Phone</h3>
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
@if (!phoneChangeEnabled()) {
|
|
49
|
+
<p>{{ phone() || 'Not set' }}</p>
|
|
50
|
+
} @else if (phonePhase() === 'idle') {
|
|
51
|
+
<p>{{ phone() || 'Not set' }}</p>
|
|
52
|
+
<button matButton (click)="startPhoneChange()">Change phone</button>
|
|
53
|
+
} @else if (phonePhase() === 'enter') {
|
|
54
|
+
<mat-form-field appearance="outline">
|
|
55
|
+
<mat-label>New phone</mat-label>
|
|
56
|
+
<input matInput type="tel" [formControl]="newPhone" required>
|
|
57
|
+
</mat-form-field>
|
|
58
|
+
<button matButton="elevated" class="primary" (click)="sendPhoneCode()" [disabled]="newPhone.invalid || loading()">Send code</button>
|
|
59
|
+
<button matButton (click)="cancelPhoneChange()">Cancel</button>
|
|
60
|
+
} @else {
|
|
61
|
+
@if (phoneMsg()) { <p>{{ phoneMsg() }}</p> }
|
|
62
|
+
<mat-form-field appearance="outline">
|
|
63
|
+
<mat-label>Confirmation code</mat-label>
|
|
64
|
+
<input matInput [formControl]="phoneCode" inputmode="numeric" required>
|
|
65
|
+
</mat-form-field>
|
|
66
|
+
<button matButton="elevated" class="primary" (click)="confirmPhoneChange()" [disabled]="phoneCode.invalid || loading()">Confirm</button>
|
|
67
|
+
<button matButton (click)="cancelPhoneChange()">Cancel</button>
|
|
68
|
+
}
|
|
69
|
+
@if (phoneErr()) { <div class="auth-error" role="alert">{{ phoneErr() }}</div> }
|
|
50
70
|
</section>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, inject, input, signal } from '@angular/core';
|
|
2
2
|
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
3
|
import { MatButtonModule } from '@angular/material/button';
|
|
4
4
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
@@ -6,9 +6,9 @@ import { MatInputModule } from '@angular/material/input';
|
|
|
6
6
|
import { BaseAuthService } from '../../service/auth.service';
|
|
7
7
|
import { UserProfile } from '../../model/auth';
|
|
8
8
|
|
|
9
|
-
// Self-service profile editor: name/locale save immediately; email
|
|
10
|
-
// verify-before-apply (code to the new
|
|
11
|
-
// the backend has an SMS provider.
|
|
9
|
+
// Self-service profile editor: name/locale save immediately; email and phone
|
|
10
|
+
// change are verify-before-apply (code to the new value). Phone change is shown
|
|
11
|
+
// only when the host enables phoneChangeEnabled (its backend has an SMS provider).
|
|
12
12
|
@Component({
|
|
13
13
|
selector: 'sail-profile-editor',
|
|
14
14
|
templateUrl: './profile_editor.html',
|
|
@@ -19,8 +19,8 @@ export class ProfileEditorComponent implements OnInit {
|
|
|
19
19
|
private readonly auth = inject(BaseAuthService);
|
|
20
20
|
private readonly fb = inject(FormBuilder);
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
readonly phoneChangeEnabled = false;
|
|
22
|
+
// Set by the host once its backend wires an SMS provider for phone codes.
|
|
23
|
+
readonly phoneChangeEnabled = input(false);
|
|
24
24
|
|
|
25
25
|
readonly loading = signal(false);
|
|
26
26
|
readonly profileMsg = signal('');
|
|
@@ -40,6 +40,12 @@ export class ProfileEditorComponent implements OnInit {
|
|
|
40
40
|
readonly newEmail = new FormControl('', [Validators.required, Validators.email]);
|
|
41
41
|
readonly emailCode = new FormControl('', [Validators.required]);
|
|
42
42
|
|
|
43
|
+
readonly phonePhase = signal<'idle' | 'enter' | 'code'>('idle');
|
|
44
|
+
readonly phoneMsg = signal('');
|
|
45
|
+
readonly phoneErr = signal('');
|
|
46
|
+
readonly newPhone = new FormControl('', [Validators.required]);
|
|
47
|
+
readonly phoneCode = new FormControl('', [Validators.required]);
|
|
48
|
+
|
|
43
49
|
ngOnInit(): void {
|
|
44
50
|
this.auth.getProfile().subscribe({
|
|
45
51
|
next: (p: UserProfile) => {
|
|
@@ -95,6 +101,38 @@ export class ProfileEditorComponent implements OnInit {
|
|
|
95
101
|
});
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
startPhoneChange(): void {
|
|
105
|
+
this.phonePhase.set('enter');
|
|
106
|
+
this.phoneMsg.set('');
|
|
107
|
+
this.phoneErr.set('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
cancelPhoneChange(): void {
|
|
111
|
+
this.phonePhase.set('idle');
|
|
112
|
+
this.newPhone.reset();
|
|
113
|
+
this.phoneCode.reset();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
sendPhoneCode(): void {
|
|
117
|
+
if (this.newPhone.invalid) return;
|
|
118
|
+
this.loading.set(true);
|
|
119
|
+
this.phoneErr.set('');
|
|
120
|
+
this.auth.requestPhoneChange(this.newPhone.value!).subscribe({
|
|
121
|
+
next: () => { this.loading.set(false); this.phonePhase.set('code'); this.phoneMsg.set('We sent a code to your new number.'); },
|
|
122
|
+
error: (e) => { this.loading.set(false); this.phoneErr.set(this.msg(e, 'Could not send a code.')); },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
confirmPhoneChange(): void {
|
|
127
|
+
if (this.phoneCode.invalid) return;
|
|
128
|
+
this.loading.set(true);
|
|
129
|
+
this.phoneErr.set('');
|
|
130
|
+
this.auth.confirmPhoneChange(this.newPhone.value!, Number(this.phoneCode.value)).subscribe({
|
|
131
|
+
next: () => { this.loading.set(false); this.phone.set(this.newPhone.value!); this.cancelPhoneChange(); this.profileMsg.set('Phone updated.'); },
|
|
132
|
+
error: (e) => { this.loading.set(false); this.phoneErr.set(this.msg(e, 'Invalid or expired code.')); },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
98
136
|
private msg(e: { error?: { detail?: string; message?: string } }, fallback: string): string {
|
|
99
137
|
return e?.error?.detail || e?.error?.message || fallback;
|
|
100
138
|
}
|
|
@@ -19,22 +19,24 @@ export class EntitySelectorComponent {
|
|
|
19
19
|
readonly entities = input<EntityOption[]>([]);
|
|
20
20
|
readonly selected = input<string | undefined>(undefined);
|
|
21
21
|
readonly label = input<string>('Business');
|
|
22
|
-
readonly selectionChange = output<string>();
|
|
22
|
+
readonly selectionChange = output<string | undefined>();
|
|
23
23
|
|
|
24
24
|
readonly value = signal<string | undefined>(undefined);
|
|
25
25
|
|
|
26
26
|
constructor() {
|
|
27
27
|
// Explicit selection wins; else keep the current pick if still valid; else the
|
|
28
|
-
// first active entity.
|
|
28
|
+
// first active entity. "Valid" means present AND active, so a scope that goes
|
|
29
|
+
// inactive (or vanishes) is dropped. Emit on every change, including a clear
|
|
30
|
+
// (undefined) when no active option remains, so the host stops showing stale data.
|
|
29
31
|
effect(() => {
|
|
30
32
|
const opts = this.entities();
|
|
31
|
-
const
|
|
33
|
+
const isActive = (id?: string) => !!id && opts.some((e) => e.id === id && e.active !== false);
|
|
32
34
|
const cur = this.value();
|
|
33
35
|
const resolved =
|
|
34
|
-
|
|
35
|
-
:
|
|
36
|
+
isActive(this.selected()) ? this.selected()
|
|
37
|
+
: isActive(cur) ? cur
|
|
36
38
|
: opts.find((e) => e.active !== false)?.id;
|
|
37
|
-
if (resolved
|
|
39
|
+
if (resolved !== cur) {
|
|
38
40
|
this.value.set(resolved);
|
|
39
41
|
this.selectionChange.emit(resolved);
|
|
40
42
|
}
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
@if (loggedOut) {
|
|
10
10
|
<p class="auth-message" role="status" aria-live="polite">You have been logged out successfully.</p>
|
|
11
11
|
}
|
|
12
|
+
@if (sessionHandoffError()) {
|
|
13
|
+
<p class="auth-error" role="alert">{{ sessionHandoffError() }}</p>
|
|
14
|
+
}
|
|
12
15
|
<form [formGroup]="loginForm" (ngSubmit)="login()" class="auth-form">
|
|
13
16
|
<mat-form-field appearance="outline">
|
|
14
17
|
<mat-label>Username</mat-label>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core';
|
|
2
2
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
3
|
import { BaseAuthService } from '../../service/auth.service';
|
|
4
|
-
import { ActivatedRoute,
|
|
4
|
+
import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
5
5
|
import { MatButtonModule } from '@angular/material/button';
|
|
6
6
|
import { MatFormFieldModule } from "@angular/material/form-field";
|
|
7
7
|
import { MatInputModule } from '@angular/material/input';
|
|
@@ -25,10 +25,11 @@ import { SocialLoginComponent } from './social_login';
|
|
|
25
25
|
})
|
|
26
26
|
export class LoginComponent {
|
|
27
27
|
private auth = inject(BaseAuthService);
|
|
28
|
-
private router = inject(Router);
|
|
29
28
|
protected fb = inject(FormBuilder);
|
|
30
29
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
31
30
|
protected readonly loggedOut = inject(ActivatedRoute).snapshot.queryParamMap.get('loggedOut') === 'true';
|
|
31
|
+
/** OAuth session-cookie handoff failure from BaseAuthService, shown below. */
|
|
32
|
+
protected readonly sessionHandoffError = this.auth.sessionHandoffError;
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Auto-show the embedded SocialLoginComponent whenever at least one
|
|
@@ -58,14 +59,12 @@ export class LoginComponent {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* loginSocial already ran completeLogin, which owns post-login navigation
|
|
63
|
+
* (route init, or the OAuth cookie handoff). Handlers must NOT navigate here —
|
|
64
|
+
* doing so abandons a pending handoff before its cookie is set. Downstream
|
|
65
|
+
* social-login handlers should follow the same rule.
|
|
65
66
|
*/
|
|
66
|
-
protected onSocialSuccess(_: LoginResponseSocial): void {
|
|
67
|
-
this.router.navigateByUrl('/');
|
|
68
|
-
}
|
|
67
|
+
protected onSocialSuccess(_: LoginResponseSocial): void {}
|
|
69
68
|
|
|
70
69
|
protected onSocialError(err: Error): void {
|
|
71
70
|
console.error('social login failed:', err);
|
|
@@ -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';
|
package/src/model/appdata.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface TableAction {
|
|
|
83
83
|
confirmMessage?: string;
|
|
84
84
|
authorityObject: string; // uppercased table_name — for canExecute()
|
|
85
85
|
authorityCheck: string; // uppercased action_name — for canExecute()
|
|
86
|
-
kind?: 'P' | 'R' | 'V'; // P post (default) / R redirect (follow {url}) / V reveal (show returned secret once)
|
|
86
|
+
kind?: 'P' | 'R' | 'V'; // constant_value codes: P post (default) / R redirect (follow {url}) / V reveal (show returned secret once)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export interface ForeignKey {
|
|
@@ -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
|
+
}
|
|
@@ -2,12 +2,11 @@ import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/co
|
|
|
2
2
|
import { catchError, tap, throwError } from 'rxjs';
|
|
3
3
|
import { isKeelApiUrl } from './rest_url';
|
|
4
4
|
|
|
5
|
-
// Auth-loop breaker: repeated
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// later success closes it; login (no bearer) is never blocked, so recovery works.
|
|
5
|
+
// Auth-loop breaker: repeated 401s on token-bearing API calls (a stale session) can
|
|
6
|
+
// drive the app to hammer the API/CDN. After AUTH_FAIL_THRESHOLD within
|
|
7
|
+
// AUTH_FAIL_WINDOW_MS the circuit opens for AUTH_CIRCUIT_COOLDOWN_MS — authed
|
|
8
|
+
// requests are refused locally. Only 401 counts (403 is a permission denial); a
|
|
9
|
+
// success or accepting a new token closes it.
|
|
11
10
|
const AUTH_FAIL_WINDOW_MS = 10_000;
|
|
12
11
|
const AUTH_FAIL_THRESHOLD = 5;
|
|
13
12
|
const AUTH_CIRCUIT_COOLDOWN_MS = 30_000;
|
|
@@ -36,7 +35,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
|
36
35
|
}
|
|
37
36
|
}),
|
|
38
37
|
catchError((err: HttpErrorResponse) => {
|
|
39
|
-
if (authedApi &&
|
|
38
|
+
if (authedApi && err.status === 401) {
|
|
40
39
|
recordAuthFailure();
|
|
41
40
|
}
|
|
42
41
|
return throwError(() => err);
|
|
@@ -70,3 +69,9 @@ function clearAuthFailures(): void {
|
|
|
70
69
|
sessionStorage.removeItem(FAIL_KEY);
|
|
71
70
|
sessionStorage.removeItem(OPEN_KEY);
|
|
72
71
|
}
|
|
72
|
+
|
|
73
|
+
/** Close the circuit on accepting a new token, so a freshly logged-in session
|
|
74
|
+
* isn't blocked by failures from a prior stale session. */
|
|
75
|
+
export function resetAuthCircuit(): void {
|
|
76
|
+
clearAuthFailures();
|
|
77
|
+
}
|
|
@@ -16,6 +16,7 @@ import { ApplicationMenu, ConstantValue } from '../model/common';
|
|
|
16
16
|
import { CanActivateFn, CanDeactivateFn, Router, Routes } from '@angular/router';
|
|
17
17
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
|
|
18
18
|
import { BaseRestService } from './base_rest.service';
|
|
19
|
+
import { resetAuthCircuit } from './auth.interceptor';
|
|
19
20
|
|
|
20
21
|
/** Sort dropdown options by their visible label so long lookup lists are scannable. */
|
|
21
22
|
const byCaption = (a: ConstantValue, b: ConstantValue) =>
|
|
@@ -61,6 +62,10 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
61
62
|
token: string | null = null;
|
|
62
63
|
readonly isLoggedIn = signal(false);
|
|
63
64
|
|
|
65
|
+
/** Set when the OAuth session-cookie handoff fails; the UI shows it and lets the
|
|
66
|
+
* user retry instead of being redirected into a cookieless login loop. */
|
|
67
|
+
readonly sessionHandoffError = signal<string | null>(null);
|
|
68
|
+
|
|
64
69
|
/**
|
|
65
70
|
* Multi-role app support. Consumers that distinguish rider/driver/admin
|
|
66
71
|
* (or any equivalent split) call setRole() after login, then
|
|
@@ -117,15 +122,22 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
117
122
|
this.token = token;
|
|
118
123
|
this.isLoggedIn.set(true);
|
|
119
124
|
localStorage.setItem('jwt', token);
|
|
125
|
+
resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
|
|
120
126
|
const ret = this.validatedReturnUrl();
|
|
121
127
|
if (ret && this.guiConfig.sessionCookieUrl) {
|
|
122
128
|
// OAuth flow: mirror the bearer into a cookie, then hand off to the
|
|
123
|
-
// (cross-origin) authorization server via a full navigation.
|
|
129
|
+
// (cross-origin) authorization server via a full navigation. Only hand off
|
|
130
|
+
// once the cookie is actually set — redirecting after a failed cookie request
|
|
131
|
+
// sends the user on without a session and loops them back to login.
|
|
132
|
+
this.sessionHandoffError.set(null);
|
|
124
133
|
this.http.post(this.url(this.guiConfig.sessionCookieUrl), {}, { withCredentials: true })
|
|
125
134
|
.pipe(take(1))
|
|
126
135
|
.subscribe({
|
|
127
136
|
next: () => window.location.assign(ret),
|
|
128
|
-
error: () =>
|
|
137
|
+
error: (err) => {
|
|
138
|
+
console.error('Session cookie handoff failed:', err);
|
|
139
|
+
this.sessionHandoffError.set('Could not complete sign-in. Please try again.');
|
|
140
|
+
},
|
|
129
141
|
});
|
|
130
142
|
return;
|
|
131
143
|
}
|
|
@@ -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
|
+
}
|