@gooddata/sdk-ui-pluggable-host 11.50.0 → 11.51.0-alpha.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.
@@ -1 +1 @@
1
- {"version":3,"file":"useRedirectTarget.d.ts","sourceRoot":"","sources":["../../src/components/useRedirectTarget.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,gCAAgC,EAAE,MAAM,qBAAqB,CAAC;AAC5E,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAMlF,MAAM,MAAM,mBAAmB,GACzB;IAAE,KAAK,EAAE,SAAS,CAAA;CAAE,GACpB;IAAE,KAAK,EAAE,QAAQ,CAAA;CAAE,GACnB;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GACtB;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC;;GAEG;AACH,wBAAgB,iBAAiB,CAC7B,IAAI,EAAE,gCAAgC,EAAE,EACxC,GAAG,EAAE,gBAAgB,EACrB,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,MAAM,GAChB,mBAAmB,CA4DrB"}
1
+ {"version":3,"file":"useRedirectTarget.d.ts","sourceRoot":"","sources":["../../src/components/useRedirectTarget.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,gCAAgC,EAAE,MAAM,qBAAqB,CAAC;AAC5E,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAOlF,MAAM,MAAM,mBAAmB,GACzB;IAAE,KAAK,EAAE,SAAS,CAAA;CAAE,GACpB;IAAE,KAAK,EAAE,QAAQ,CAAA;CAAE,GACnB;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GACtB;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC;;GAEG;AACH,wBAAgB,iBAAiB,CAC7B,IAAI,EAAE,gCAAgC,EAAE,EACxC,GAAG,EAAE,gBAAgB,EACrB,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,MAAM,GAChB,mBAAmB,CA6DrB"}
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { debugLog } from "../debug.js";
4
4
  import { AppNotFoundError, resolveRedirectTarget } from "../loader/redirectLogic.js";
5
5
  import { getBackend } from "../platformContext/backend.js";
6
+ import { getWorkspaceAccess } from "../platformContext/workspaceAccess.js";
6
7
  /**
7
8
  * Resolves the redirect target for the current URL and permission context.
8
9
  */
@@ -26,6 +27,7 @@ export function useRedirectTarget(apps, ctx, pathname, search) {
26
27
  pathname,
27
28
  search,
28
29
  fetchFirstWorkspaceId,
30
+ getWorkspaceAccess: (workspaceId) => getWorkspaceAccess(getBackend(), workspaceId),
29
31
  })
30
32
  .then((url) => {
31
33
  if (cancelled) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Who a remembered workspace belongs to.
3
+ *
4
+ * Both parts are needed because localStorage belongs to the *origin*, not to the session or
5
+ * the backend:
6
+ * - every account signing in from the same browser profile shares the key, so one user must
7
+ * not be sent to another user's workspace just because both can access it;
8
+ * - one origin can be pointed at different backends — a local dev server switched between
9
+ * environments — where the same login exists but its workspaces do not.
10
+ */
11
+ export interface ILastVisitedWorkspaceOwner {
12
+ organizationId: string | undefined;
13
+ userId: string;
14
+ }
15
+ /**
16
+ * Returns the workspace the given owner visited last, or `undefined` when nothing usable is
17
+ * stored for them or the stored value cannot be read.
18
+ */
19
+ export declare function getLastVisitedWorkspace(owner: ILastVisitedWorkspaceOwner): string | undefined;
20
+ /**
21
+ * Persists the workspace the given owner is currently in, so that a later landing on a URL
22
+ * that carries no workspace ("/", "/workspace/", a bare legacy app path) can return to it
23
+ * instead of falling back to their first workspace.
24
+ * Silently swallows errors (e.g. storage full, private browsing).
25
+ */
26
+ export declare function setLastVisitedWorkspace(owner: ILastVisitedWorkspaceOwner, workspaceId: string): void;
27
+ /**
28
+ * Forgets the owner's stored workspace, but only while it is still `expectedWorkspaceId`,
29
+ * so the next landing does not keep retrying a workspace that turned out to be unusable
30
+ * (deleted, or access revoked).
31
+ *
32
+ * The compare matters because the decision to forget is made across an `await`: a newer
33
+ * navigation may have stored a different workspace in the meantime, and that one should
34
+ * survive. It narrows the window rather than closing it — another tab can still write between
35
+ * the read and the delete, and the cost of that rare interleaving is one lost preference,
36
+ * re-established on the next visit.
37
+ */
38
+ export declare function clearLastVisitedWorkspace(owner: ILastVisitedWorkspaceOwner, expectedWorkspaceId: string): void;
39
+ //# sourceMappingURL=lastVisitedWorkspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lastVisitedWorkspace.d.ts","sourceRoot":"","sources":["../../src/loader/lastVisitedWorkspace.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AACH,MAAM,WAAW,0BAA0B;IACvC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,MAAM,EAAE,MAAM,CAAC;CAClB;AAiDD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,0BAA0B,GAAG,MAAM,GAAG,SAAS,CAM7F;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,0BAA0B,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAQpG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CACrC,KAAK,EAAE,0BAA0B,EACjC,mBAAmB,EAAE,MAAM,GAC5B,IAAI,CAYN"}
@@ -0,0 +1,91 @@
1
+ // (C) 2026 GoodData Corporation
2
+ const STORAGE_KEY = "gdc-host-lastVisitedWorkspace";
3
+ function ownerKey({ organizationId, userId }) {
4
+ return `${organizationId ?? "-"}|${userId}`;
5
+ }
6
+ /**
7
+ * Parses the stored JSON into a record that is safe to read and to write through, discarding
8
+ * missing, corrupt or unexpectedly shaped values.
9
+ *
10
+ * Two properties are established here so every caller can rely on them:
11
+ * - the record has a **null prototype**, so an owner key ending in `__proto__` becomes an
12
+ * ordinary own key (on a plain object that assignment hits the legacy prototype setter and
13
+ * the entry is silently dropped by `JSON.stringify`) and keys like `constructor` or
14
+ * `toString` cannot pick up a function from `Object.prototype`;
15
+ * - every retained value is a **non-empty string**, so a hand-edited `{"user": 42}` can never
16
+ * reach `backend.workspace()` as a bogus id.
17
+ */
18
+ function safeParse(raw) {
19
+ const record = Object.create(null);
20
+ if (!raw) {
21
+ return record;
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(raw);
25
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
26
+ return record;
27
+ }
28
+ for (const [key, value] of Object.entries(parsed)) {
29
+ if (typeof value === "string" && value) {
30
+ record[key] = value;
31
+ }
32
+ }
33
+ }
34
+ catch {
35
+ // Intentionally ignored — a corrupt value is treated as "nothing remembered"
36
+ }
37
+ return record;
38
+ }
39
+ /**
40
+ * Returns the workspace the given owner visited last, or `undefined` when nothing usable is
41
+ * stored for them or the stored value cannot be read.
42
+ */
43
+ export function getLastVisitedWorkspace(owner) {
44
+ try {
45
+ return safeParse(localStorage.getItem(STORAGE_KEY))[ownerKey(owner)];
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Persists the workspace the given owner is currently in, so that a later landing on a URL
53
+ * that carries no workspace ("/", "/workspace/", a bare legacy app path) can return to it
54
+ * instead of falling back to their first workspace.
55
+ * Silently swallows errors (e.g. storage full, private browsing).
56
+ */
57
+ export function setLastVisitedWorkspace(owner, workspaceId) {
58
+ try {
59
+ const record = safeParse(localStorage.getItem(STORAGE_KEY));
60
+ record[ownerKey(owner)] = workspaceId;
61
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
62
+ }
63
+ catch {
64
+ // Intentionally ignored — localStorage may be unavailable or full
65
+ }
66
+ }
67
+ /**
68
+ * Forgets the owner's stored workspace, but only while it is still `expectedWorkspaceId`,
69
+ * so the next landing does not keep retrying a workspace that turned out to be unusable
70
+ * (deleted, or access revoked).
71
+ *
72
+ * The compare matters because the decision to forget is made across an `await`: a newer
73
+ * navigation may have stored a different workspace in the meantime, and that one should
74
+ * survive. It narrows the window rather than closing it — another tab can still write between
75
+ * the read and the delete, and the cost of that rare interleaving is one lost preference,
76
+ * re-established on the next visit.
77
+ */
78
+ export function clearLastVisitedWorkspace(owner, expectedWorkspaceId) {
79
+ try {
80
+ const record = safeParse(localStorage.getItem(STORAGE_KEY));
81
+ const key = ownerKey(owner);
82
+ if (record[key] !== expectedWorkspaceId) {
83
+ return;
84
+ }
85
+ delete record[key];
86
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
87
+ }
88
+ catch {
89
+ // Intentionally ignored — localStorage may be unavailable
90
+ }
91
+ }
@@ -1,5 +1,6 @@
1
1
  import { type PluggableApplicationRegistryItem } from "@gooddata/sdk-model";
2
2
  import { type IPlatformContext } from "@gooddata/sdk-pluggable-application-model";
3
+ import { type WorkspaceAccess } from "../platformContext/workspaceAccess.js";
3
4
  /**
4
5
  * Thrown when the current URL does not correspond to any accessible application.
5
6
  */
@@ -18,6 +19,11 @@ export interface IResolveRedirectTargetOptions {
18
19
  search?: string;
19
20
  /** Fetches the first workspace ID for the current user. */
20
21
  fetchFirstWorkspaceId: () => Promise<string | undefined>;
22
+ /**
23
+ * Resolves whether the given workspace can still be opened by the current user.
24
+ * Used to validate the remembered workspace before redirecting into it.
25
+ */
26
+ getWorkspaceAccess: (workspaceId: string) => Promise<WorkspaceAccess>;
21
27
  }
22
28
  /**
23
29
  * Resolves where the shell app should navigate given the current URL and permission context.
@@ -29,5 +35,5 @@ export interface IResolveRedirectTargetOptions {
29
35
  * @throws {AppNotFoundError} The current path maps to no accessible application (show 404)
30
36
  * @throws {Error} Unexpected failure (show generic error screen)
31
37
  */
32
- export declare function resolveRedirectTarget({ apps, ctx, pathname, search, fetchFirstWorkspaceId }: IResolveRedirectTargetOptions): Promise<string | null>;
38
+ export declare function resolveRedirectTarget({ apps, ctx, pathname, search, fetchFirstWorkspaceId, getWorkspaceAccess }: IResolveRedirectTargetOptions): Promise<string | null>;
33
39
  //# sourceMappingURL=redirectLogic.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"redirectLogic.d.ts","sourceRoot":"","sources":["../../src/loader/redirectLogic.ts"],"names":[],"mappings":"AAEA,OAAO,EAAyB,KAAK,gCAAgC,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAQlF;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IACvC,YAAY,OAAO,EAAE,MAAM,EAG1B;CACJ;AAED,MAAM,WAAW,6BAA6B;IAC1C;;;OAGG;IACH,IAAI,EAAE,gCAAgC,EAAE,CAAC;IACzC,GAAG,EAAE,gBAAgB,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,qBAAqB,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAC5D;AAqFD;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CAAC,EACxC,IAAI,EACJ,GAAG,EACH,QAAQ,EACR,MAAM,EACN,qBAAqB,EACxB,EAAE,6BAA6B,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAwFxD"}
1
+ {"version":3,"file":"redirectLogic.d.ts","sourceRoot":"","sources":["../../src/loader/redirectLogic.ts"],"names":[],"mappings":"AAEA,OAAO,EAAyB,KAAK,gCAAgC,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAGlF,OAAO,EAAE,KAAK,eAAe,EAAE,MAAM,uCAAuC,CAAC;AAY7E;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IACvC,YAAY,OAAO,EAAE,MAAM,EAG1B;CACJ;AAED,MAAM,WAAW,6BAA6B;IAC1C;;;OAGG;IACH,IAAI,EAAE,gCAAgC,EAAE,CAAC;IACzC,GAAG,EAAE,gBAAgB,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,qBAAqB,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACzD;;;OAGG;IACH,kBAAkB,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;CACzE;AAkJD;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CAAC,EACxC,IAAI,EACJ,GAAG,EACH,QAAQ,EACR,MAAM,EACN,qBAAqB,EACrB,kBAAkB,EACrB,EAAE,6BAA6B,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAsHxD"}
@@ -1,6 +1,7 @@
1
1
  // (C) 2026 GoodData Corporation
2
2
  import { debugLog } from "../debug.js";
3
3
  import { getLastVisitedApp, setLastVisitedApp } from "./lastVisitedApp.js";
4
+ import { clearLastVisitedWorkspace, getLastVisitedWorkspace, setLastVisitedWorkspace, } from "./lastVisitedWorkspace.js";
4
5
  import { mapBareLegacyPathToApp } from "./legacyRedirect.js";
5
6
  import { getActiveInternalApplication, getApplicationHref } from "./routing.js";
6
7
  /**
@@ -40,6 +41,50 @@ function isAtScopeRoot(pathname, scope, workspaceId) {
40
41
  const _ = scope;
41
42
  throw new Error(`[host-runtime/redirectLogic] Unhandled application scope: ${_}`);
42
43
  }
44
+ /**
45
+ * Resolves the workspace to land in when the URL carries none: the workspace this user
46
+ * visited last, falling back to the first workspace they can see.
47
+ *
48
+ * The remembered workspace is validated before it is used — it may have been deleted or
49
+ * the user's access to it revoked since it was stored. Only a workspace the user provably
50
+ * cannot open is forgotten; a failed check ("unknown") keeps the memory, because losing the
51
+ * user's workspace over a network blip is worse than letting the workspace route retry the
52
+ * same request and report the real error.
53
+ */
54
+ async function resolveLandingWorkspaceId(owner, { fetchFirstWorkspaceId, getWorkspaceAccess, }) {
55
+ const rememberedWorkspaceId = getLastVisitedWorkspace(owner);
56
+ if (rememberedWorkspaceId) {
57
+ const access = await getWorkspaceAccess(rememberedWorkspaceId);
58
+ if (access === "forbidden") {
59
+ debugLog(`[host-runtime/redirect] last visited workspace ${rememberedWorkspaceId} is not accessible — forgetting it`);
60
+ clearLastVisitedWorkspace(owner, rememberedWorkspaceId);
61
+ }
62
+ else {
63
+ debugLog(`[host-runtime/redirect] using last visited workspace → ${rememberedWorkspaceId} (access=${access})`);
64
+ return rememberedWorkspaceId;
65
+ }
66
+ }
67
+ debugLog("[host-runtime/redirect] fetching first workspace");
68
+ return fetchFirstWorkspaceId();
69
+ }
70
+ /**
71
+ * Remembers the workspace the user is in — reached by link, route change or the header
72
+ * workspace picker — so a later landing on a workspace-less URL returns here instead of the
73
+ * user's first workspace.
74
+ *
75
+ * Called only from the paths that resolved to an app the user may open, never from the
76
+ * not-found paths: a workspace whose URL maps to no app, or that grants the user no app at
77
+ * all, would otherwise stay remembered and send every later workspace-less landing back to
78
+ * the same 404 — which has no header, so there is no workspace picker to escape with.
79
+ *
80
+ * Also gated on loaded permissions: an inaccessible workspace reaches this point with
81
+ * `workspacePermissions` undefined (useWorkspacePermissions reports "forbidden" for 403/404).
82
+ */
83
+ function rememberWorkspace(owner, workspaceId, ctx) {
84
+ if (workspaceId && ctx.workspacePermissions) {
85
+ setLastVisitedWorkspace(owner, workspaceId);
86
+ }
87
+ }
43
88
  /**
44
89
  * Returns the last-visited app for the given scope if it is in the eligible list,
45
90
  * otherwise falls back to the first app.
@@ -90,9 +135,15 @@ function resolveOrgScopeTarget(apps, ctx, pathname) {
90
135
  * @throws {AppNotFoundError} The current path maps to no accessible application (show 404)
91
136
  * @throws {Error} Unexpected failure (show generic error screen)
92
137
  */
93
- export async function resolveRedirectTarget({ apps, ctx, pathname, search, fetchFirstWorkspaceId, }) {
138
+ export async function resolveRedirectTarget({ apps, ctx, pathname, search, fetchFirstWorkspaceId, getWorkspaceAccess, }) {
94
139
  const scope = ctx.currentApplicationScope;
95
140
  const workspaceId = ctx.currentWorkspaceId;
141
+ // The user id is the same identifier the host uses elsewhere (e.g. for the workspace
142
+ // picker); the organization keeps entries apart when one origin serves several backends
143
+ const owner = {
144
+ organizationId: ctx.organization?.id,
145
+ userId: ctx.user.login,
146
+ };
96
147
  debugLog(`[host-runtime/redirect] resolveRedirectTarget: scope=${scope ?? "(none)"} workspaceId=${workspaceId ?? "(none)"} pathname=${pathname} apps=${apps.length}`);
97
148
  // Bare legacy app landing (/dashboards, /analyze, /metrics, /modeler — no workspace in the
98
149
  // hash; hash-bearing legacy URLs are rewritten synchronously before the host boots). The
@@ -101,8 +152,11 @@ export async function resolveRedirectTarget({ apps, ctx, pathname, search, fetch
101
152
  // (which would land on /organization or the preferred workspace app).
102
153
  const legacyAppRouteBase = mapBareLegacyPathToApp(pathname);
103
154
  if (legacyAppRouteBase) {
104
- debugLog(`[host-runtime/redirect] bare legacy app path — fetching first workspace`);
105
- const legacyWorkspaceId = await fetchFirstWorkspaceId();
155
+ debugLog(`[host-runtime/redirect] bare legacy app path — resolving landing workspace`);
156
+ const legacyWorkspaceId = await resolveLandingWorkspaceId(owner, {
157
+ fetchFirstWorkspaceId,
158
+ getWorkspaceAccess,
159
+ });
106
160
  if (!legacyWorkspaceId) {
107
161
  debugLog("[host-runtime/redirect] no workspace available for user — throwing not-found");
108
162
  throw new AppNotFoundError("No workspace is available for this user.");
@@ -121,10 +175,21 @@ export async function resolveRedirectTarget({ apps, ctx, pathname, search, fetch
121
175
  const active = getActiveInternalApplication(apps, ctx, pathname);
122
176
  if (!active) {
123
177
  debugLog(`[host-runtime/redirect] workspace scope: no app matched pathname → ${pathname} — throwing not-found`);
178
+ // This URL can be one the host built itself: a bare legacy landing (/analyze) is
179
+ // redirected into the remembered workspace's app path, and if that app is not
180
+ // permitted there it lands exactly here. Leaving the memory intact would send every
181
+ // later bare-legacy visit back to this same 404, which has no header and therefore no
182
+ // workspace picker to escape with. Forgetting costs one preference update — the next
183
+ // successful app navigation records it again — so it is the cheaper side to err on
184
+ // even for a merely mistyped path.
185
+ if (workspaceId) {
186
+ clearLastVisitedWorkspace(owner, workspaceId);
187
+ }
124
188
  throw new AppNotFoundError(`No application found at path: ${pathname}`);
125
189
  }
126
190
  debugLog(`[host-runtime/redirect] workspace scope: active app matched → ${active.id}`);
127
191
  setLastVisitedApp("workspace", active.id);
192
+ rememberWorkspace(owner, workspaceId, ctx);
128
193
  return null;
129
194
  }
130
195
  // At a workspace root (with or without workspace ID) or the top-level app root.
@@ -140,8 +205,11 @@ export async function resolveRedirectTarget({ apps, ctx, pathname, search, fetch
140
205
  }
141
206
  // Hop 1: resolve a workspace ID and redirect to its root so that the next render cycle
142
207
  // can load workspace permissions and filter apps accurately.
143
- debugLog("[host-runtime/redirect] no workspace ID — fetching first workspace");
144
- const resolvedWorkspaceId = await fetchFirstWorkspaceId();
208
+ debugLog("[host-runtime/redirect] no workspace ID — resolving landing workspace");
209
+ const resolvedWorkspaceId = await resolveLandingWorkspaceId(owner, {
210
+ fetchFirstWorkspaceId,
211
+ getWorkspaceAccess,
212
+ });
145
213
  if (!resolvedWorkspaceId) {
146
214
  debugLog("[host-runtime/redirect] no workspace available for user — throwing not-found");
147
215
  throw new AppNotFoundError("No workspace is available for this user.");
@@ -155,9 +223,16 @@ export async function resolveRedirectTarget({ apps, ctx, pathname, search, fetch
155
223
  const targetApp = preferLastVisitedApp(apps, "workspace");
156
224
  if (!targetApp) {
157
225
  debugLog("[host-runtime/redirect] no permitted workspace apps — throwing not-found");
226
+ // A workspace that grants this user no app at all must not stay remembered. It can have
227
+ // had apps when it was stored and lost them since (an app permission or feature flag
228
+ // changed), and the access check cannot see that: the permissions endpoint still
229
+ // succeeds. Without this, every later workspace-less landing would be sent straight back
230
+ // to this 404, which has no header and therefore no workspace picker to escape with.
231
+ clearLastVisitedWorkspace(owner, workspaceId);
158
232
  throw new AppNotFoundError("No workspace-scoped applications are available for this workspace.");
159
233
  }
160
234
  const href = getApplicationHref(targetApp, ctx, pathname);
161
235
  debugLog(`[host-runtime/redirect] redirecting to preferred workspace app → ${href} (app: ${targetApp.id})`);
236
+ rememberWorkspace(owner, workspaceId, ctx);
162
237
  return href;
163
238
  }
@@ -0,0 +1,13 @@
1
+ type StorageMethod = "getItem" | "setItem" | "removeItem";
2
+ /**
3
+ * Runs `run` against a `localStorage` whose `method` throws, and returns how many times that
4
+ * throwing method was actually reached — so a test can prove it exercised the failure path
5
+ * instead of passing vacuously.
6
+ *
7
+ * The global is swapped out rather than spied on: under happy-dom `localStorage` is a proxy
8
+ * whose methods do not come from `Storage.prototype` (a prototype spy is never called) and
9
+ * whose instance spies survive `vi.restoreAllMocks()`, leaking into later tests.
10
+ */
11
+ export declare function withFailingStorage(method: StorageMethod, run: () => void, seed?: Record<string, string>): number;
12
+ export {};
13
+ //# sourceMappingURL=failingStorage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"failingStorage.d.ts","sourceRoot":"","sources":["../../../src/loader/test/failingStorage.ts"],"names":[],"mappings":"AAEA,KAAK,aAAa,GAAG,SAAS,GAAG,SAAS,GAAG,YAAY,CAAC;AAE1D;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAC9B,MAAM,EAAE,aAAa,EACrB,GAAG,EAAE,MAAM,IAAI,EACf,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiCR"}
@@ -0,0 +1,43 @@
1
+ // (C) 2026 GoodData Corporation
2
+ /**
3
+ * Runs `run` against a `localStorage` whose `method` throws, and returns how many times that
4
+ * throwing method was actually reached — so a test can prove it exercised the failure path
5
+ * instead of passing vacuously.
6
+ *
7
+ * The global is swapped out rather than spied on: under happy-dom `localStorage` is a proxy
8
+ * whose methods do not come from `Storage.prototype` (a prototype spy is never called) and
9
+ * whose instance spies survive `vi.restoreAllMocks()`, leaking into later tests.
10
+ */
11
+ export function withFailingStorage(method, run, seed = {}) {
12
+ // Seeded directly, so a test can start from stored state even when `setItem` is the method
13
+ // that throws
14
+ const store = new Map(Object.entries(seed));
15
+ let failedCalls = 0;
16
+ const fake = {
17
+ getItem: (key) => store.get(key) ?? null,
18
+ setItem: (key, value) => {
19
+ store.set(key, value);
20
+ },
21
+ removeItem: (key) => {
22
+ store.delete(key);
23
+ },
24
+ };
25
+ fake[method] = () => {
26
+ failedCalls++;
27
+ throw new Error(`localStorage.${method} is unavailable`);
28
+ };
29
+ const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
30
+ Object.defineProperty(globalThis, "localStorage", { value: fake, configurable: true });
31
+ try {
32
+ run();
33
+ }
34
+ finally {
35
+ if (original) {
36
+ Object.defineProperty(globalThis, "localStorage", original);
37
+ }
38
+ else {
39
+ Reflect.deleteProperty(globalThis, "localStorage");
40
+ }
41
+ }
42
+ return failedCalls;
43
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"useWorkspacePermissions.d.ts","sourceRoot":"","sources":["../../src/platformContext/useWorkspacePermissions.ts"],"names":[],"mappings":"AAIA,OAAO,EAA2B,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC7F,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,KAAK,yBAAyB,GACxB;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GACjB;IAAE,KAAK,EAAE,SAAS,CAAA;CAAE,GACpB;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,qBAAqB,CAAA;CAAE,GACtD;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GACtB;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACnC,OAAO,EAAE,kBAAkB,GAAG,SAAS,EACvC,WAAW,EAAE,MAAM,GAAG,SAAS,GAChC,yBAAyB,CA2C3B"}
1
+ {"version":3,"file":"useWorkspacePermissions.d.ts","sourceRoot":"","sources":["../../src/platformContext/useWorkspacePermissions.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAIjE,KAAK,yBAAyB,GACxB;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GACjB;IAAE,KAAK,EAAE,SAAS,CAAA;CAAE,GACpB;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,qBAAqB,CAAA;CAAE,GACtD;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GACtB;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAYxC;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACnC,OAAO,EAAE,kBAAkB,GAAG,SAAS,EACvC,WAAW,EAAE,MAAM,GAAG,SAAS,GAChC,yBAAyB,CA6D3B"}
@@ -1,6 +1,8 @@
1
1
  // (C) 2026 GoodData Corporation
2
2
  import { useEffect, useState } from "react";
3
- import { UnexpectedResponseError } from "@gooddata/sdk-backend-spi";
3
+ import { classifyWorkspaceAccessError } from "./workspaceAccess.js";
4
+ const IDLE = { state: "idle" };
5
+ const LOADING = { state: "loading" };
4
6
  /**
5
7
  * Loads workspace permissions for the current user.
6
8
  *
@@ -11,42 +13,55 @@ import { UnexpectedResponseError } from "@gooddata/sdk-backend-spi";
11
13
  * @internal
12
14
  */
13
15
  export function useWorkspacePermissions(backend, workspaceId) {
14
- const [permissionsState, setPermissionsState] = useState({
15
- state: "idle",
16
+ const [snapshot, setSnapshot] = useState({
17
+ backend: undefined,
18
+ workspaceId: undefined,
19
+ state: IDLE,
16
20
  });
17
21
  useEffect(() => {
18
22
  if (!backend || !workspaceId) {
19
- setPermissionsState((prev) => (prev.state === "idle" ? prev : { state: "idle" }));
23
+ setSnapshot((prev) => prev.state === IDLE && prev.backend === undefined && prev.workspaceId === undefined
24
+ ? prev
25
+ : { backend: undefined, workspaceId: undefined, state: IDLE });
20
26
  return;
21
27
  }
22
28
  let cancelled = false;
23
- setPermissionsState({ state: "loading" });
29
+ setSnapshot({ backend, workspaceId, state: LOADING });
30
+ const update = (state) => {
31
+ if (!cancelled) {
32
+ setSnapshot({ backend, workspaceId, state });
33
+ }
34
+ };
24
35
  backend
25
36
  .workspace(workspaceId)
26
37
  .permissions()
27
38
  .getPermissionsForCurrentUser()
28
39
  .then((permissions) => {
29
- if (!cancelled) {
30
- setPermissionsState({ state: "ready", permissions });
31
- }
40
+ update({ state: "ready", permissions });
32
41
  })
33
42
  .catch((e) => {
34
- if (cancelled)
35
- return;
36
- // 403/404 on the workspace permissions endpoint means no access — Tiger uses
37
- // 404 to avoid leaking workspace existence (same message as 403). Signal
38
- // "forbidden" so the platform context reaches "ready" with undefined permissions
39
- // and the mounted app can render its own access-denied UI.
40
- if (e instanceof UnexpectedResponseError && (e.httpStatus === 403 || e.httpStatus === 404)) {
41
- setPermissionsState({ state: "forbidden" });
43
+ // No access signals "forbidden" rather than an error, so the platform context
44
+ // reaches "ready" with undefined permissions and the mounted app can render its
45
+ // own access-denied UI. The 403/404 rule itself lives in workspaceAccess.ts,
46
+ // shared with the redirect resolver.
47
+ if (classifyWorkspaceAccessError(e) === "forbidden") {
48
+ update({ state: "forbidden" });
42
49
  return;
43
50
  }
44
51
  const error = e instanceof Error ? e.message : "Unknown error loading workspace permissions.";
45
- setPermissionsState({ state: "error", error });
52
+ update({ state: "error", error });
46
53
  });
47
54
  return () => {
48
55
  cancelled = true;
49
56
  };
50
57
  }, [backend, workspaceId]);
51
- return permissionsState;
58
+ if (!backend || !workspaceId) {
59
+ return IDLE;
60
+ }
61
+ // A backend or workspaceId change is reported as "loading" from the very first render: the
62
+ // fetch effect runs a beat later, and until it does the stored state still describes the
63
+ // PREVIOUS backend/workspace. Callers gate on this state to decide what the current
64
+ // workspace allows, so handing them permissions from another workspace — or from a
65
+ // previous session's backend — even for one render is a correctness bug.
66
+ return snapshot.backend === backend && snapshot.workspaceId === workspaceId ? snapshot.state : LOADING;
52
67
  }
@@ -0,0 +1,32 @@
1
+ import { type IAnalyticalBackend } from "@gooddata/sdk-backend-spi";
2
+ /**
3
+ * Outcome of checking whether a workspace can be opened by the current user.
4
+ *
5
+ * `unknown` is deliberately distinct from `forbidden`: the check itself failing (offline,
6
+ * 5xx) says nothing about the workspace, so it must not be treated as "you cannot open it".
7
+ *
8
+ * @internal
9
+ */
10
+ export type WorkspaceAccess = "accessible" | "forbidden" | "unknown";
11
+ /**
12
+ * Classifies a failed workspace-permissions request.
13
+ *
14
+ * 403 and 404 both mean no access — Tiger answers 404, with the same message as 403, to avoid
15
+ * leaking whether a workspace exists. Everything else is inconclusive.
16
+ *
17
+ * The single owner of this rule: both the platform context (which gates workspace routes on
18
+ * it) and the redirect resolver (which decides whether to keep a remembered workspace) must
19
+ * agree, or the host can gate on "forbidden" while the resolver keeps sending the user back.
20
+ *
21
+ * @internal
22
+ */
23
+ export declare function classifyWorkspaceAccessError(e: unknown): "forbidden" | "unknown";
24
+ /**
25
+ * Resolves whether the given workspace can be opened by the current user, using the very
26
+ * request the platform context gates workspace routes on — so a workspace the host redirects
27
+ * into is one it can actually load.
28
+ *
29
+ * @internal
30
+ */
31
+ export declare function getWorkspaceAccess(backend: IAnalyticalBackend, workspaceId: string): Promise<WorkspaceAccess>;
32
+ //# sourceMappingURL=workspaceAccess.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspaceAccess.d.ts","sourceRoot":"","sources":["../../src/platformContext/workspaceAccess.ts"],"names":[],"mappings":"AAEA,OAAO,EAA2B,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE7F;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,WAAW,GAAG,SAAS,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,4BAA4B,CAAC,CAAC,EAAE,OAAO,GAAG,WAAW,GAAG,SAAS,CAIhF;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAC9B,OAAO,EAAE,kBAAkB,EAC3B,WAAW,EAAE,MAAM,GACpB,OAAO,CAAC,eAAe,CAAC,CAM1B"}
@@ -0,0 +1,33 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { UnexpectedResponseError } from "@gooddata/sdk-backend-spi";
3
+ /**
4
+ * Classifies a failed workspace-permissions request.
5
+ *
6
+ * 403 and 404 both mean no access — Tiger answers 404, with the same message as 403, to avoid
7
+ * leaking whether a workspace exists. Everything else is inconclusive.
8
+ *
9
+ * The single owner of this rule: both the platform context (which gates workspace routes on
10
+ * it) and the redirect resolver (which decides whether to keep a remembered workspace) must
11
+ * agree, or the host can gate on "forbidden" while the resolver keeps sending the user back.
12
+ *
13
+ * @internal
14
+ */
15
+ export function classifyWorkspaceAccessError(e) {
16
+ return e instanceof UnexpectedResponseError && (e.httpStatus === 403 || e.httpStatus === 404)
17
+ ? "forbidden"
18
+ : "unknown";
19
+ }
20
+ /**
21
+ * Resolves whether the given workspace can be opened by the current user, using the very
22
+ * request the platform context gates workspace routes on — so a workspace the host redirects
23
+ * into is one it can actually load.
24
+ *
25
+ * @internal
26
+ */
27
+ export function getWorkspaceAccess(backend, workspaceId) {
28
+ return backend
29
+ .workspace(workspaceId)
30
+ .permissions()
31
+ .getPermissionsForCurrentUser()
32
+ .then(() => "accessible", classifyWorkspaceAccessError);
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gooddata/sdk-ui-pluggable-host",
3
- "version": "11.50.0",
3
+ "version": "11.51.0-alpha.1",
4
4
  "description": "GoodData SDK runtime for hosting pluggable applications — registry, loader, routing, platform context, default UI chrome",
5
5
  "license": "MIT",
6
6
  "author": "GoodData Corporation",
@@ -29,20 +29,20 @@
29
29
  "dependencies": {
30
30
  "@module-federation/runtime": "2.6.0",
31
31
  "lodash-es": "^4.17.23",
32
- "@gooddata/sdk-backend-base": "11.50.0",
33
- "@gooddata/sdk-embedding": "11.50.0",
34
- "@gooddata/sdk-model": "11.50.0",
35
- "@gooddata/sdk-backend-tiger": "11.50.0",
36
- "@gooddata/sdk-pluggable-application-model": "11.50.0",
37
- "@gooddata/sdk-backend-spi": "11.50.0",
38
- "@gooddata/sdk-ui": "11.50.0",
39
- "@gooddata/sdk-ui-application-header": "11.50.0",
40
- "@gooddata/sdk-ui-ext": "11.50.0",
41
- "@gooddata/sdk-ui-kit": "11.50.0",
42
- "@gooddata/sdk-ui-semantic-search": "11.50.0",
43
- "@gooddata/sdk-ui-gen-ai": "11.50.0",
44
- "@gooddata/sdk-ui-theme-provider": "11.50.0",
45
- "@gooddata/util": "11.50.0"
32
+ "@gooddata/sdk-backend-base": "11.51.0-alpha.1",
33
+ "@gooddata/sdk-backend-spi": "11.51.0-alpha.1",
34
+ "@gooddata/sdk-backend-tiger": "11.51.0-alpha.1",
35
+ "@gooddata/sdk-embedding": "11.51.0-alpha.1",
36
+ "@gooddata/sdk-model": "11.51.0-alpha.1",
37
+ "@gooddata/sdk-pluggable-application-model": "11.51.0-alpha.1",
38
+ "@gooddata/sdk-ui": "11.51.0-alpha.1",
39
+ "@gooddata/sdk-ui-application-header": "11.51.0-alpha.1",
40
+ "@gooddata/sdk-ui-ext": "11.51.0-alpha.1",
41
+ "@gooddata/sdk-ui-gen-ai": "11.51.0-alpha.1",
42
+ "@gooddata/sdk-ui-semantic-search": "11.51.0-alpha.1",
43
+ "@gooddata/sdk-ui-kit": "11.51.0-alpha.1",
44
+ "@gooddata/sdk-ui-theme-provider": "11.51.0-alpha.1",
45
+ "@gooddata/util": "11.51.0-alpha.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@microsoft/api-documenter": "^7.17.0",
@@ -85,8 +85,8 @@
85
85
  "vite": "8.0.16",
86
86
  "vitest": "4.1.8",
87
87
  "vitest-dom": "0.1.1",
88
- "@gooddata/eslint-config": "11.50.0",
89
- "@gooddata/oxlint-config": "11.50.0"
88
+ "@gooddata/eslint-config": "11.51.0-alpha.1",
89
+ "@gooddata/oxlint-config": "11.51.0-alpha.1"
90
90
  },
91
91
  "peerDependencies": {
92
92
  "react": ">=18.3.1",