@nauticana/sail 1.1.26 → 1.1.28
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 +29 -1
- package/package.json +1 -1
- package/src/component/billing/portal_button.ts +6 -1
- package/src/component/login/login_component.ts +3 -3
- package/src/config.ts +5 -5
- package/src/model/agency.ts +8 -0
- package/src/service/agency.service.ts +7 -0
- package/src/service/auth.service.ts +76 -29
- package/src/service/billing.service.ts +3 -3
- package/src/service/rest_url.ts +1 -0
- package/src/util/errors.ts +5 -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.1.x ↔ keel v1.2.x**; sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (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`.
|
|
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.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (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. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
|
|
8
8
|
|
|
@@ -308,11 +308,25 @@ interface SailGuiConfig {
|
|
|
308
308
|
defaultPolicyLanguage?: string; // ISO 639-1 fallback language
|
|
309
309
|
accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
|
|
310
310
|
passwordPolicyUrl?: string; // Public route serving keel's password policy (see below)
|
|
311
|
+
oauthServerUrl?: string; // Base URL of keel's OAuth authorization server (see below)
|
|
312
|
+
allowedReturnHosts?: string[]; // Hosts a post-login ?return= may name
|
|
311
313
|
}
|
|
312
314
|
```
|
|
313
315
|
|
|
314
316
|
### Password-policy validation
|
|
315
317
|
|
|
318
|
+
**OAuth sign-in hand-off (v1.1.28, keel v1.2.93).** When the login page is opened with
|
|
319
|
+
`?return=<absolute authorize URL>` and both `oauthServerUrl` and `allowedReturnHosts` are set,
|
|
320
|
+
sail posts `{"return": <raw value>}` with the bearer JWT to
|
|
321
|
+
`{oauthServerUrl}/oauth/session/handoff` and then leaves the SPA with a full navigation to the
|
|
322
|
+
`redirect` keel answers. This runs after a sign-in, and from `loadStoredSession()` when a user
|
|
323
|
+
who is already signed in opens a `/login` route; a 401 rotates the refresh token once and retries. keel sets the authorization server's own HttpOnly cookie while
|
|
324
|
+
redeeming that URL; sail does not opt into cross-origin cookies or send a CSRF token. A
|
|
325
|
+
`return` whose host is not in `allowedReturnHosts`, or a `redirect` outside `oauthServerUrl`'s
|
|
326
|
+
origin, is not followed. Failures set `BaseAuthService.sessionHandoffError` (rendered by
|
|
327
|
+
`<sail-login>`) and never redirect: 400 means keel rejected the return URL, 401 means the user
|
|
328
|
+
signs in again to retry. The authorization server must allow the SPA origin by CORS.
|
|
329
|
+
|
|
316
330
|
Set `passwordPolicyUrl` to the app's public route for keel's `PublicHandler.GetPasswordPolicy`
|
|
317
331
|
(e.g. `'/public/v1/auth/password/policy'`). `BaseAuthService.ensurePasswordPolicy()` fetches it
|
|
318
332
|
once into the `passwordRules` signal, and `passwordPolicyValidator(() => auth.passwordRules())`
|
|
@@ -434,6 +448,11 @@ export class PricingPage {
|
|
|
434
448
|
| `createPortalSession()` | `POST /api/billing/portal` | `Observable<PortalResponse>` |
|
|
435
449
|
| `listUsage()` | `GET /api/billing/usage` | `Observable<UsageMeter[]>` |
|
|
436
450
|
|
|
451
|
+
`cancelSubscription()`, `changePlan()` and `createPortalSession()` answer 403 (keel v1.2.89)
|
|
452
|
+
unless the user holds `PARTNER_PLAN_SUBSCRIPTION` `CANCEL` / `CHANGE` / `PORTAL`, seeded for
|
|
453
|
+
`PARTNER_ADMIN`. Test a failure with `isPermissionDenied(err)` and report it as not permitted;
|
|
454
|
+
`<sail-portal-button>` does.
|
|
455
|
+
|
|
437
456
|
### Components
|
|
438
457
|
|
|
439
458
|
**Plan picker** — pure presentational, no API calls:
|
|
@@ -774,6 +793,15 @@ currency exponent reported by `Intl.NumberFormat`, so zero- and three-decimal
|
|
|
774
793
|
currencies render correctly; earnings stay separated by currency rather than
|
|
775
794
|
being summed into a misleading total.
|
|
776
795
|
|
|
796
|
+
**Delegation roles (v1.1.27, keel v1.2.89).** `AgencyDelegation.roles` (always present) and
|
|
797
|
+
`AgencyClient.roles` carry `AgencyRoleGrant { role, expiresAt? }`; no `expiresAt` is
|
|
798
|
+
open-ended, and a delegation without roles grants no access.
|
|
799
|
+
`AgencyService.setDelegationRoles(roles, clientPartnerId = 0)` replaces the whole set — an
|
|
800
|
+
empty list removes access without revoking the delegation. Role codes and captions come from
|
|
801
|
+
the `agency_delegation_role` constant catalogue (`LabelService.options('agency_delegation_role')`);
|
|
802
|
+
do not hardcode them. keel answers 422 for an unknown, duplicate or blank role, more than 32
|
|
803
|
+
roles, or an expiry not in the future, and 403 without `AGENCY_DELEGATION` / `SET_ROLE`.
|
|
804
|
+
|
|
777
805
|
The agency URL defaults use `/api/v1/agency/*`. Apps mounting Keel elsewhere
|
|
778
806
|
must override the `RestURL.agency*URL` values with `configureRestUrls()`.
|
|
779
807
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.28",
|
|
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
|
"exports": {
|
|
@@ -2,12 +2,14 @@ import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, input }
|
|
|
2
2
|
import { MatButtonModule } from '@angular/material/button';
|
|
3
3
|
import { BaseAsync } from '../abstract/base_async';
|
|
4
4
|
import { BillingService } from '../../service/billing.service';
|
|
5
|
+
import { isPermissionDenied } from '../../util/errors';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Button that opens a provider customer-portal session and redirects the
|
|
8
9
|
* browser to the returned URL — the partner manages their payment method /
|
|
9
10
|
* subscription on the provider's hosted page. Mirrors CheckoutButtonComponent;
|
|
10
|
-
* uses BaseAsync for loading/error state.
|
|
11
|
+
* uses BaseAsync for loading/error state. A 403 (no PARTNER_PLAN_SUBSCRIPTION /
|
|
12
|
+
* PORTAL grant) reads as not permitted rather than as a provider failure.
|
|
11
13
|
*/
|
|
12
14
|
@Component({
|
|
13
15
|
selector: 'sail-portal-button',
|
|
@@ -26,6 +28,9 @@ export class PortalButtonComponent extends BaseAsync {
|
|
|
26
28
|
this.billing.createPortalSession(),
|
|
27
29
|
(resp) => { window.location.href = resp.portalUrl; },
|
|
28
30
|
'Could not open the billing portal. Please try again.',
|
|
31
|
+
(err) => {
|
|
32
|
+
if (isPermissionDenied(err)) this.errorMessage.set('You are not permitted to manage billing.');
|
|
33
|
+
},
|
|
29
34
|
);
|
|
30
35
|
}
|
|
31
36
|
}
|
|
@@ -29,7 +29,7 @@ export class LoginComponent extends BaseAsync {
|
|
|
29
29
|
protected fb = inject(FormBuilder);
|
|
30
30
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
31
31
|
protected readonly loggedOut = inject(ActivatedRoute).snapshot.queryParamMap.get('loggedOut') === 'true';
|
|
32
|
-
/** OAuth session-
|
|
32
|
+
/** OAuth session hand-off failure from BaseAuthService, shown below. */
|
|
33
33
|
protected readonly sessionHandoffError = this.auth.sessionHandoffError;
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -64,8 +64,8 @@ export class LoginComponent extends BaseAsync {
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* loginSocial already ran completeLogin, which owns post-login navigation
|
|
67
|
-
* (route init, or the OAuth
|
|
68
|
-
* doing so abandons a pending
|
|
67
|
+
* (route init, or the OAuth session hand-off). Handlers must NOT navigate here —
|
|
68
|
+
* doing so abandons a pending hand-off before it redirects. Downstream
|
|
69
69
|
* social-login handlers should follow the same rule.
|
|
70
70
|
*/
|
|
71
71
|
protected onSocialSuccess(_: LoginResponseSocial): void {}
|
package/src/config.ts
CHANGED
|
@@ -67,11 +67,11 @@ export interface SailGuiConfig {
|
|
|
67
67
|
accountDeletedRoute?: string;
|
|
68
68
|
|
|
69
69
|
// ── OAuth authorization-flow bridge (for an OAuth AS on a different host) ──
|
|
70
|
-
/**
|
|
71
|
-
* a
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
/** Hostnames a post-login `?return=` may
|
|
70
|
+
/** Base URL of keel's OAuth authorization server, e.g. 'https://mcp.example.com'.
|
|
71
|
+
* When set, a login carrying an allowed ?return= POSTs it to
|
|
72
|
+
* `{oauthServerUrl}/oauth/session/handoff` and navigates to the URL keel answers. */
|
|
73
|
+
oauthServerUrl?: string;
|
|
74
|
+
/** Hostnames a post-login `?return=` may name (open-redirect allowlist),
|
|
75
75
|
* e.g. ['mcp.example.com']. Anything not listed is ignored. */
|
|
76
76
|
allowedReturnHosts?: string[];
|
|
77
77
|
|
package/src/model/agency.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface AgencyClient {
|
|
|
24
24
|
acceptedAt?: string;
|
|
25
25
|
expired?: boolean;
|
|
26
26
|
billingModel?: string;
|
|
27
|
+
roles?: AgencyRoleGrant[];
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface AgencyInvite {
|
|
@@ -42,6 +43,13 @@ export interface AgencyDelegation {
|
|
|
42
43
|
agencyName: string;
|
|
43
44
|
billingModel?: string;
|
|
44
45
|
grantedAt: string;
|
|
46
|
+
roles: AgencyRoleGrant[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One `agency_delegation_role` code a client grants its agency; no expiresAt is open-ended. */
|
|
50
|
+
export interface AgencyRoleGrant {
|
|
51
|
+
role: string;
|
|
52
|
+
expiresAt?: string;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export interface AgencyEarningsBalance {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
AgencyInvite,
|
|
9
9
|
AgencyPayoutProfileOptions,
|
|
10
10
|
AgencyProfile,
|
|
11
|
+
AgencyRoleGrant,
|
|
11
12
|
} from '../model/agency';
|
|
12
13
|
import { BaseRestService } from './base_rest.service';
|
|
13
14
|
import { RestURL } from './rest_url';
|
|
@@ -73,4 +74,10 @@ export class AgencyService extends BaseRestService {
|
|
|
73
74
|
revokeDelegation(clientPartnerId = 0): Observable<{ status: string }> {
|
|
74
75
|
return this.http.post<{ status: string }>(this.url(RestURL.agencyRevokeDelegationURL), { clientPartnerId });
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/** Replace the whole role set the client grants its agency; an empty list
|
|
79
|
+
* removes access without revoking the delegation. */
|
|
80
|
+
setDelegationRoles(roles: AgencyRoleGrant[], clientPartnerId = 0): Observable<{ status: string }> {
|
|
81
|
+
return this.http.post<{ status: string }>(this.url(RestURL.agencyDelegationRolesURL), { clientPartnerId, roles });
|
|
82
|
+
}
|
|
76
83
|
}
|
|
@@ -25,6 +25,20 @@ export class RefreshSupersededError extends Error {
|
|
|
25
25
|
constructor() { super('session changed during refresh'); this.name = 'RefreshSupersededError'; }
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function handoffErrorMessage(status: number | undefined): string {
|
|
29
|
+
if (status === 400) return 'This sign-in link is not valid. Start again from the application you were connecting.';
|
|
30
|
+
if (status === 401) return 'Your session was not accepted. Please sign in again.';
|
|
31
|
+
return 'Could not complete sign-in. Please try again.';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sameOrigin(a: string, b: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(a, window.location.origin).origin === new URL(b, window.location.origin).origin;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
/** Sort dropdown options by their visible label so long lookup lists are scannable. */
|
|
29
43
|
const byCaption = (a: ConstantValue, b: ConstantValue) =>
|
|
30
44
|
(a.Caption ?? '').localeCompare(b.Caption ?? '', undefined, { numeric: true, sensitivity: 'base' });
|
|
@@ -124,8 +138,8 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
124
138
|
/** Last appdata load failure (null when none). */
|
|
125
139
|
readonly appDataError = signal<unknown>(null);
|
|
126
140
|
|
|
127
|
-
/** Set when the OAuth session-
|
|
128
|
-
*
|
|
141
|
+
/** Set when the OAuth session hand-off fails; the UI shows it and the user
|
|
142
|
+
* signs in again to retry instead of being redirected without a session. */
|
|
129
143
|
readonly sessionHandoffError = signal<string | null>(null);
|
|
130
144
|
|
|
131
145
|
/**
|
|
@@ -203,42 +217,73 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
203
217
|
this.sessionGeneration++;
|
|
204
218
|
this.storeTokens(token, refreshToken);
|
|
205
219
|
this.isLoggedIn.set(true);
|
|
220
|
+
this.sessionHandoffError.set(null);
|
|
206
221
|
resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
|
|
207
|
-
|
|
208
|
-
if (ret && this.guiConfig.sessionCookieUrl) {
|
|
209
|
-
// OAuth flow: mirror the bearer into a cookie, then hand off to the
|
|
210
|
-
// (cross-origin) authorization server via a full navigation. Only hand off
|
|
211
|
-
// once the cookie is actually set — redirecting after a failed cookie request
|
|
212
|
-
// sends the user on without a session and loops them back to login.
|
|
213
|
-
this.sessionHandoffError.set(null);
|
|
214
|
-
this.http.post(this.url(this.guiConfig.sessionCookieUrl), {}, { withCredentials: true })
|
|
215
|
-
.pipe(take(1))
|
|
216
|
-
.subscribe({
|
|
217
|
-
next: () => window.location.assign(ret),
|
|
218
|
-
error: (err) => {
|
|
219
|
-
console.error('Session cookie handoff failed:', err);
|
|
220
|
-
this.sessionHandoffError.set('Could not complete sign-in. Please try again.');
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
222
|
+
if (this.startHandoff()) return;
|
|
225
223
|
this.loadAppData();
|
|
226
224
|
this.initRoutes();
|
|
227
225
|
}
|
|
228
226
|
|
|
229
|
-
/**
|
|
230
|
-
*
|
|
227
|
+
/** Hands the session to the OAuth authorization server when the page carries an
|
|
228
|
+
* allowed `?return=` and oauthServerUrl is set; false when there is nothing to hand off. */
|
|
229
|
+
private startHandoff(): boolean {
|
|
230
|
+
const ret = this.validatedReturnUrl();
|
|
231
|
+
const oauthServer = this.oauthServerBase();
|
|
232
|
+
if (!ret || !oauthServer) return false;
|
|
233
|
+
this.handOffSession(oauthServer, ret);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Exchange the bearer session for the authorization server's own cookie session
|
|
238
|
+
* (keel POST /oauth/session/handoff), then leave the SPA for the URL it returns.
|
|
239
|
+
* A 401 rotates the refresh token once and retries. Any failure stays on the
|
|
240
|
+
* login page; the browser is never sent elsewhere. */
|
|
241
|
+
private handOffSession(oauthServer: string, ret: string): void {
|
|
242
|
+
const mint = (token: string) => this.http.post<{ data?: { redirect?: unknown } }>(
|
|
243
|
+
oauthServer + '/oauth/session/handoff',
|
|
244
|
+
{ return: ret },
|
|
245
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
246
|
+
);
|
|
247
|
+
mint(this.token ?? '').pipe(
|
|
248
|
+
catchError((err: HttpErrorResponse) => err.status === 401 && localStorage.getItem('refreshToken')
|
|
249
|
+
? this.refreshSession().pipe(mergeMap(mint))
|
|
250
|
+
: throwError(() => err)),
|
|
251
|
+
take(1),
|
|
252
|
+
).subscribe({
|
|
253
|
+
next: (res) => {
|
|
254
|
+
const redirect = res?.data?.redirect;
|
|
255
|
+
if (typeof redirect !== 'string' || !sameOrigin(redirect, oauthServer)) {
|
|
256
|
+
console.error('Session handoff returned an unexpected redirect:', redirect);
|
|
257
|
+
this.sessionHandoffError.set('Could not complete sign-in. Please try again.');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
window.location.assign(redirect);
|
|
261
|
+
},
|
|
262
|
+
error: (err: unknown) => {
|
|
263
|
+
console.error('Session handoff failed:', err);
|
|
264
|
+
this.sessionHandoffError.set(handoffErrorMessage((err as { status?: number } | null)?.status));
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** guiConfig.oauthServerUrl without trailing slashes; '' when unset. */
|
|
270
|
+
private oauthServerBase(): string {
|
|
271
|
+
return (this.guiConfig.oauthServerUrl ?? '').replace(/\/+$/, '');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The raw post-login `?return=` value, only when its host is in allowedReturnHosts
|
|
275
|
+
* and the scheme is https (or loopback) — an open-redirect guard. '' otherwise. */
|
|
231
276
|
private validatedReturnUrl(): string {
|
|
232
277
|
const raw = new URLSearchParams(window.location.search).get('return');
|
|
233
278
|
if (!raw) return '';
|
|
234
279
|
try {
|
|
235
|
-
const u = new URL(raw
|
|
280
|
+
const u = new URL(raw);
|
|
236
281
|
if (u.username || u.password) return ''; // reject userinfo ("user@host") spoofing
|
|
237
282
|
const host = u.hostname.toLowerCase();
|
|
238
283
|
const okScheme = u.protocol === 'https:' || host === 'localhost' || host === '127.0.0.1';
|
|
239
284
|
const allowed = (this.guiConfig.allowedReturnHosts ?? []).some((h) => h.toLowerCase() === host);
|
|
240
285
|
if (okScheme && allowed) {
|
|
241
|
-
return
|
|
286
|
+
return raw;
|
|
242
287
|
}
|
|
243
288
|
} catch { /* malformed → reject */ }
|
|
244
289
|
return '';
|
|
@@ -415,11 +460,12 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
415
460
|
|
|
416
461
|
loadStoredSession() {
|
|
417
462
|
this.token = localStorage.getItem('jwt');
|
|
418
|
-
if (this.token)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
463
|
+
if (!this.token) return;
|
|
464
|
+
this.isLoggedIn.set(true);
|
|
465
|
+
// A signed-in user sent to the login page by the authorization server is handed off without signing in again.
|
|
466
|
+
if (window.location.pathname.startsWith('/login') && this.startHandoff()) return;
|
|
467
|
+
this.loadAppData();
|
|
468
|
+
this.initRoutes();
|
|
423
469
|
}
|
|
424
470
|
|
|
425
471
|
/** Revoke the refresh token server-side (best effort: the local session is
|
|
@@ -564,6 +610,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
564
610
|
this.cache = undefined;
|
|
565
611
|
this.authIndex.clear();
|
|
566
612
|
this.appDataError.set(null);
|
|
613
|
+
this.sessionHandoffError.set(null);
|
|
567
614
|
this.refreshInFlight = null;
|
|
568
615
|
this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
|
|
569
616
|
this.appData$.next(null); // live subscribers see menus clear; identity stays stable
|
|
@@ -45,7 +45,7 @@ export class BillingService extends BaseRestService {
|
|
|
45
45
|
return this.http.get<Subscription>(this.url(RestURL.subscriptionURL));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** Cancel auto-renew on the current subscription. */
|
|
48
|
+
/** Cancel auto-renew on the current subscription. 403 without PARTNER_PLAN_SUBSCRIPTION / CANCEL. */
|
|
49
49
|
cancelSubscription(): Observable<void> {
|
|
50
50
|
return this.http.post<void>(this.url(RestURL.cancelSubURL), {});
|
|
51
51
|
}
|
|
@@ -53,7 +53,7 @@ export class BillingService extends BaseRestService {
|
|
|
53
53
|
/**
|
|
54
54
|
* Upgrade / downgrade the current subscription to `planId` — distinct from
|
|
55
55
|
* first-time checkout. Routes to keel's `SubscriptionLifecycle.ChangePlan`
|
|
56
|
-
* (atomic close+open)
|
|
56
|
+
* (atomic close+open). 403 without PARTNER_PLAN_SUBSCRIPTION / CHANGE.
|
|
57
57
|
*/
|
|
58
58
|
changePlan(planId: string): Observable<void> {
|
|
59
59
|
return this.http.post<void>(this.url(RestURL.changePlanURL), { planId });
|
|
@@ -86,7 +86,7 @@ export class BillingService extends BaseRestService {
|
|
|
86
86
|
/**
|
|
87
87
|
* Create a provider customer-portal session; caller redirects to the URL so
|
|
88
88
|
* the partner can manage their payment method / subscription on the
|
|
89
|
-
* provider's hosted page.
|
|
89
|
+
* provider's hosted page. 403 without PARTNER_PLAN_SUBSCRIPTION / PORTAL.
|
|
90
90
|
*/
|
|
91
91
|
createPortalSession(): Observable<PortalResponse> {
|
|
92
92
|
return this.http.post<PortalResponse>(this.url(RestURL.portalURL), {});
|
package/src/service/rest_url.ts
CHANGED
|
@@ -77,6 +77,7 @@ export const RestURL = {
|
|
|
77
77
|
agencyAcceptInviteURL: '/api/v1/agency/invite/accept',
|
|
78
78
|
agencyDelegationURL: '/api/v1/agency/delegation',
|
|
79
79
|
agencyRevokeDelegationURL: '/api/v1/agency/delegation/revoke',
|
|
80
|
+
agencyDelegationRolesURL: '/api/v1/agency/delegation/roles',
|
|
80
81
|
// User payment methods — list is keel's generic REST CRUD; set_default and
|
|
81
82
|
// remove are keel UserPaymentMethodHandler table actions.
|
|
82
83
|
paymentMethodSetDefaultURL: '/api/v1/user_payment_method/set_default',
|
package/src/util/errors.ts
CHANGED
|
@@ -31,6 +31,11 @@ export function isTransportFailure(err: unknown): boolean {
|
|
|
31
31
|
return statusOf(err) === 0;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** True for keel's 403: the session is valid but lacks the grant for this action. */
|
|
35
|
+
export function isPermissionDenied(err: unknown): boolean {
|
|
36
|
+
return statusOf(err) === 403;
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
/**
|
|
35
40
|
* Human-readable message from any sail error shape — SailApiError (BackendService),
|
|
36
41
|
* raw HttpErrorResponse (direct HttpClient calls), or plain Error.
|