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 @@ - - avatar - + + + avatar + + + + {#if $page.params.organization && $page.params.project && $page.params.dashboard} + + + close1(undefined)} + /> + + + {/if} + + { + // handleClose(); + handleLogOut(); + }}>Logout + { + // handleClose(); + handleDocumentation(); + }}>Documentation + + + 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 @@ + + + + + + View as + + + + + 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 @@ + + +
+
+ Test your security policies by viewing this project from the perspective of another user. +
+ + {#if visibleUsers.length > 0} +
+ {#each visibleUsers as user} + handleViewAsUser(user)} + > + + {#if user === $viewAsUserStore} + + {:else} + + {/if} + + {user.email} + + {/each} +
+ {:else} +
no results
+ {/if} +
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"),