@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.
- package/esm/components/useRedirectTarget.d.ts.map +1 -1
- package/esm/components/useRedirectTarget.js +2 -0
- package/esm/loader/lastVisitedWorkspace.d.ts +39 -0
- package/esm/loader/lastVisitedWorkspace.d.ts.map +1 -0
- package/esm/loader/lastVisitedWorkspace.js +91 -0
- package/esm/loader/redirectLogic.d.ts +7 -1
- package/esm/loader/redirectLogic.d.ts.map +1 -1
- package/esm/loader/redirectLogic.js +80 -5
- package/esm/loader/test/failingStorage.d.ts +13 -0
- package/esm/loader/test/failingStorage.d.ts.map +1 -0
- package/esm/loader/test/failingStorage.js +43 -0
- package/esm/platformContext/useWorkspacePermissions.d.ts.map +1 -1
- package/esm/platformContext/useWorkspacePermissions.js +33 -18
- package/esm/platformContext/workspaceAccess.d.ts +32 -0
- package/esm/platformContext/workspaceAccess.d.ts.map +1 -0
- package/esm/platformContext/workspaceAccess.js +33 -0
- package/package.json +17 -17
|
@@ -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;
|
|
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;
|
|
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 —
|
|
105
|
-
const legacyWorkspaceId = await
|
|
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 —
|
|
144
|
-
const resolvedWorkspaceId = await
|
|
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,
|
|
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 {
|
|
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 [
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
setPermissionsState({ state: "ready", permissions });
|
|
31
|
-
}
|
|
40
|
+
update({ state: "ready", permissions });
|
|
32
41
|
})
|
|
33
42
|
.catch((e) => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
52
|
+
update({ state: "error", error });
|
|
46
53
|
});
|
|
47
54
|
return () => {
|
|
48
55
|
cancelled = true;
|
|
49
56
|
};
|
|
50
57
|
}, [backend, workspaceId]);
|
|
51
|
-
|
|
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.
|
|
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.
|
|
33
|
-
"@gooddata/sdk-
|
|
34
|
-
"@gooddata/sdk-
|
|
35
|
-
"@gooddata/sdk-
|
|
36
|
-
"@gooddata/sdk-
|
|
37
|
-
"@gooddata/sdk-
|
|
38
|
-
"@gooddata/sdk-ui": "11.
|
|
39
|
-
"@gooddata/sdk-ui-application-header": "11.
|
|
40
|
-
"@gooddata/sdk-ui-ext": "11.
|
|
41
|
-
"@gooddata/sdk-ui-
|
|
42
|
-
"@gooddata/sdk-ui-semantic-search": "11.
|
|
43
|
-
"@gooddata/sdk-ui-
|
|
44
|
-
"@gooddata/sdk-ui-theme-provider": "11.
|
|
45
|
-
"@gooddata/util": "11.
|
|
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.
|
|
89
|
-
"@gooddata/oxlint-config": "11.
|
|
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",
|