Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions web-admin/src/features/authentication/AvatarButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{#if params.organization && params.project && params.dashboard}
{#if params.organization && params.project}
<ProjectAccessControls
organization={params.organization}
project={params.project}
Expand Down Expand Up @@ -71,16 +71,18 @@
</DropdownMenu.Sub>
</svelte:fragment>
</ProjectAccessControls>
<DropdownMenu.Item
href={`/${params.organization}/${params.project}/-/alerts`}
>
Alerts
</DropdownMenu.Item>
<DropdownMenu.Item
href={`/${params.organization}/${params.project}/-/reports`}
>
Reports
</DropdownMenu.Item>
{#if params.dashboard}
<DropdownMenu.Item
href={`/${params.organization}/${params.project}/-/alerts`}
>
Alerts
</DropdownMenu.Item>
<DropdownMenu.Item
href={`/${params.organization}/${params.project}/-/reports`}
>
Reports
</DropdownMenu.Item>
{/if}
{/if}

<ThemeToggle />
Expand Down
45 changes: 43 additions & 2 deletions web-admin/src/features/navigation/TopNavigationBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -15,6 +16,7 @@
import { runtime } from "@rilldata/web-common/runtime-client/runtime-store";
import {
createAdminServiceGetCurrentUser,
createAdminServiceGetDeploymentCredentials,
createAdminServiceListOrganizations as listOrgs,
createAdminServiceListProjectsForOrganization as listProjects,
type V1Organization,
Expand Down Expand Up @@ -69,6 +71,43 @@
$: 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 =
$mockedProjectQuery.data?.projectPermissions?.manageProjectMembers ??
manageProjectMembers;
$: effectiveCreateMagicAuthTokens =
$mockedProjectQuery.data?.projectPermissions?.createMagicAuthTokens ??
createMagicAuthTokens;

$: loggedIn = !!$user.data?.user;
$: rillLogoHref = !loggedIn ? "https://www.rilldata.com" : "/";
$: logoUrl = organizationLogoUrl;
Expand Down Expand Up @@ -231,7 +270,7 @@
{/if}
<!-- NOTE: only project admin and editor can manage project members -->
<!-- https://docs.rilldata.com/guide/administration/users-and-access/roles-permissions -->
{#if onProjectPage && manageProjectMembers}
{#if onProjectPage && effectiveManageProjectMembers}
<ShareProjectPopover
{organization}
{project}
Expand Down Expand Up @@ -264,7 +303,9 @@
{#if $alertsFlag}
<CreateAlert />
{/if}
<ShareDashboardPopover {createMagicAuthTokens} />
<ShareDashboardPopover
createMagicAuthTokens={effectiveCreateMagicAuthTokens}
/>
{/if}
</StateManagersProvider>
{/key}
Expand Down
87 changes: 85 additions & 2 deletions web-admin/src/features/view-as-user/viewAsUserStore.ts
Original file line number Diff line number Diff line change
@@ -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<V1User | null>(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<V1User | null>(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 };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: initForProject is called from a reactive block that re-fires on any $page change (not just org/project changes). An early return when the scope hasn't changed would avoid redundant sessionStorage reads:

if (currentScope?.org === org && currentScope?.project === project) return;

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();
53 changes: 52 additions & 1 deletion web-admin/src/routes/[organization]/[project]/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
</script>

<script lang="ts">
import { onNavigate } from "$app/navigation";
import { page } from "$app/stores";
import {
V1DeploymentStatus,
Expand Down Expand Up @@ -57,6 +58,7 @@
import type { CreateQueryOptions } from "@tanstack/svelte-query";
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts";
import { getRuntimeServiceListResourcesQueryKey } from "@rilldata/web-common/runtime-client";
import { onDestroy } from "svelte";

const user = createAdminServiceGetCurrentUser();

Expand All @@ -65,6 +67,28 @@
params: { organization, project, token },
} = $page);

// Initialize view-as store for this project scope (loads from sessionStorage)
$: if (organization && project) {
viewAsUserStore.initForProject(organization, project);
}

// Clear view-as state when navigating to a different project
onNavigate(({ from, to }) => {
const changedProject =
!from ||
!to ||
from.params?.organization !== to.params?.organization ||
from.params?.project !== to.params?.project;
if (changedProject) {
viewAsUserStore.clear();
}
});

// Clear view-as state when unmounting (e.g., navigating to org page)
onDestroy(() => {
viewAsUserStore.clear();
});

$: onProjectPage = isProjectPage($page);
$: onPublicURLPage = isPublicURLPage($page);
$: onPublicReportOrAlertPage =
Expand Down Expand Up @@ -122,7 +146,34 @@
$: ({ 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;

$: deploymentStatus = projectData?.deployment?.status;
// A re-deploy triggers `DEPLOYMENT_STATUS_UPDATING` status. But we can still show the project UI.
$: isProjectAvailable =
Expand Down Expand Up @@ -168,7 +219,7 @@

{#if onProjectPage && deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING}
<ProjectTabs
projectPermissions={projectData.projectPermissions}
projectPermissions={effectiveProjectPermissions}
{organization}
{pathname}
{project}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import { getHomeBookmarkExploreState } from "@rilldata/web-admin/features/bookmarks/selectors";
import DashboardBuilding from "@rilldata/web-common/features/dashboards/DashboardBuilding.svelte";
import DashboardErrored from "@rilldata/web-admin/features/dashboards/DashboardErrored.svelte";
import { viewAsUserStore } from "@rilldata/web-admin/features/view-as-user/viewAsUserStore";
import {
DashboardBannerID,
DashboardBannerPriority,
Expand Down Expand Up @@ -82,7 +81,6 @@
);

onNavigate(({ from, to }) => {
viewAsUserStore.set(null);
errorStore.reset();

const changedDashboard =
Expand Down
Loading