From 3fc067890a4d06ff3ec9b9d78e50dd088f5e34f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Jan 2026 22:09:22 +0000 Subject: [PATCH 1/3] feat: extend View As functionality to project home page - Modified AvatarButton.svelte to show 'View As' option on all project pages, not just dashboard pages (removed dashboard param requirement) - Updated navigation behavior to preserve 'View As' state when navigating within the same project, only clearing when switching projects - Alerts/Reports links in avatar menu now only show when on a dashboard page Resolves PM-103 Co-authored-by: eric.okuma --- .../authentication/AvatarButton.svelte | 24 ++++++++++--------- .../explore/[dashboard]/+page.svelte | 10 +++++++- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/web-admin/src/features/authentication/AvatarButton.svelte b/web-admin/src/features/authentication/AvatarButton.svelte index 9eb678d06e0..cb1b2f2b155 100644 --- a/web-admin/src/features/authentication/AvatarButton.svelte +++ b/web-admin/src/features/authentication/AvatarButton.svelte @@ -42,7 +42,7 @@ /> - {#if params.organization && params.project && params.dashboard} + {#if params.organization && params.project} - - Alerts - - - Reports - + {#if params.dashboard} + + Alerts + + + Reports + + {/if} {/if} diff --git a/web-admin/src/routes/[organization]/[project]/explore/[dashboard]/+page.svelte b/web-admin/src/routes/[organization]/[project]/explore/[dashboard]/+page.svelte index 07538000e80..5412a1e867e 100644 --- a/web-admin/src/routes/[organization]/[project]/explore/[dashboard]/+page.svelte +++ b/web-admin/src/routes/[organization]/[project]/explore/[dashboard]/+page.svelte @@ -82,7 +82,15 @@ ); onNavigate(({ from, to }) => { - viewAsUserStore.set(null); + // Only clear "View As" state when navigating outside of the current project + const changedProject = + !from || + !to || + from.params.organization !== to.params.organization || + from.params.project !== to.params.project; + if (changedProject) { + viewAsUserStore.set(null); + } errorStore.reset(); const changedDashboard = From b043ed8806078ef501f35abe6fd3d2ea82a735f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 18:45:37 +0000 Subject: [PATCH 2/3] feat: use server-side permissions for View As UI gating Use the existing GetProjectWithBearerToken pattern to fetch the impersonated user's projectPermissions from the server, rather than reconstructing permissions client-side. Changes: - Project layout calls GetProjectWithBearerToken with the mocked user's JWT (from GetDeploymentCredentials) to get their actual permissions - Created effectivePermissionsStore to share permissions with TopNavBar - ProjectTabs uses effectiveProjectPermissions (impersonated user's permissions when View As is active) - TopNavigationBar uses effective permissions for Share button visibility This follows the existing architecture where the server is the single source of truth for permissions, rather than duplicating permission logic on the client. Co-authored-by: ericokuma --- .../navigation/TopNavigationBar.svelte | 16 +++++++- .../view-as-user/effectivePermissionsStore.ts | 10 +++++ .../[organization]/[project]/+layout.svelte | 37 ++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 web-admin/src/features/view-as-user/effectivePermissionsStore.ts diff --git a/web-admin/src/features/navigation/TopNavigationBar.svelte b/web-admin/src/features/navigation/TopNavigationBar.svelte index 2ad06decdcc..2d321c23ab6 100644 --- a/web-admin/src/features/navigation/TopNavigationBar.svelte +++ b/web-admin/src/features/navigation/TopNavigationBar.svelte @@ -21,6 +21,7 @@ } from "../../client"; import ViewAsUserChip from "../../features/view-as-user/ViewAsUserChip.svelte"; import { viewAsUserStore } from "../../features/view-as-user/viewAsUserStore"; + import { effectiveProjectPermissionsStore } from "../../features/view-as-user/effectivePermissionsStore"; import CreateAlert from "../alerts/CreateAlert.svelte"; import { useAlerts } from "../alerts/selectors"; import AvatarButton from "../authentication/AvatarButton.svelte"; @@ -69,6 +70,15 @@ $: onPublicURLPage = isPublicURLPage($page); $: onOrgPage = isOrganizationPage($page); + // Use effective permissions when "View As" is active (from server) + // Otherwise fall back to the props passed from the root layout + $: effectiveManageProjectMembers = + $effectiveProjectPermissionsStore?.manageProjectMembers ?? + manageProjectMembers; + $: effectiveCreateMagicAuthTokens = + $effectiveProjectPermissionsStore?.createMagicAuthTokens ?? + createMagicAuthTokens; + $: loggedIn = !!$user.data?.user; $: rillLogoHref = !loggedIn ? "https://www.rilldata.com" : "/"; $: logoUrl = organizationLogoUrl; @@ -231,7 +241,7 @@ {/if} - {#if onProjectPage && manageProjectMembers} + {#if onProjectPage && effectiveManageProjectMembers} {/if} - + {/if} {/key} diff --git a/web-admin/src/features/view-as-user/effectivePermissionsStore.ts b/web-admin/src/features/view-as-user/effectivePermissionsStore.ts new file mode 100644 index 00000000000..fabb198b3b2 --- /dev/null +++ b/web-admin/src/features/view-as-user/effectivePermissionsStore.ts @@ -0,0 +1,10 @@ +import { writable } from "svelte/store"; +import type { V1ProjectPermissions } from "../../client"; + +/** + * Store for effective project permissions when "View As" is active. + * When null, the actual user's permissions should be used. + * When set, these are the impersonated user's permissions (from server). + */ +export const effectiveProjectPermissionsStore = + writable(null); diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index 13be2cdfa1a..94f7aefb824 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -48,6 +48,7 @@ import { createAdminServiceGetProjectWithBearerToken } from "@rilldata/web-admin/features/public-urls/get-project-with-bearer-token"; import { cloudVersion } from "@rilldata/web-admin/features/telemetry/initCloudMetrics"; import { viewAsUserStore } from "@rilldata/web-admin/features/view-as-user/viewAsUserStore"; + import { effectiveProjectPermissionsStore } from "@rilldata/web-admin/features/view-as-user/effectivePermissionsStore"; import ErrorPage from "@rilldata/web-common/components/ErrorPage.svelte"; import { metricsService } from "@rilldata/web-common/metrics/initMetrics"; import RuntimeProvider from "@rilldata/web-common/runtime-client/RuntimeProvider.svelte"; @@ -122,7 +123,41 @@ $: ({ data: mockedUserDeploymentCredentials } = $mockedUserDeploymentCredentialsQuery); + /** + * When "View As" is active, fetch the project using the mocked user's JWT. + * This returns the impersonated user's `projectPermissions` from the server. + */ + $: mockedUserProjectQuery = createAdminServiceGetProjectWithBearerToken( + organization, + project, + mockedUserDeploymentCredentials?.accessToken ?? "", + undefined, + { + query: { + enabled: !!mockedUserDeploymentCredentials?.accessToken, + }, + }, + ); + $: ({ data: projectData, error: projectError } = $projectQuery); + + /** + * Compute effective project permissions. + * When "View As" is active, use the impersonated user's permissions (from server). + * Otherwise, use the actual user's permissions. + */ + $: effectiveProjectPermissions = + mockedUserId && $mockedUserProjectQuery.data?.projectPermissions + ? $mockedUserProjectQuery.data.projectPermissions + : projectData?.projectPermissions; + + // Update the global store so TopNavigationBar can access effective permissions + $: effectiveProjectPermissionsStore.set( + mockedUserId && $mockedUserProjectQuery.data?.projectPermissions + ? $mockedUserProjectQuery.data.projectPermissions + : null, + ); + $: deploymentStatus = projectData?.deployment?.status; // A re-deploy triggers `DEPLOYMENT_STATUS_UPDATING` status. But we can still show the project UI. $: isProjectAvailable = @@ -168,7 +203,7 @@ {#if onProjectPage && deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING} Date: Fri, 20 Feb 2026 21:30:39 +0000 Subject: [PATCH 3/3] refactor: use TanStack Query dedup for View As permissions, persist to sessionStorage - Remove effectiveProjectPermissionsStore and use TanStack Query deduplication in TopNavigationBar instead (matching ProjectAccessControls pattern) - Move onNavigate guard from dashboard page to project layout to cover all project pages - Add onDestroy cleanup when unmounting project layout - Persist View As state to sessionStorage scoped to {org}/{project} for refresh survival without 'forgot I was impersonating' risk Co-authored-by: ericokuma --- .../navigation/TopNavigationBar.svelte | 35 +++++++- .../view-as-user/effectivePermissionsStore.ts | 10 --- .../features/view-as-user/viewAsUserStore.ts | 87 ++++++++++++++++++- .../[organization]/[project]/+layout.svelte | 32 +++++-- .../explore/[dashboard]/+page.svelte | 10 --- 5 files changed, 141 insertions(+), 33 deletions(-) delete mode 100644 web-admin/src/features/view-as-user/effectivePermissionsStore.ts diff --git a/web-admin/src/features/navigation/TopNavigationBar.svelte b/web-admin/src/features/navigation/TopNavigationBar.svelte index 2d321c23ab6..c5d44e601c1 100644 --- a/web-admin/src/features/navigation/TopNavigationBar.svelte +++ b/web-admin/src/features/navigation/TopNavigationBar.svelte @@ -4,6 +4,7 @@ import ExploreBookmarks from "@rilldata/web-admin/features/bookmarks/ExploreBookmarks.svelte"; import ShareDashboardPopover from "@rilldata/web-admin/features/dashboards/share/ShareDashboardPopover.svelte"; import ShareProjectPopover from "@rilldata/web-admin/features/projects/user-management/ShareProjectPopover.svelte"; + import { createAdminServiceGetProjectWithBearerToken } from "@rilldata/web-admin/features/public-urls/get-project-with-bearer-token"; import Rill from "@rilldata/web-common/components/icons/Rill.svelte"; import Breadcrumbs from "@rilldata/web-common/components/navigation/breadcrumbs/Breadcrumbs.svelte"; import type { PathOption } from "@rilldata/web-common/components/navigation/breadcrumbs/types"; @@ -15,13 +16,13 @@ import { runtime } from "@rilldata/web-common/runtime-client/runtime-store"; import { createAdminServiceGetCurrentUser, + createAdminServiceGetDeploymentCredentials, createAdminServiceListOrganizations as listOrgs, createAdminServiceListProjectsForOrganization as listProjects, type V1Organization, } from "../../client"; import ViewAsUserChip from "../../features/view-as-user/ViewAsUserChip.svelte"; import { viewAsUserStore } from "../../features/view-as-user/viewAsUserStore"; - import { effectiveProjectPermissionsStore } from "../../features/view-as-user/effectivePermissionsStore"; import CreateAlert from "../alerts/CreateAlert.svelte"; import { useAlerts } from "../alerts/selectors"; import AvatarButton from "../authentication/AvatarButton.svelte"; @@ -70,13 +71,41 @@ $: onPublicURLPage = isPublicURLPage($page); $: onOrgPage = isOrganizationPage($page); + // When "View As" is active, fetch deployment credentials for the mocked user. + // TanStack Query deduplicates by query key, so if the project layout already + // ran this query, we get instant cache hits with zero extra network calls. + $: mockedUserId = $viewAsUserStore?.id; + + $: mockedCredentialsQuery = createAdminServiceGetDeploymentCredentials( + organization, + project, + { userId: mockedUserId }, + { + query: { + enabled: !!mockedUserId && !!organization && !!project, + }, + }, + ); + + $: mockedProjectQuery = createAdminServiceGetProjectWithBearerToken( + organization, + project, + $mockedCredentialsQuery.data?.accessToken ?? "", + undefined, + { + query: { + enabled: !!$mockedCredentialsQuery.data?.accessToken, + }, + }, + ); + // Use effective permissions when "View As" is active (from server) // Otherwise fall back to the props passed from the root layout $: effectiveManageProjectMembers = - $effectiveProjectPermissionsStore?.manageProjectMembers ?? + $mockedProjectQuery.data?.projectPermissions?.manageProjectMembers ?? manageProjectMembers; $: effectiveCreateMagicAuthTokens = - $effectiveProjectPermissionsStore?.createMagicAuthTokens ?? + $mockedProjectQuery.data?.projectPermissions?.createMagicAuthTokens ?? createMagicAuthTokens; $: loggedIn = !!$user.data?.user; diff --git a/web-admin/src/features/view-as-user/effectivePermissionsStore.ts b/web-admin/src/features/view-as-user/effectivePermissionsStore.ts deleted file mode 100644 index fabb198b3b2..00000000000 --- a/web-admin/src/features/view-as-user/effectivePermissionsStore.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { writable } from "svelte/store"; -import type { V1ProjectPermissions } from "../../client"; - -/** - * Store for effective project permissions when "View As" is active. - * When null, the actual user's permissions should be used. - * When set, these are the impersonated user's permissions (from server). - */ -export const effectiveProjectPermissionsStore = - writable(null); diff --git a/web-admin/src/features/view-as-user/viewAsUserStore.ts b/web-admin/src/features/view-as-user/viewAsUserStore.ts index a2f9a0f0fa4..6763bcd0dd4 100644 --- a/web-admin/src/features/view-as-user/viewAsUserStore.ts +++ b/web-admin/src/features/view-as-user/viewAsUserStore.ts @@ -1,4 +1,87 @@ -import { writable } from "svelte/store"; +import { writable, get } from "svelte/store"; import type { V1User } from "../../client"; +import { browser } from "$app/environment"; -export const viewAsUserStore = writable(null); +const STORAGE_KEY_PREFIX = "rill:viewAsUser:"; + +function getStorageKey(org: string, project: string): string { + return `${STORAGE_KEY_PREFIX}${org}/${project}`; +} + +function createViewAsUserStore() { + const store = writable(null); + let currentScope: { org: string; project: string } | null = null; + + return { + subscribe: store.subscribe, + + /** + * Initialize the store for a specific project scope. + * Loads persisted state from sessionStorage if available. + */ + initForProject(org: string, project: string): void { + if (!browser) return; + + currentScope = { org, project }; + const key = getStorageKey(org, project); + + try { + const stored = sessionStorage.getItem(key); + if (stored) { + const user = JSON.parse(stored) as V1User; + store.set(user); + } else { + store.set(null); + } + } catch { + store.set(null); + } + }, + + /** + * Set the view-as user for the current project scope. + */ + set(user: V1User | null): void { + store.set(user); + + if (!browser || !currentScope) return; + + const key = getStorageKey(currentScope.org, currentScope.project); + try { + if (user) { + sessionStorage.setItem(key, JSON.stringify(user)); + } else { + sessionStorage.removeItem(key); + } + } catch { + // Ignore storage errors + } + }, + + /** + * Clear the view-as state (e.g., when navigating away from project). + */ + clear(): void { + store.set(null); + + if (!browser || !currentScope) return; + + const key = getStorageKey(currentScope.org, currentScope.project); + try { + sessionStorage.removeItem(key); + } catch { + // Ignore storage errors + } + currentScope = null; + }, + + /** + * Get current value synchronously. + */ + get(): V1User | null { + return get(store); + }, + }; +} + +export const viewAsUserStore = createViewAsUserStore(); diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index 94f7aefb824..c983cb6b533 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -27,6 +27,7 @@