diff --git a/package-lock.json b/package-lock.json
index fa8118ea0f2..1c107aee1ff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6577,6 +6577,17 @@
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "dev": true,
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
"node_modules/@redocly/ajv": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.0.tgz",
@@ -6647,6 +6658,15 @@
"node": ">=10"
}
},
+ "node_modules/@rgossiaux/svelte-headlessui": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@rgossiaux/svelte-headlessui/-/svelte-headlessui-2.0.0.tgz",
+ "integrity": "sha512-ksh245HqMM8yqkzd/OyAK2FCHZYOSA3ldLIHab7C9S60FmifqT24JFVgi8tZpsDEMk3plbfQvfah93n5IEi8sg==",
+ "dev": true,
+ "peerDependencies": {
+ "svelte": "^3.47.0"
+ }
+ },
"node_modules/@rilldata/rill": {
"resolved": "web-local",
"link": true
@@ -40575,6 +40595,16 @@
"svelte": ">= 3"
}
},
+ "node_modules/svelte-popperjs": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/svelte-popperjs/-/svelte-popperjs-1.3.2.tgz",
+ "integrity": "sha512-fwrErlkvngL876WXRnL3OLlfk/n9YkZwwLxuKRpZOYCJLt1zrwhoKTXS+/sRgDveD/zd6GQ35hV89EOip+NBGA==",
+ "dev": true,
+ "peerDependencies": {
+ "@popperjs/core": ">=2",
+ "svelte": ">=3"
+ }
+ },
"node_modules/svelte-preprocess": {
"version": "4.10.7",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-4.10.7.tgz",
@@ -44666,6 +44696,7 @@
"devDependencies": {
"@fontsource/fira-mono": "^4.5.0",
"@playwright/test": "^1.25.0",
+ "@rgossiaux/svelte-headlessui": "^2.0.0",
"@rilldata/svelte-query": "^4.29.20-0.0.1",
"@sveltejs/adapter-static": "^1.0.0",
"@sveltejs/kit": "^1.5.0",
@@ -44685,6 +44716,7 @@
"prettier-plugin-svelte": "^2.7.0",
"svelte": "^3.48.0",
"svelte-check": "^3.0.3",
+ "svelte-popperjs": "^1.3.2",
"svelte-preprocess": "^4.10.6",
"tailwindcss": "^3.2.7",
"tslib": "^2.3.1",
diff --git a/web-admin/package.json b/web-admin/package.json
index 27d8ccf8bea..4555e75c380 100644
--- a/web-admin/package.json
+++ b/web-admin/package.json
@@ -15,6 +15,7 @@
"devDependencies": {
"@fontsource/fira-mono": "^4.5.0",
"@playwright/test": "^1.25.0",
+ "@rgossiaux/svelte-headlessui": "^2.0.0",
"@sveltejs/adapter-static": "^1.0.0",
"@sveltejs/kit": "^1.5.0",
"@rilldata/svelte-query": "^4.29.20-0.0.1",
@@ -34,6 +35,7 @@
"prettier-plugin-svelte": "^2.7.0",
"svelte": "^3.48.0",
"svelte-check": "^3.0.3",
+ "svelte-popperjs": "^1.3.2",
"svelte-preprocess": "^4.10.6",
"tailwindcss": "^3.2.7",
"tslib": "^2.3.1",
diff --git a/web-admin/src/components/authentication/UserButton.svelte b/web-admin/src/components/authentication/UserButton.svelte
index 8f9a2388c20..47a80335459 100644
--- a/web-admin/src/components/authentication/UserButton.svelte
+++ b/web-admin/src/components/authentication/UserButton.svelte
@@ -1,7 +1,17 @@
-
-
-
+
+
+
+
+
+
+
+
diff --git a/web-admin/src/components/errors/error-utils.ts b/web-admin/src/components/errors/error-utils.ts
index 1a9a628cdb4..0f70520f264 100644
--- a/web-admin/src/components/errors/error-utils.ts
+++ b/web-admin/src/components/errors/error-utils.ts
@@ -7,31 +7,42 @@ import { ADMIN_URL } from "../../client/http-client";
import { ErrorStoreState, errorStore } from "./error-store";
export function globalErrorCallback(error: AxiosError): void {
- // If Unauthorized, redirect to login page
- if (error.response.status === 401) {
- goto(`${ADMIN_URL}/auth/login?redirect=${window.origin}`);
- return;
+ const isProjectPage = get(page).route.id === "/[organization]/[project]";
+ const isDashboardPage =
+ get(page).route.id === "/[organization]/[project]/[dashboard]";
+
+ // Special handling for some errors on the Project page
+ if (isProjectPage) {
+ // If "repository not found", ignore the error and show the page
+ if (
+ error.response.status === 400 &&
+ (error.response.data as RpcStatus).message === "repository not found"
+ ) {
+ return;
+ }
}
- // If on a Project page, and "repository not found", ignore the error and show the page
- const isProjectPage = get(page).route.id === "/[organization]/[project]";
- if (
- isProjectPage &&
- error.response.status === 400 &&
- (error.response.data as RpcStatus).message === "repository not found"
- ) {
- return;
+ // Special handling for some errors on the Dashboard page
+ if (isDashboardPage) {
+ // If a dashboard wasn't found, let +page.svelte handle the error.
+ // Because the project may be reconciling, in which case we want to show a loading spinner not a 404.
+ if (
+ error.response.status === 404 &&
+ (error.response.data as RpcStatus).message === "not found"
+ ) {
+ return;
+ }
+
+ // When a JWT doesn't permit access to a metrics view, the metrics view APIs return 401s.
+ // In this scenario, `GetCatalog` returns a 404. We ignore the 401s so we can show the 404.
+ if (error.response.status === 401) {
+ return;
+ }
}
- // If on a Dashboard page, and "entry not found" (i.e. a dashboard wasn't found),
- // ignore the error here, so the page can handle it.
- const isDashboardPage =
- get(page).route.id === "/[organization]/[project]/[dashboard]";
- if (
- isDashboardPage &&
- error.response.status === 400 &&
- (error.response.data as RpcStatus).message === "entry not found"
- ) {
+ // If Unauthorized, redirect to login page
+ if (error.response.status === 401) {
+ goto(`${ADMIN_URL}/auth/login?redirect=${window.origin}`);
return;
}
diff --git a/web-admin/src/components/navigation/TopNavigationBar.svelte b/web-admin/src/components/navigation/TopNavigationBar.svelte
index 52a365f6cea..3e3d804d54b 100644
--- a/web-admin/src/components/navigation/TopNavigationBar.svelte
+++ b/web-admin/src/components/navigation/TopNavigationBar.svelte
@@ -4,6 +4,8 @@
import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte";
import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte";
import { createAdminServiceGetCurrentUser } from "../../client";
+ import ViewAsUserChip from "../../features/view-as-user/ViewAsUserChip.svelte";
+ import { viewAsUserStore } from "../../features/view-as-user/viewAsUserStore";
import SignIn from "../authentication/SignIn.svelte";
import UserButton from "../authentication/UserButton.svelte";
import { isErrorStoreEmpty } from "../errors/error-store";
@@ -38,6 +40,9 @@
{/if}
+ {#if $viewAsUserStore}
+
+ {/if}
+ import { page } from "$app/stores";
+ import {
+ Popover,
+ PopoverButton,
+ PopoverPanel,
+ } from "@rgossiaux/svelte-headlessui";
+ import { IconSpaceFixer } from "@rilldata/web-common/components/button";
+ import { Chip } from "@rilldata/web-common/components/chip";
+ import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte";
+ import { useQueryClient } from "@tanstack/svelte-query";
+ import { createPopperActions } from "svelte-popperjs";
+ import { errorStore } from "../../components/errors/error-store";
+ import { clearViewedAsUserWithinProject } from "./clearViewedAsUser";
+ import ViewAsUserPopover from "./ViewAsUserPopover.svelte";
+ import { viewAsUserStore } from "./viewAsUserStore";
+
+ // Position the popover
+ const [popperRef, popperContent] = createPopperActions();
+ const popperOptions = {
+ placement: "bottom-start",
+ strategy: "fixed",
+ modifiers: [{ name: "offset", options: { offset: [0, 4] } }],
+ };
+
+ const queryClient = useQueryClient();
+ $: org = $page.params.organization;
+ $: project = $page.params.project;
+
+
+
+
+ {
+ await clearViewedAsUserWithinProject(queryClient, org, project);
+ errorStore.reset();
+ }}
+ active={open}
+ >
+
+
+
+ Viewing as {$viewAsUserStore.email}
+
+
+
+
+
+ Clear view
+
+
+
+
+ close(undefined)}
+ />
+
+
diff --git a/web-admin/src/features/view-as-user/ViewAsUserMenuItem.svelte b/web-admin/src/features/view-as-user/ViewAsUserMenuItem.svelte
new file mode 100644
index 00000000000..1633762bf38
--- /dev/null
+++ b/web-admin/src/features/view-as-user/ViewAsUserMenuItem.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ dispatch("select-user")}
+ />
+
+
diff --git a/web-admin/src/features/view-as-user/ViewAsUserPopover.svelte b/web-admin/src/features/view-as-user/ViewAsUserPopover.svelte
new file mode 100644
index 00000000000..8e392713271
--- /dev/null
+++ b/web-admin/src/features/view-as-user/ViewAsUserPopover.svelte
@@ -0,0 +1,89 @@
+
+
+
diff --git a/web-admin/src/features/view-as-user/clearViewedAsUser.ts b/web-admin/src/features/view-as-user/clearViewedAsUser.ts
new file mode 100644
index 00000000000..a889171ce7f
--- /dev/null
+++ b/web-admin/src/features/view-as-user/clearViewedAsUser.ts
@@ -0,0 +1,71 @@
+import { afterNavigate } from "$app/navigation";
+import {
+ invalidateAllMetricsViews,
+ invalidateRuntimeQueries,
+} from "@rilldata/web-common/runtime-client/invalidation";
+import { runtime } from "@rilldata/web-common/runtime-client/runtime-store";
+import type { QueryClient } from "@tanstack/svelte-query";
+import { get } from "svelte/store";
+import {
+ adminServiceGetProject,
+ getAdminServiceGetProjectQueryKey,
+ type V1GetProjectResponse,
+} from "../../client";
+import { viewAsUserStore } from "./viewAsUserStore";
+
+/**
+ * Remove the viewed as user (if any) when navigating away from the Dashboard page
+ */
+export function clearViewedAsUserAfterNavigate(queryClient: QueryClient) {
+ afterNavigate((nav) => {
+ // Only proceed if Viewing As a user on the Dashboard page
+ if (!get(viewAsUserStore) || !nav.from?.params?.dashboard) return;
+
+ // If staying within the project, set the admin's JWT
+ if (!nav.to.params.dashboard && nav.to.params.project) {
+ clearViewedAsUserWithinProject(
+ queryClient,
+ nav.to.params.organization,
+ nav.to.params.project
+ );
+ }
+
+ // If leaving a project, clear the JWT outright
+ if (!nav.to.params.dashboard && !nav.to.params.project) {
+ clearViewedAsUserOutsideProject(queryClient);
+ }
+ });
+}
+
+export async function clearViewedAsUserWithinProject(
+ queryClient: QueryClient,
+ organization: string,
+ project: string
+) {
+ viewAsUserStore.set(null);
+
+ // Get the admin's original JWT from the `GetProject` call
+ const projResp = await queryClient.fetchQuery({
+ queryKey: getAdminServiceGetProjectQueryKey(organization, project),
+ queryFn: () => adminServiceGetProject(organization, project),
+ });
+ const jwt = projResp.jwt;
+
+ runtime.update((runtimeState) => {
+ runtimeState.jwt = jwt;
+ return runtimeState;
+ });
+
+ await invalidateAllMetricsViews(queryClient, get(runtime).instanceId);
+}
+
+async function clearViewedAsUserOutsideProject(queryClient: QueryClient) {
+ viewAsUserStore.set(null);
+
+ runtime.update((runtimeState) => {
+ runtimeState.jwt = null;
+ return runtimeState;
+ });
+
+ await invalidateRuntimeQueries(queryClient);
+}
diff --git a/web-admin/src/features/view-as-user/setViewedAsUser.ts b/web-admin/src/features/view-as-user/setViewedAsUser.ts
new file mode 100644
index 00000000000..b6f6fb4d362
--- /dev/null
+++ b/web-admin/src/features/view-as-user/setViewedAsUser.ts
@@ -0,0 +1,43 @@
+import {
+ adminServiceGetDeploymentCredentials,
+ getAdminServiceGetDeploymentCredentialsQueryKey,
+ V1GetDeploymentCredentialsResponse,
+ type V1User,
+} from "@rilldata/web-admin/client";
+import { viewAsUserStore } from "@rilldata/web-admin/features/view-as-user/viewAsUserStore";
+import { invalidateAllMetricsViews } from "@rilldata/web-common/runtime-client/invalidation";
+import { runtime } from "@rilldata/web-common/runtime-client/runtime-store";
+import type { QueryClient } from "@tanstack/svelte-query";
+import { get } from "svelte/store";
+
+export async function setViewedAsUser(
+ queryClient: QueryClient,
+ organization: string,
+ project: string,
+ user: V1User
+) {
+ viewAsUserStore.set(user);
+
+ const jwtResp =
+ await queryClient.fetchQuery({
+ queryKey: getAdminServiceGetDeploymentCredentialsQueryKey(
+ organization,
+ project,
+ {
+ userId: user.id,
+ }
+ ),
+ queryFn: () =>
+ adminServiceGetDeploymentCredentials(organization, project, {
+ userId: user.id,
+ }),
+ });
+ const jwt = jwtResp.jwt;
+
+ runtime.update((runtimeState) => {
+ runtimeState.jwt = jwt;
+ return runtimeState;
+ });
+
+ await invalidateAllMetricsViews(queryClient, get(runtime).instanceId);
+}
diff --git a/web-admin/src/features/view-as-user/viewAsUserStore.ts b/web-admin/src/features/view-as-user/viewAsUserStore.ts
new file mode 100644
index 00000000000..a2f9a0f0fa4
--- /dev/null
+++ b/web-admin/src/features/view-as-user/viewAsUserStore.ts
@@ -0,0 +1,4 @@
+import { writable } from "svelte/store";
+import type { V1User } from "../../client";
+
+export const viewAsUserStore = writable(null);
diff --git a/web-admin/src/routes/+layout.svelte b/web-admin/src/routes/+layout.svelte
index 8f9c156b663..21a3bc86d2a 100644
--- a/web-admin/src/routes/+layout.svelte
+++ b/web-admin/src/routes/+layout.svelte
@@ -14,6 +14,7 @@
import { globalErrorCallback } from "../components/errors/error-utils";
import ErrorBoundary from "../components/errors/ErrorBoundary.svelte";
import TopNavigationBar from "../components/navigation/TopNavigationBar.svelte";
+ import { clearViewedAsUserAfterNavigate } from "../features/view-as-user/clearViewedAsUser";
const queryClient = new QueryClient({
queryCache: new QueryCache({
@@ -38,6 +39,7 @@
});
beforeNavigate(retainFeaturesFlags);
+ clearViewedAsUserAfterNavigate(queryClient);
diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte
index 38d0cc8531a..7a25b5ff9e3 100644
--- a/web-admin/src/routes/[organization]/[project]/+layout.svelte
+++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte
@@ -3,6 +3,7 @@
import { page } from "$app/stores";
import RuntimeProvider from "@rilldata/web-common/runtime-client/RuntimeProvider.svelte";
import { useProjectRuntime } from "../../../components/projects/selectors";
+ import { viewAsUserStore } from "../../../features/view-as-user/viewAsUserStore";
$: projRuntime = useProjectRuntime(
$page.params.organization,
@@ -17,7 +18,9 @@
}
-{#if $projRuntime.data}
+
+{#if $projRuntime.data && !$viewAsUserStore}
typeof query.queryKey[0] === "string" &&
query.queryKey[0].startsWith("/v1/instances"),