Browse Source

feat(iot-hub): drop itemId-based deep links, keep only version URL

Consolidates to a single deep-link shape: /iot-hub/version/{itemVersionId}.
A specific version snapshot is a more useful sharable link than
"latest of item" — stable, unambiguous, and fits creator review
workflows ("please look at exactly this draft").

Removes /iot-hub/{itemId} and /iot-hub/{itemId}/preview routes, the
getPublishedVersion and getLatestVersion API methods (now unused), the
byVersion and preview route-data flags, and the iot-hub.item-preview
i18n key. Simplifies the resolver to a single getVersionInfo call with
the same warning-if-unpublished gate.

Big win for the IoT Hub backend contract: no new endpoints needed, only
a behavior guarantee on existing /api/versions/{versionId}.
pull/15539/head
Andrii Shvaika 5 months ago
parent
commit
c2747b68f6
  1. 19
      docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md
  2. 137
      docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md
  3. 14
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  4. 30
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts
  5. 18
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts
  6. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json

19
docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md

@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship `/iot-hub/{itemId}` (published), `/iot-hub/{itemId}/preview` (unpublished-with-warning), and `/iot-hub/version/{itemVersionId}` (stable version snapshot, warning if unpublished) deep links that resolve a version, navigate to its type-specific browse page, and open the existing detail dialog. **Goal:** Ship `/iot-hub/version/{itemVersionId}` deep links that resolve a specific version, gate unpublished versions behind a security warning, navigate to the type-specific browse page, and open the existing detail dialog.
**Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints. **Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints.
@ -924,14 +924,15 @@ If the smoke test uncovered issues that required fixes, stage and commit them wi
## IoT Hub-side changes required (recap) ## IoT Hub-side changes required (recap)
From the design doc, the following IoT Hub (external) backend work is needed to ship the full deep-link feature end-to-end: No new endpoints. Only behavior + CORS contracts on the existing `/api/versions/{versionId}` family:
1. **New endpoint** `GET /api/items/{itemId}/published` — latest PUBLISHED version as `MpItemVersionView`, 404 if none. Anonymous cross-origin. 1. **`GET /api/versions/{versionId}`** must return the requested version regardless of state (PUBLISHED / DRAFT / PENDING_REVIEW / …). Anonymous cross-origin; versionId UUID is the soft-secret gate.
2. **New endpoint** `GET /api/items/{itemId}/latest` — latest version regardless of state, preferring non-published and falling back to published. Anonymous cross-origin. 2. **`MpItemVersionView`** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`0`/`null`) for non-published versions, or add an explicit `state` field. Frontend's `isPublished()` uses `publishedTime > 0` today.
3. **Existing `GET /api/versions/{versionId}`** must return unpublished versions when queried directly by id (powers the `/iot-hub/version/{itemVersionId}` URL). Anonymous cross-origin, soft-secret authorization via versionId. 3. **Related by-versionId endpoints** must also serve unpublished versions (required for install-from-deep-link):
4. **`MpItemVersionView`**: for unpublished versions, either keep `publishedTime` falsy (`0`/`null`) or add an explicit `state` field. The frontend's `isPublished()` uses `publishedTime > 0` today. - `GET /api/versions/{versionId}/readme`
5. **By-versionId endpoints** (`GET /api/versions/{versionId}`, `/readme`, `/fileData`, `POST /install`) must all serve unpublished versions when queried by ID — the install flow proxied from TB depends on this. - `GET /api/versions/{versionId}/fileData`
6. **Install counter policy** for unpublished versions — recommended: skip counting. - `POST /api/versions/{versionId}/install`
7. **CORS** on `/api/items/{itemId}/published`, `/api/items/{itemId}/latest`, and the by-versionId endpoints must permit cross-origin GET from any origin. 4. **Install counter policy** for unpublished versions — recommended: skip counting.
5. **CORS** on the `/api/versions/{versionId}/...` family must permit cross-origin GET from any origin.
These live in the IoT Hub repository, not ThingsBoard CE. These live in the IoT Hub repository, not ThingsBoard CE.

137
docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md

@ -7,70 +7,47 @@
## Summary ## Summary
Allow URLs of the form `http://<tb-host>/iot-hub/{itemId}`, `/iot-hub/{itemId}/preview`, and `/iot-hub/version/{itemVersionId}` to open the detail view for any IoT Hub item or specific version. The preview and by-version variants support creators testing unpublished content on any ThingsBoard instance; both gate unpublished versions behind a security warning before the detail view opens. Allow a URL of the form `http://<tb-host>/iot-hub/version/{itemVersionId}` to open the detail view for a specific IoT Hub item version. The link works for published and unpublished versions alike; unpublished versions are gated behind a security warning before the detail view opens. Intended for creators sharing stable snapshots ("review exactly this draft") and for bookmarkable deep links to any version.
## Goals ## Goals
- Shareable, bookmarkable deep links to IoT Hub items and versions. - A single shareable, bookmarkable deep link shape: `/iot-hub/version/{itemVersionId}`.
- Three URL shapes with distinct semantics: - Published versions open directly.
- `/iot-hub/{itemId}` — always latest published version of the item. - Unpublished versions (DRAFT / PENDING_REVIEW / …) open only after the user acknowledges a security warning, and the detail dialog then shows an "Unpublished preview" badge.
- `/iot-hub/{itemId}/preview` — latest version regardless of state (draft-first, falling back to published), gated by a security warning when unpublished.
- `/iot-hub/version/{itemVersionId}` — a specific version (stable snapshot, ideal for "review exactly this draft" links), gated by the same warning when unpublished.
- Zero backend changes in ThingsBoard. Install/update flows reuse the existing versionId-based pipeline. - Zero backend changes in ThingsBoard. Install/update flows reuse the existing versionId-based pipeline.
## Non-goals ## Non-goals
- Authenticated access to unpublished content. Authorization is "public-by-link": whoever has the item UUID can fetch it. - Authenticated access to unpublished content. Authorization is "public-by-link": whoever has the version UUID can fetch it.
- Full standalone page for item detail. The detail view stays as an Angular Material dialog; the deep link navigates to the type-specific browse page (`/iot-hub/widgets`, `/iot-hub/dashboards`, etc.) and opens the dialog over it. - Full standalone page for item detail. The detail view stays as an Angular Material dialog; the deep link navigates to the type-specific browse page (`/iot-hub/widgets`, `/iot-hub/dashboards`, etc.) and opens the dialog over it.
- Preview-specific install semantics. Install from preview reuses the same flow as a normal install; only the counter policy on the IoT Hub side may differ (see IoT Hub-side changes). - Install-specific semantics for unpublished versions. Install reuses the normal flow; only the IoT Hub-side install counter policy may differ (see IoT Hub-side changes).
- Item-level "latest" shorthand links (no `/iot-hub/{itemId}` or `/iot-hub/{itemId}/preview`). Creators share specific version ids; stable links are preferred over moving-target "latest" semantics.
## User flows ## User flows
### Published link: `/iot-hub/{itemId}`
1. User pastes or clicks `/iot-hub/{itemId}`.
2. Angular mounts `TbIotHubItemResolverComponent`.
3. Resolver calls `iotHubApiService.getPublishedVersion(itemId)` → IoT Hub `GET /api/items/{itemId}/published`.
4. On success, resolver navigates to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`.
5. `TbIotHubItemsPageComponent.ngOnInit` consumes `history.state.openItem`, resolves installed state, and calls `IotHubActionsService.openItemDetail(...)`.
6. The existing detail dialog opens with no visual changes.
7. On 404 or other error, resolver shows a toast and redirects to `/iot-hub`.
### Preview link: `/iot-hub/{itemId}/preview`
1. User pastes or clicks `/iot-hub/{itemId}/preview`.
2. `TbIotHubItemResolverComponent` mounts with `route.data.preview === true`.
3. Resolver calls `iotHubApiService.getLatestVersion(itemId)` → IoT Hub `GET /api/items/{itemId}/latest`.
4. If the resolved version `isPublished()` returns true (no draft exists; endpoint fell back to published), behave exactly like the published flow — no warning, no badge.
5. If the resolved version is unpublished, resolver opens `TbIotHubUnpublishedWarningDialogComponent` (`disableClose: true`).
- Cancel → `router.navigate(['/iot-hub'])`.
- "I understand the risk, continue" → resolver navigates to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: true } }` and `replaceUrl: true`.
6. Type-page opens the detail dialog with `preview: true`; dialog renders an "Unpublished preview" badge next to the version in the sticky meta bar.
7. Install / Update / Remove / Open-entity actions behave identically to a published item. The preview badge is informational.
### Version link: `/iot-hub/version/{itemVersionId}` ### Version link: `/iot-hub/version/{itemVersionId}`
1. User pastes or clicks `/iot-hub/version/{itemVersionId}`. 1. User pastes or clicks `/iot-hub/version/{itemVersionId}`.
2. `TbIotHubItemResolverComponent` mounts with `route.data.byVersion === true`. 2. Angular mounts `TbIotHubItemResolverComponent`.
3. Resolver calls `iotHubApiService.getVersionInfo(itemVersionId)` → IoT Hub `GET /api/versions/{versionId}` (existing endpoint). 3. Resolver calls `iotHubApiService.getVersionInfo(itemVersionId)` → IoT Hub `GET /api/versions/{versionId}` (existing endpoint).
4. If the returned version is published → identical to the published flow (no warning, no badge). A published version link is a stable snapshot. 4. If the returned version is published → resolver navigates directly to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge.
5. If unpublished → same warning-dialog gate as the preview flow; on confirm, navigates to `/iot-hub/{typeSegment(item.type)}` with `{ openItem: { version, preview: true } }`; dialog renders the "Unpublished preview" badge. 5. If the returned version is unpublished → resolver opens `TbIotHubUnpublishedWarningDialogComponent` (`disableClose: true`).
6. Install / Update / Remove / Open-entity actions behave as usual — all operate on the versionId already fetched, so install-from-version works end-to-end. - Cancel → `router.navigate(['/iot-hub'])`.
- "I understand the risk, continue" → navigates to `/iot-hub/{typeSegment(item.type)}` with `{ openItem: { version, preview: true } }`; detail dialog opens with the "Unpublished preview" badge.
6. `TbIotHubItemsPageComponent.ngOnInit` consumes `history.state.openItem`, resolves installed state, and calls `IotHubActionsService.openItemDetail(...)`.
7. Install / Update / Remove / Open-entity actions behave as usual — all operate on the versionId already fetched, so install-from-version works end-to-end.
8. On 404 or other error, resolver shows a toast and redirects to `/iot-hub`.
## Angular routing ## Angular routing
Three routes added to `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`. All must come **after** existing named child routes (`widgets`, `dashboards`, `solution-templates`, `calculated-fields`, `rule-chains`, `devices`, `search`, `installed`, `creator/:creatorId`). Among the three new routes, the literal `version/:itemVersionId` must come **before** the `:itemId` wildcards so it matches first: One route added to `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, placed after the existing named child routes (`widgets`, `dashboards`, `solution-templates`, `calculated-fields`, `rule-chains`, `devices`, `search`, `installed`, `creator/:creatorId`):
```ts ```ts
{ path: 'version/:itemVersionId', component: TbIotHubItemResolverComponent, { path: 'version/:itemVersionId', component: TbIotHubItemResolverComponent,
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail', byVersion: true } },
{ path: ':itemId', component: TbIotHubItemResolverComponent,
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } }, data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } },
{ path: ':itemId/preview', component: TbIotHubItemResolverComponent,
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-preview', preview: true } },
``` ```
A UUID-shape check runs inside the resolver (not as a `UrlMatcher`) so an invalid id — in either param — produces a friendly toast instead of a generic not-found page. A UUID-shape check runs inside the resolver (not as a `UrlMatcher`) so an invalid id produces a friendly toast instead of a generic not-found page.
## Components ## Components
@ -81,15 +58,13 @@ Location: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.compo
- Standalone: `false`. Declared in `IotHubModule`. - Standalone: `false`. Declared in `IotHubModule`.
- Template: empty (`template: ''`). The component renders nothing; it is a router-reachable controller. - Template: empty (`template: ''`). The component renders nothing; it is a router-reachable controller.
- `ngOnInit`: - `ngOnInit`:
1. Read route `data.byVersion` and `data.preview` flags. 1. Read `itemVersionId` from route params.
2. Read the relevant id — `itemVersionId` when `byVersion`, otherwise `itemId` — from route params. 2. Reject non-UUID id → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`.
3. Reject non-UUID id → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`. 3. Call `getVersionInfo(itemVersionId, { ignoreErrors: true })`.
4. Dispatch to `getVersionInfo` (byVersion), `getLatestVersion` (preview), or `getPublishedVersion` (default). 4. On error, map HTTP status to `iot-hub.deep-link-not-found` (404) or `iot-hub.deep-link-fetch-failed` (other) and redirect.
5. Compute `mayBeUnpublished = byVersion || preview` — any shape that can surface unpublished content. 5. On success, call `handleResolved(version)`:
6. On error, map HTTP status to `iot-hub.deep-link-not-found` (404) or `iot-hub.deep-link-fetch-failed` (other) and redirect. - If version is unpublished → open warning dialog; confirm routes to type-page with state (`preview: true`); cancel routes to `/iot-hub`.
7. On success, call `handleResolved(version, mayBeUnpublished)`: - Otherwise → route directly to type-page with state (`preview: false`).
- `mayBeUnpublished` + version unpublished → open warning dialog; confirm routes to type-page with state; cancel routes to `/iot-hub`.
- Otherwise → route directly to type-page with state.
- All navigations use `replaceUrl: true` so the resolver URL does not pollute browser history. - All navigations use `replaceUrl: true` so the resolver URL does not pollute browser history.
### `iot-hub-deep-link.utils.ts` (new) ### `iot-hub-deep-link.utils.ts` (new)
@ -199,36 +174,19 @@ private resolveInstalledItem(v: MpItemVersionView): Observable<IotHubInstalledIt
## API contract ## API contract
### `IotHubApiService` — new methods ### `IotHubApiService`
```ts No new methods. The resolver uses the existing `getVersionInfo(versionId, config)` which calls `GET /api/versions/{versionId}` on IoT Hub. The resolver passes `{ ignoreErrors: true }` so it can handle failures inline rather than surfacing the global interceptor toast.
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/published`,
{ params: this.buildParams(config) }
);
}
public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/latest`,
{ params: this.buildParams(config) }
);
}
```
Both accept `{ ignoreErrors: true }` so the resolver can handle failures inline rather than surfacing the global interceptor toast.
### ThingsBoard backend ### ThingsBoard backend
Unchanged. `IotHubController.installVersion` and `IotHubController.updateInstalledItem` already operate on versionIds; the preview flow funnels into them without modification. Unchanged. `IotHubController.installVersion` and `IotHubController.updateInstalledItem` already operate on versionIds; the version-link flow funnels into them without modification.
## i18n ## i18n
Add to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (and mirror into other locales): Add to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (and mirror into other locales):
- `iot-hub.item-detail` — "IoT Hub item" - `iot-hub.item-detail` — "IoT Hub item"
- `iot-hub.item-preview` — "IoT Hub item preview"
- `iot-hub.unpublished-warning-title` — "Unpublished content" - `iot-hub.unpublished-warning-title` — "Unpublished content"
- `iot-hub.unpublished-warning-text` — "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator." - `iot-hub.unpublished-warning-text` — "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator."
- `iot-hub.unpublished-warning-confirm` — "I understand the risk, continue" - `iot-hub.unpublished-warning-confirm` — "I understand the risk, continue"
@ -239,24 +197,22 @@ Add to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (and mirror into ot
## Edge cases ## Edge cases
- **Invalid UUID shape** (either `itemId` or `itemVersionId`) → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`. - **Invalid UUID shape** for `itemVersionId` → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`.
- **404 from IoT Hub** → `iot-hub.deep-link-not-found` toast + redirect. - **404 from IoT Hub** (version doesn't exist or was removed) → `iot-hub.deep-link-not-found` toast + redirect.
- **Network / 5xx error** → `iot-hub.deep-link-fetch-failed` toast + redirect. - **Network / 5xx error** → `iot-hub.deep-link-fetch-failed` toast + redirect.
- **Preview URL resolves to a published version** (no draft exists) → no warning, no badge. Behaves identically to the published URL.
- **Version URL resolves to a published version** → no warning, no badge. Serves as a stable snapshot link to that version. - **Version URL resolves to a published version** → no warning, no badge. Serves as a stable snapshot link to that version.
- **Unsupported `ItemType`** (future value not in `typeSegment`) → treated as `iot-hub.deep-link-fetch-failed`. - **Unsupported `ItemType`** (future value not in `typeSegment`) → treated as `iot-hub.deep-link-fetch-failed`.
- **User hits Browser Back from the warning dialog** → dialog destroys with the resolver component; no zombie dialog. - **User hits Browser Back from the warning dialog** → dialog destroys with the resolver component; no zombie dialog.
- **User lacks `TENANT_ADMIN`** → the `/iot-hub` parent route guard blocks; no additional guard needed. - **User lacks `TENANT_ADMIN`** → the `/iot-hub` parent route guard blocks; no additional guard needed.
- **Preview or version URL for an already-installed item** → detail dialog shows its usual "Installed / Update / Open entity" actions against the resolved versionId. Creators can test update and install-one-more flows end-to-end. - **Version URL for an already-installed item** → detail dialog shows its usual "Installed / Update / Open entity" actions against the resolved versionId. Creators can test update and install-one-more flows end-to-end.
- **Refresh after deep link has been resolved** → URL is now `/iot-hub/{typePage}`; `history.state.openItem` is cleared; user sees the type-page with no dialog (expected). - **Refresh after deep link has been resolved** → URL is now `/iot-hub/{typePage}`; `history.state.openItem` is cleared; user sees the type-page with no dialog (expected).
## Testing ## Testing
- `TbIotHubItemResolverComponent` unit tests with mocked `IotHubApiService` and `Router`: - `TbIotHubItemResolverComponent` unit tests with mocked `IotHubApiService` and `Router`:
- published happy path - published version happy path (no warning, no badge)
- preview happy path (unpublished → warning → confirm) - unpublished version happy path (warning → confirm → dialog with badge)
- preview warning cancel - unpublished version, warning cancel → redirect to `/iot-hub`
- preview resolves to published (no warning path)
- invalid UUID - invalid UUID
- 404 - 404
- 5xx / network error - 5xx / network error
@ -289,26 +245,13 @@ Modified:
## IoT Hub-side changes required ## IoT Hub-side changes required
These live in the IoT Hub repository, not ThingsBoard CE. The frontend deep-link feature cannot ship end-to-end until they land. These live in the IoT Hub repository, not ThingsBoard CE. No new endpoints are required — only behavior and CORS contracts on the existing `/api/versions/{versionId}` family.
The three URL shapes depend on three data-access patterns: "item → latest published", "item → latest regardless of state", and "version by id". The first two need new endpoints. The third relies on the *existing* `/api/versions/{versionId}` endpoint but requires that it serve unpublished versions when queried by id — that's a behavior contract, not a new endpoint. 1. **Behavior contract on existing `GET /api/versions/{versionId}`**: must return the requested version regardless of its state (PUBLISHED, DRAFT, PENDING_REVIEW, …). This powers the `/iot-hub/version/{itemVersionId}` deep link. Anonymous cross-origin; the versionId UUID itself is the soft-secret gate.
2. **`MpItemVersionView` response for unpublished versions** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`null` / `0`) for non-published versions, or an explicit `state` field must be added. Pick one; the frontend uses `isPublished(v)` based on `publishedTime` today.
1. **New endpoint** `GET /api/items/{itemId}/published` 3. **Related by-versionId endpoints must also serve unpublished versions** (required by the install flow proxied through TB):
- Returns `MpItemVersionView` for the latest version of the item that is in the PUBLISHED state.
- `404` when the item has no published version.
- Anonymous cross-origin access (same CORS policy as `/api/versions/published`).
2. **New endpoint** `GET /api/items/{itemId}/latest`
- Returns `MpItemVersionView` for the most relevant version regardless of state.
- Ordering: prefer the latest non-published (DRAFT / PENDING_REVIEW / …) version; fall back to the latest published version if none is in flight.
- `404` when the item has no versions at all.
- Anonymous cross-origin access.
- Soft-secret authorization model: the item UUID alone grants access.
3. **Behavior contract on existing `GET /api/versions/{versionId}`**: must return the requested version regardless of its state (PUBLISHED, DRAFT, PENDING_REVIEW, …). This powers the new `/iot-hub/version/{itemVersionId}` deep link. Anonymous cross-origin; the versionId UUID itself is the soft-secret gate.
4. **`MpItemVersionView` response for unpublished versions** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`null` / `0`) for non-published versions, or an explicit `state` field must be added. Pick one; the frontend uses `isPublished(v)` based on `publishedTime` today. **This applies to all three endpoints above** — the frontend decides whether to show the warning by inspecting the payload it received.
5. **By-versionId endpoints must all serve unpublished versions** when queried directly by ID (required by the version URL + the install flow proxied through TB):
- `GET /api/versions/{versionId}`
- `GET /api/versions/{versionId}/readme` - `GET /api/versions/{versionId}/readme`
- `GET /api/versions/{versionId}/fileData` - `GET /api/versions/{versionId}/fileData`
- `POST /api/versions/{versionId}/install` - `POST /api/versions/{versionId}/install`
6. **Install counter policy**: decide whether `POST /api/versions/{versionId}/install` against an unpublished version increments counters. Recommended: skip, to avoid inflating published install metrics with creator self-tests. Note that creators can now install an unpublished version via *either* the preview URL *or* the version URL — the counter policy should treat them identically. 4. **Install counter policy**: decide whether `POST /api/versions/{versionId}/install` against an unpublished version increments counters. Recommended: skip, to avoid inflating published install metrics with creator self-tests.
7. **CORS**: ensure `/api/items/{itemId}/published`, `/api/items/{itemId}/latest`, and the full `/api/versions/{versionId}/...` family permit cross-origin GET from any origin. 5. **CORS**: ensure the `/api/versions/{versionId}/...` family permits cross-origin GET from any origin (same policy as `/api/versions/published`).

14
ui-ngx/src/app/core/http/iot-hub-api.service.ts

@ -114,20 +114,6 @@ export class IotHubApiService {
); );
} }
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/published`,
{ params: this.buildParams(config) }
);
}
public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/latest`,
{ params: this.buildParams(config) }
);
}
public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable<string> { public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable<string> {
return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, { return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, {
params: this.buildParams(config), params: this.buildParams(config),

30
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts

@ -51,32 +51,15 @@ export class TbIotHubItemResolverComponent implements OnInit {
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
const data = this.route.snapshot.data; const itemVersionId = this.route.snapshot.paramMap.get('itemVersionId');
const byVersion = data['byVersion'] === true;
const preview = data['preview'] === true;
const id = byVersion if (!isUUID(itemVersionId)) {
? this.route.snapshot.paramMap.get('itemVersionId')
: this.route.snapshot.paramMap.get('itemId');
if (!isUUID(id)) {
this.failTo('iot-hub.deep-link-invalid-id'); this.failTo('iot-hub.deep-link-invalid-id');
return; return;
} }
let fetch$; this.iotHubApi.getVersionInfo(itemVersionId, { ignoreErrors: true }).subscribe({
if (byVersion) { next: v => this.handleResolved(v),
fetch$ = this.iotHubApi.getVersionInfo(id, { ignoreErrors: true });
} else if (preview) {
fetch$ = this.iotHubApi.getLatestVersion(id, { ignoreErrors: true });
} else {
fetch$ = this.iotHubApi.getPublishedVersion(id, { ignoreErrors: true });
}
const mayBeUnpublished = byVersion || preview;
fetch$.subscribe({
next: v => this.handleResolved(v, mayBeUnpublished),
error: err => { error: err => {
const key = err?.status === 404 const key = err?.status === 404
? 'iot-hub.deep-link-not-found' ? 'iot-hub.deep-link-not-found'
@ -86,7 +69,7 @@ export class TbIotHubItemResolverComponent implements OnInit {
}); });
} }
private handleResolved(version: MpItemVersionView, mayBeUnpublished: boolean): void { private handleResolved(version: MpItemVersionView): void {
const segment = typeSegment(version.type); const segment = typeSegment(version.type);
if (!segment) { if (!segment) {
this.failTo('iot-hub.deep-link-fetch-failed'); this.failTo('iot-hub.deep-link-fetch-failed');
@ -94,9 +77,8 @@ export class TbIotHubItemResolverComponent implements OnInit {
} }
const unpublished = !isPublished(version); const unpublished = !isPublished(version);
const showWarning = mayBeUnpublished && unpublished;
if (showWarning) { if (unpublished) {
this.dialog.open< this.dialog.open<
TbIotHubUnpublishedWarningDialogComponent, TbIotHubUnpublishedWarningDialogComponent,
IotHubUnpublishedWarningDialogData, IotHubUnpublishedWarningDialogData,

18
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts

@ -143,28 +143,10 @@ const routes: Routes = [
{ {
path: 'version/:itemVersionId', path: 'version/:itemVersionId',
component: TbIotHubItemResolverComponent, component: TbIotHubItemResolverComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-detail',
byVersion: true
}
},
{
path: ':itemId',
component: TbIotHubItemResolverComponent,
data: { data: {
auth: [Authority.TENANT_ADMIN], auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-detail' title: 'iot-hub.item-detail'
} }
},
{
path: ':itemId/preview',
component: TbIotHubItemResolverComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-preview',
preview: true
}
} }
] ]
} }

1
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3882,7 +3882,6 @@
"done": "Done", "done": "Done",
"in-progress": "In progress", "in-progress": "In progress",
"item-detail": "IoT Hub item", "item-detail": "IoT Hub item",
"item-preview": "IoT Hub item preview",
"unpublished-warning-title": "Unpublished content", "unpublished-warning-title": "Unpublished content",
"unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.", "unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.",
"unpublished-warning-confirm": "I understand the risk, continue", "unpublished-warning-confirm": "I understand the risk, continue",

Loading…
Cancel
Save