@nauticana/sail 0.8.4 → 0.9.1
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 +142 -2
- package/package.json +1 -1
- package/src/component/security/trusted_devices.html +0 -3
- package/src/component/security/trusted_devices.ts +0 -5
- package/src/component/security/twofactor_verify.ts +0 -10
- package/src/component/table/table_lookup.ts +1 -1
- package/src/model/appdata.ts +0 -2
- package/src/service/auth.service.ts +12 -8
- package/src/service/rest_service.ts +19 -31
package/README.md
CHANGED
|
@@ -279,7 +279,7 @@ interface SailGuiConfig {
|
|
|
279
279
|
| `GET /api/billing/invoices` | Invoice history |
|
|
280
280
|
| `GET /api/billing/payment-methods` | Saved payment methods |
|
|
281
281
|
|
|
282
|
-
Device registration happens within `/public/2fa/verify` when `trustDevice=true`.
|
|
282
|
+
Device registration happens within `/public/2fa/verify` when `trustDevice=true`. Since **keel v0.9 / sail v0.9.0**, the trusted-device credential is a server-minted `keel_td` cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets `withCredentials: true` on the login and 2FA-verify calls so the cookie round-trips; see [Enabling 2FA → Cross-origin cookie requirement](#cross-origin-cookie-requirement) below.
|
|
283
283
|
|
|
284
284
|
## Enabling 2FA in your project
|
|
285
285
|
|
|
@@ -297,6 +297,22 @@ import { TwoFactorVerifyComponent, TwoFactorSetupComponent, TrustedDevicesCompon
|
|
|
297
297
|
|
|
298
298
|
The login flow handles 2FA automatically: when the backend returns `twoFactorRequired: true`, sail redirects to `/login/2fa`. No other code changes needed.
|
|
299
299
|
|
|
300
|
+
### Cross-origin cookie requirement
|
|
301
|
+
|
|
302
|
+
The "trust this device" feature (keel v0.9+) is backed by a server-set HttpOnly cookie named `keel_td`. The lifecycle is entirely server-driven:
|
|
303
|
+
|
|
304
|
+
1. On `POST /public/2fa/verify` with `trustDevice: true`, keel mints a 32-byte secret, stores its SHA-256, and returns the raw secret to the browser **only** as a `Set-Cookie: keel_td=…` header (never in the JSON body).
|
|
305
|
+
2. On the next `POST /public/login/local` (or `/public/login/gmail`), the browser sends the `keel_td` cookie back, keel hashes it, finds the matching row, and **skips the 2FA prompt**.
|
|
306
|
+
|
|
307
|
+
For the browser to store and resend that cookie, the requests must be credentialed. sail already sets `withCredentials: true` on `login()`, `loginWithGoogle()`, `verify2FALogin()`, and `verifyBackupCode()` — **you do not add this in your app code**. But the keel backend must permit credentialed CORS, or the browser drops the cookie and every login re-prompts for 2FA:
|
|
308
|
+
|
|
309
|
+
- **Same-origin** deployments (SPA served from the same host as the API) work with no extra config — credentialed same-origin requests are always allowed.
|
|
310
|
+
- **Cross-origin** deployments (SPA on a different host/port than the API) require the keel `HttpBackend` to set:
|
|
311
|
+
- `AllowCredentials: true` → emits `Access-Control-Allow-Credentials: true`
|
|
312
|
+
- `Origin` = the **exact** SPA origin (e.g. `https://app.example.com`). A wildcard `*` is rejected by the browser for credentialed responses — keel's CORS layer enforces this.
|
|
313
|
+
|
|
314
|
+
If "trust this device" appears to do nothing (user is 2FA-prompted on every login despite checking the box), the cause is almost always a missing `AllowCredentials` / wildcard-origin on the backend — open devtools and confirm the `keel_td` cookie is actually set after a successful verify.
|
|
315
|
+
|
|
300
316
|
## Billing
|
|
301
317
|
|
|
302
318
|
sail ships a shared `BillingService` and three billing components backed by keel's payment endpoints (see [keel/SHARED_PAYMENT.md](https://github.com/nauticana/keel/blob/main/SHARED_PAYMENT.md) for the provider contract — Stripe by default, pluggable).
|
|
@@ -532,6 +548,81 @@ Under the hood, the component calls `BaseAuthService.loginSocial(provider, idTok
|
|
|
532
548
|
|
|
533
549
|
For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
|
|
534
550
|
|
|
551
|
+
## Adding a new domain table
|
|
552
|
+
|
|
553
|
+
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.
|
|
554
|
+
|
|
555
|
+
### 1. Backend: register the route
|
|
556
|
+
|
|
557
|
+
Add a `rest_api_header` seed row so keel mounts the CRUD endpoints at boot:
|
|
558
|
+
|
|
559
|
+
```sql
|
|
560
|
+
INSERT INTO rest_api_header (id, master_table)
|
|
561
|
+
VALUES ('favorite_location', 'favorite_location');
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
This exposes `GET/POST /api/v1/favorite_location/list,get,post,delete`. The binary reads `rest_api_header` once at startup, so restart `httpsrv` after seeding.
|
|
565
|
+
|
|
566
|
+
### 2. Frontend: model class extends `SiudAction`
|
|
567
|
+
|
|
568
|
+
Every row submitted through keel's bulk-POST endpoint is dispatched by an `op_code` field — `I` insert, `U` update, `D` delete, `R` recurse-children, `S` select-only (no-op). Rows without `op_code` are **silently skipped**: the handler still returns 201, but no SQL runs. The way to stay safe is to extend `SiudAction` (which carries `op_code: OpCodeValue` defaulted to `'S'`) rather than declaring a plain interface.
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
import { SiudAction } from '@nauticana/sail';
|
|
572
|
+
|
|
573
|
+
export class FavoriteLocation extends SiudAction {
|
|
574
|
+
Id?: number;
|
|
575
|
+
UserId?: number;
|
|
576
|
+
Label?: string;
|
|
577
|
+
Address?: string;
|
|
578
|
+
Lat?: number;
|
|
579
|
+
Lng?: number;
|
|
580
|
+
SortOrder?: number;
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Field names are **PascalCase** — keel marshals SQL columns to `column.pascal_name` (i.e. `user_id` → `UserId`) for the wire format, and the `<table-edit>` component generator follows the same convention. Snake_case fields will round-trip in some paths but break others (e.g. `<table-edit>` form binding); pick PascalCase up front.
|
|
585
|
+
|
|
586
|
+
### 3. Frontend: flip op_code before save
|
|
587
|
+
|
|
588
|
+
When the row leaves the UI, set `op_code` to the operation you actually want:
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
import { OpCode } from '@nauticana/sail';
|
|
592
|
+
import { BackendService } from 'service/rest_service';
|
|
593
|
+
import { FavoriteLocation } from 'model/tripdb';
|
|
594
|
+
|
|
595
|
+
const backend = inject(BackendService);
|
|
596
|
+
|
|
597
|
+
const row = new FavoriteLocation();
|
|
598
|
+
row.op_code = existing?.Id ? OpCode.Update : OpCode.Insert;
|
|
599
|
+
row.Id = existing?.Id;
|
|
600
|
+
row.Label = 'Home';
|
|
601
|
+
row.Address = '...';
|
|
602
|
+
|
|
603
|
+
backend.post<FavoriteLocation>('v1/favorite_location', [row]).subscribe();
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
The `<table-edit>` and `<table-list>` components handle this automatically — they tag fetched rows `'S'`, flip to `'U'` on dirty-edit, `'I'` for new rows, and `'D'` when the user deletes. You only need to set `op_code` manually when bypassing those components (custom save flows, bulk imports, etc.).
|
|
607
|
+
|
|
608
|
+
### 4. Backend permissions
|
|
609
|
+
|
|
610
|
+
Grant `TABLE SELECT/INSERT/UPDATE/DELETE` to the relevant roles in `authorization_role_permission`. For owner-scoped tables (rows have a `user_id` FK to `user_account`), keel auto-detects `UserSpecific` from the FK + column-name combination and pins the session's user id on insert + filters list/get to the owner automatically — no extra wiring needed.
|
|
611
|
+
|
|
612
|
+
```sql
|
|
613
|
+
INSERT INTO authorization_role_permission (role_id, authorization_object_id, action, low_limit) VALUES
|
|
614
|
+
('RIDER', 'TABLE', 'INSERT', 'favorite_location'),
|
|
615
|
+
('RIDER', 'TABLE', 'UPDATE', 'favorite_location'),
|
|
616
|
+
('RIDER', 'TABLE', 'DELETE', 'favorite_location');
|
|
617
|
+
-- SELECT is granted via the wildcard ('RIDER', 'TABLE', 'SELECT', '*') if you have one.
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
### Footguns
|
|
621
|
+
|
|
622
|
+
- **Plain interfaces instead of `extends SiudAction`** — `op_code` field never exists, every row in a bulk POST is silently skipped, handler returns 201, table stays empty. Symptom: "saving says success but the list comes back empty."
|
|
623
|
+
- **Snake_case fields** — keel marshals PascalCase, so a snake_case `address` field on the wire serializes as `Address` from keel but your client TS expects `address`. Mixed payloads break in subtle ways (read paths show data, write paths look like no-ops).
|
|
624
|
+
- **Forgetting to restart httpsrv after seeding `rest_api_header`** — the route table is built once at startup. New rows = 404 until restart.
|
|
625
|
+
|
|
535
626
|
## Account deletion / logout everywhere / push tokens
|
|
536
627
|
|
|
537
628
|
These are App Store / Play Store compliance primitives from keel. All are opt-in.
|
|
@@ -632,7 +723,7 @@ npm update @nauticana/sail
|
|
|
632
723
|
Or to install a specific version:
|
|
633
724
|
|
|
634
725
|
```bash
|
|
635
|
-
npm install @nauticana/sail@0.
|
|
726
|
+
npm install @nauticana/sail@0.9.0
|
|
636
727
|
```
|
|
637
728
|
|
|
638
729
|
## Migrating to v0.5.0
|
|
@@ -1174,6 +1265,55 @@ Two-step plumbing on the page that owns the OTP screen:
|
|
|
1174
1265
|
|
|
1175
1266
|
v0.8.2 targets **keel v0.8.4**. The new `resendCountdownSec` response field is documented in [keel/README.md → Migration Guide (v0.8.4)](https://github.com/nauticana/keel/blob/main/README.md). Sail will tolerate older keels that don't return the field — the input falls back to its 30s default.
|
|
1176
1267
|
|
|
1268
|
+
## Migrating to v0.9.0 — server-set trusted-device cookie
|
|
1269
|
+
|
|
1270
|
+
**Breaking change.** v0.9.0 adopts keel's v0.9 trusted-device model: the "trust this device" credential moves from a client-supplied `deviceFingerprint` string to a server-minted, HttpOnly `keel_td` cookie. This closes two weaknesses in the old scheme — the client picked the fingerprint value (so a malicious client could replay a known fingerprint across users), and keel stored it in plaintext (so a DB leak handed an attacker direct 2FA-bypass material). The new secret is server-generated, returned only via `Set-Cookie`, and stored as a SHA-256 hash.
|
|
1271
|
+
|
|
1272
|
+
This release targets **keel v0.9** (the "bdsmail upstream" trusted-device rework). It will **not** interoperate correctly with keel < v0.9 — older keels expect the `deviceFingerprint` field that sail no longer sends, so "trust this device" silently fails. Upgrade the backend in lockstep.
|
|
1273
|
+
|
|
1274
|
+
### What changed in the public API
|
|
1275
|
+
|
|
1276
|
+
| Symbol | Before (v0.8.x) | After (v0.9.0) |
|
|
1277
|
+
|---|---|---|
|
|
1278
|
+
| `BaseAuthService.login()` | `login(username, password, deviceFingerprint?)` | `login(username, password)` |
|
|
1279
|
+
| `BaseAuthService.verify2FALogin()` | `verify2FALogin(code, trustDevice?, deviceFingerprint?, deviceName?)` | `verify2FALogin(code, trustDevice?, deviceName?)` |
|
|
1280
|
+
| `TwoFactorVerifyRequest` (model) | had `deviceFingerprint?: string` | field removed |
|
|
1281
|
+
| `TrustedDevice` (model) | had `fingerprint: string` | field removed (keel no longer returns it — it was the bypass credential) |
|
|
1282
|
+
|
|
1283
|
+
`trustDevice` and `deviceName` are unchanged — `deviceName` is still a real field keel records as the human-readable label in the trusted-device list.
|
|
1284
|
+
|
|
1285
|
+
### Migration steps
|
|
1286
|
+
|
|
1287
|
+
Most consumers use sail's bundled `LoginComponent` / `TwoFactorVerifyComponent` and **need no code changes** — those components were updated internally. Steps below apply only if you call the auth API directly or wrap these components.
|
|
1288
|
+
|
|
1289
|
+
1. **Drop the `deviceFingerprint` argument** from any direct call to `login()` or `verify2FALogin()`:
|
|
1290
|
+
|
|
1291
|
+
```diff
|
|
1292
|
+
- this.auth.login(username, password, this.getDeviceFingerprint());
|
|
1293
|
+
+ this.auth.login(username, password);
|
|
1294
|
+
|
|
1295
|
+
- this.auth.verify2FALogin(code, trustDevice, this.getDeviceFingerprint(), deviceName);
|
|
1296
|
+
+ this.auth.verify2FALogin(code, trustDevice, deviceName);
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
2. **Delete any client-side fingerprint generation.** The old `crypto.randomUUID()` → `localStorage['deviceFingerprint']` pattern is dead — the server owns the credential now. You can also clear the stale key:
|
|
1300
|
+
|
|
1301
|
+
```ts
|
|
1302
|
+
localStorage.removeItem('deviceFingerprint');
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
3. **Stop reading `TrustedDevice.fingerprint`.** It no longer exists on the type. If you rendered a "this device" badge by comparing it to a stored fingerprint, **remove that** — the credential is an HttpOnly cookie, so JavaScript cannot read it and the current device is no longer identifiable client-side. sail removed the badge from its own `TrustedDevicesComponent`.
|
|
1306
|
+
|
|
1307
|
+
4. **Confirm credentialed CORS on the backend.** This is the one operational step that is easy to miss. The `keel_td` cookie only round-trips on credentialed requests. sail sends `withCredentials: true` automatically, but the keel `HttpBackend` must cooperate — see [Enabling 2FA → Cross-origin cookie requirement](#cross-origin-cookie-requirement). Same-origin deployments need nothing; cross-origin deployments need `AllowCredentials: true` and an exact (non-wildcard) `Origin`.
|
|
1308
|
+
|
|
1309
|
+
### Existing trusted-device rows
|
|
1310
|
+
|
|
1311
|
+
No data migration needed. Rows written under the old plaintext-fingerprint scheme will never match the new hashed lookup and expire naturally via their 30-day `expires_at`. Affected users are prompted for 2FA on their next login and can re-opt into "trust this device". (Backends that want to clean up early: `DELETE FROM user_trusted_device WHERE LENGTH(device_fingerprint) <> 64` — new hashes are exactly 64 hex chars.)
|
|
1312
|
+
|
|
1313
|
+
### Backend alignment
|
|
1314
|
+
|
|
1315
|
+
v0.9.0 targets **keel v0.9**. The full backend contract is documented in [keel/README.md → Trusted-device model (v0.9) and Migration from v0.8 trusted-device API](https://github.com/nauticana/keel/blob/main/README.md). Verify your backend sets `AllowCredentials` (cross-origin only) before rolling out.
|
|
1316
|
+
|
|
1177
1317
|
## Modernization items
|
|
1178
1318
|
|
|
1179
1319
|
When you extend sail in your own app, apply the same patterns sail itself follows. Downstream projects that copied templates or scaffolding from earlier sail versions will benefit from the same sweep. This list is a checklist — none are sail-specific.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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
|
"files": [
|
|
@@ -80,11 +80,6 @@ export class TrustedDevicesComponent extends BaseAsync implements OnInit {
|
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
isCurrentDevice(device: TrustedDevice): boolean {
|
|
84
|
-
const fp = localStorage.getItem('deviceFingerprint');
|
|
85
|
-
return !!fp && device.fingerprint === fp;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
83
|
formatDate(dateStr: string): string {
|
|
89
84
|
if (!dateStr) return '';
|
|
90
85
|
return dateStr.slice(0, 16).replace('T', ' ');
|
|
@@ -67,21 +67,11 @@ export class TwoFactorVerifyComponent extends BaseAsync {
|
|
|
67
67
|
this.auth.verify2FALogin(
|
|
68
68
|
code!,
|
|
69
69
|
trustDevice ?? false,
|
|
70
|
-
this.getDeviceFingerprint(),
|
|
71
70
|
this.getDeviceName(),
|
|
72
71
|
);
|
|
73
72
|
}
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
private getDeviceFingerprint(): string {
|
|
77
|
-
let fp = localStorage.getItem('deviceFingerprint');
|
|
78
|
-
if (!fp) {
|
|
79
|
-
fp = crypto.randomUUID();
|
|
80
|
-
localStorage.setItem('deviceFingerprint', fp);
|
|
81
|
-
}
|
|
82
|
-
return fp;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
75
|
private getDeviceName(): string {
|
|
86
76
|
return navigator.userAgent.substring(0, 80);
|
|
87
77
|
}
|
|
@@ -46,7 +46,7 @@ export class TableLookup extends BaseView implements OnInit {
|
|
|
46
46
|
for (const api in data.Apis) {
|
|
47
47
|
const entry = data.Apis[api];
|
|
48
48
|
if (entry.Table.TableName === tableName) {
|
|
49
|
-
this.apiName.set(
|
|
49
|
+
this.apiName.set(api); // bare RestUri; BackendService.crudUrl resolves the version
|
|
50
50
|
break;
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/model/appdata.ts
CHANGED
|
@@ -160,7 +160,6 @@ export interface TwoFactorVerifyRequest {
|
|
|
160
160
|
code: string;
|
|
161
161
|
loginToken?: string;
|
|
162
162
|
trustDevice?: boolean;
|
|
163
|
-
deviceFingerprint?: string;
|
|
164
163
|
deviceName?: string;
|
|
165
164
|
}
|
|
166
165
|
|
|
@@ -172,7 +171,6 @@ export interface TwoFactorVerifyResponse {
|
|
|
172
171
|
export interface TrustedDevice {
|
|
173
172
|
id: number;
|
|
174
173
|
name: string;
|
|
175
|
-
fingerprint: string;
|
|
176
174
|
last_used_at: string;
|
|
177
175
|
created_at: string;
|
|
178
176
|
}
|
|
@@ -104,8 +104,11 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
104
104
|
this.initRoutes();
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
login(username: string, password: string
|
|
108
|
-
|
|
107
|
+
login(username: string, password: string) {
|
|
108
|
+
// withCredentials so the browser sends the server-set `keel_td` cookie
|
|
109
|
+
// (keel v0.9 trusted-device secret). When it maps to a trusted row, keel
|
|
110
|
+
// skips 2FA. The old client-supplied `deviceFingerprint` field is gone.
|
|
111
|
+
this.http.post<LoginResponse2FA>(this.loginUrl, { username, password }, { withCredentials: true }).subscribe({
|
|
109
112
|
next: (res) => {
|
|
110
113
|
if (res.twoFactorRequired && res.loginToken) {
|
|
111
114
|
localStorage.setItem('loginToken', res.loginToken);
|
|
@@ -121,7 +124,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
loginWithGoogle(code: string, redirectUri?: string) {
|
|
124
|
-
this.http.post<LoginResponse2FA>(this.loginGoogleUrl, { code, redirectUri }).subscribe({
|
|
127
|
+
this.http.post<LoginResponse2FA>(this.loginGoogleUrl, { code, redirectUri }, { withCredentials: true }).subscribe({
|
|
125
128
|
next: (res) => {
|
|
126
129
|
if (res.twoFactorRequired && res.loginToken) {
|
|
127
130
|
localStorage.setItem('loginToken', res.loginToken);
|
|
@@ -156,7 +159,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
156
159
|
/**
|
|
157
160
|
* Verify a TOTP code during the LOGIN flow (public endpoint). Uses `/public/2fa/verify`
|
|
158
161
|
*/
|
|
159
|
-
verify2FALogin(code: string, trustDevice = false,
|
|
162
|
+
verify2FALogin(code: string, trustDevice = false, deviceName?: string) {
|
|
160
163
|
const loginToken = localStorage.getItem('loginToken');
|
|
161
164
|
if (!loginToken) {
|
|
162
165
|
console.error('No login token found');
|
|
@@ -166,10 +169,11 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
166
169
|
code,
|
|
167
170
|
loginToken,
|
|
168
171
|
trustDevice,
|
|
169
|
-
deviceFingerprint,
|
|
170
172
|
deviceName,
|
|
171
173
|
};
|
|
172
|
-
|
|
174
|
+
// withCredentials so the browser stores the `keel_td` cookie keel sets on
|
|
175
|
+
// a `trustDevice:true` verify — that cookie is what skips 2FA next login.
|
|
176
|
+
this.http.post<TwoFactorVerifyResponse>(this.twoFactorLoginVerifyUrl, request, { withCredentials: true }).subscribe({
|
|
173
177
|
next: (res) => {
|
|
174
178
|
if (res.valid && res.token) {
|
|
175
179
|
localStorage.removeItem('loginToken');
|
|
@@ -185,7 +189,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
185
189
|
/** Verify a single-use backup code during the LOGIN flow (public endpoint). */
|
|
186
190
|
verifyBackupCode(code: string) {
|
|
187
191
|
const loginToken = localStorage.getItem('loginToken');
|
|
188
|
-
return this.http.post<TwoFactorVerifyResponse>(this.twoFactorBackupVerifyUrl, { code, loginToken });
|
|
192
|
+
return this.http.post<TwoFactorVerifyResponse>(this.twoFactorBackupVerifyUrl, { code, loginToken }, { withCredentials: true });
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
/**
|
|
@@ -553,7 +557,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
553
557
|
if (!page.ItemId) continue;
|
|
554
558
|
const restUri = page.RestUri ?? '';
|
|
555
559
|
const api = apis?.[restUri];
|
|
556
|
-
const apiName =
|
|
560
|
+
const apiName = restUri; // bare RestUri; BackendService.crudUrl resolves the version
|
|
557
561
|
const tableName = api?.Table?.TableName ?? restUri;
|
|
558
562
|
|
|
559
563
|
const overrideComponent = this.getRouteOverride(restUri);
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { HttpErrorResponse, HttpParams } from "@angular/common/http";
|
|
2
|
-
import { Injectable } from "@angular/core";
|
|
2
|
+
import { inject, Injectable } from "@angular/core";
|
|
3
3
|
import { catchError, map, Observable, throwError } from "rxjs";
|
|
4
4
|
import { PaginatedList } from "../model/appdata";
|
|
5
5
|
import { RestURL } from "./rest_url";
|
|
6
|
+
import { BaseAuthService } from "./auth.service";
|
|
6
7
|
import { BaseRestService } from "./base_rest.service";
|
|
7
8
|
|
|
8
9
|
@Injectable({providedIn: 'root'})
|
|
9
10
|
export class BackendService extends BaseRestService {
|
|
11
|
+
private readonly auth = inject(BaseAuthService);
|
|
10
12
|
protected readonly prefix = this.url(RestURL.api_prefix);
|
|
11
13
|
|
|
12
14
|
protected handleError(error: HttpErrorResponse) {
|
|
@@ -18,33 +20,25 @@ export class BackendService extends BaseRestService {
|
|
|
18
20
|
return throwError(() => 'error occurred, see console log and try again');
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
/**
|
|
22
|
-
* Build a fully-qualified URL under the configured API base. Use for
|
|
23
|
-
* endpoints that don't fit the metadata-driven `/api/{name}/{op}`
|
|
24
|
-
* shape — e.g. domain-specific actions like `rides/start`. Strips any
|
|
25
|
-
* leading slash on `path` so callers can pass either form.
|
|
26
|
-
*
|
|
27
|
-
* For metadata-driven CRUD use list/get/post/delete instead — they
|
|
28
|
-
* already build the URL.
|
|
29
|
-
*/
|
|
23
|
+
/** Free-form path under the API base — for custom endpoints (e.g. `rides/start`). CRUD methods use `crudUrl` instead. */
|
|
30
24
|
protected apiUrl(path: string): string {
|
|
31
25
|
const trimmed = path.startsWith('/') ? path.slice(1) : path;
|
|
32
26
|
return this.prefix + trimmed;
|
|
33
27
|
}
|
|
34
28
|
|
|
29
|
+
/** CRUD URL versioned from the backend (Apis[apiName].Version); throws rather than assume a version. */
|
|
30
|
+
private crudUrl(apiName: string, action: string): string {
|
|
31
|
+
const version = this.auth.getApiDictionary(apiName)?.Version;
|
|
32
|
+
if (!version) {
|
|
33
|
+
throw new Error(`sail: no backend version for API "${apiName}"; refusing to assume one.`);
|
|
34
|
+
}
|
|
35
|
+
return `${this.prefix}${version}/${apiName}/${action}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
35
38
|
/**
|
|
36
39
|
* Generic typed HTTP request for endpoints that don't fit the
|
|
37
40
|
* list/get/post/delete pattern. Subclasses use this for
|
|
38
|
-
* domain-specific actions
|
|
39
|
-
*
|
|
40
|
-
* class RideService extends BackendService {
|
|
41
|
-
* startRide(req: StartRideReq) {
|
|
42
|
-
* return this.request<RideResponse>('POST', 'rides/start', req);
|
|
43
|
-
* }
|
|
44
|
-
* }
|
|
45
|
-
*
|
|
46
|
-
* Errors are routed through handleError so callers see the same
|
|
47
|
-
* "see console log and try again" envelope as CRUD methods.
|
|
41
|
+
* domain-specific actions.
|
|
48
42
|
*/
|
|
49
43
|
protected request<T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: unknown): Observable<T> {
|
|
50
44
|
return this.http.request<T>(method, this.apiUrl(path), { body }).pipe(catchError(this.handleError));
|
|
@@ -62,34 +56,28 @@ export class BackendService extends BaseRestService {
|
|
|
62
56
|
return params;
|
|
63
57
|
}
|
|
64
58
|
|
|
65
|
-
/**
|
|
66
|
-
* keel list endpoints return `{items, limit, offset, total}`. This method
|
|
67
|
-
* unwraps to the array — most callers only need the records. Use
|
|
68
|
-
* `listPaginated()` to access the total + offset. Pass `_limit` / `_offset`
|
|
69
|
-
* in `filter` to control paging (defaults: 100 / 0).
|
|
70
|
-
*/
|
|
59
|
+
/** Unwraps `{items, limit, offset, total}` to the items array. Pass `_limit` / `_offset` in `filter` to control paging (defaults: 100 / 0). */
|
|
71
60
|
list<T>(apiName: string, filter?: {[key: string]: string}) : Observable<T[]> {
|
|
72
61
|
return this.listPaginated<T>(apiName, filter).pipe(map((page) => page.items));
|
|
73
62
|
}
|
|
74
63
|
|
|
75
|
-
/** Paginated list — returns `{items, limit, offset, total}`. */
|
|
76
64
|
listPaginated<T>(apiName: string, filter?: {[key: string]: string}) : Observable<PaginatedList<T>> {
|
|
77
65
|
return this.http.get<PaginatedList<T>>(
|
|
78
|
-
this.
|
|
66
|
+
this.crudUrl(apiName, 'list'),
|
|
79
67
|
{params: this.toParams(filter)},
|
|
80
68
|
).pipe(catchError(this.handleError));
|
|
81
69
|
}
|
|
82
70
|
|
|
83
71
|
get<T>(apiName: string, filter?: {[key: string]: string}) : Observable<T> {
|
|
84
|
-
return this.http.get<T>(this.
|
|
72
|
+
return this.http.get<T>(this.crudUrl(apiName, 'get'), {params: this.toParams(filter)}).pipe(catchError(this.handleError));
|
|
85
73
|
}
|
|
86
74
|
|
|
87
75
|
post<T>(apiName: string, items: T | T[]) : Observable<{message: string}> {
|
|
88
|
-
return this.http.post<{message: string}>(this.
|
|
76
|
+
return this.http.post<{message: string}>(this.crudUrl(apiName, 'post'), items).pipe(catchError(this.handleError));
|
|
89
77
|
}
|
|
90
78
|
|
|
91
79
|
delete(apiName: string, filter?: {[key: string]: string}): Observable<{message: string}> {
|
|
92
|
-
return this.http.delete<{message: string}>(this.
|
|
80
|
+
return this.http.delete<{message: string}>(this.crudUrl(apiName, 'delete'), {params: this.toParams(filter)}).pipe(catchError(this.handleError));
|
|
93
81
|
}
|
|
94
82
|
|
|
95
83
|
/**
|