From a5e884db91b558a8a5caaf018936079c1df82850 Mon Sep 17 00:00:00 2001
From: Vedanta-Gawande <30631624+Vedanta-Gawande@users.noreply.github.com>
Date: Mon, 6 Jul 2026 00:22:05 -0400
Subject: [PATCH] (fix): delete dialog on click behavior; fix user page syncing
issues (#1684)
## Summary
This PR fixes the Users dashboard delete-dialog click behavior and makes
user mutations refresh the Users page without requiring a manual browser
reload.
The main user-facing changes are:
1. Opening the delete dialog from a Users table row no longer lets
dialog clicks fall through into row navigation.
2. Creating, deleting, or updating a user from the Users page now
refreshes the table, total-user count, and KPI cards automatically.
3. Users table profile links avoid expensive profile prefetching, so
refreshes no longer fan out into many unnecessary per-user profile
requests.
---
## Delete Dialog Click Behavior
The Users page has two delete-user entry points:
- the user profile action menu
- the Users table row action menu
Before this PR, the profile-page flow behaved correctly, but the
table-row flow could accidentally trigger row navigation while the
delete dialog was open. For example, clicking dialog content, footer
whitespace, the confirmation label, or the overlay could navigate to the
user's profile instead of simply interacting with or closing the dialog.
This PR fixes that by treating the row action area as non-row-click
territory and stopping click/double-click propagation from the action
surface.
Behavior after this change:
- Clicking outside the dialog closes it and returns to the page that
opened it.
- Clicking inside the dialog no longer navigates through the underlying
row.
- The confirmation label still toggles the required delete
acknowledgement.
- The explicit user identifier inside the dialog is the only navigation
target to that user's profile.
- The delete confirmation copy is shorter and uses a non-empty display
fallback:
- display name
- primary email
- user ID
---
## Users Page Refresh
Previously, creating or deleting a user through the Users page changed
backend state, but the table and KPI surfaces could stay stale until the
user manually refreshed the page or clicked the reload button.
This PR adds an explicit Users-page mutation refresh path:
- `UserDialog` can notify the page after create/edit succeeds.
- `DeleteUserDialog` can notify the page after delete succeeds.
- `UserTable` exposes its existing `useDataSource().reload()` function
to the page.
- The Users page refreshes the table and the metrics/count surfaces
after successful user mutations.
- The row action menu also refreshes after removing 2FA, so the row no
longer keeps showing a stale 2FA action after the update succeeds.
---
## Refresh Performance
The existing manual reload path used the broad `_refreshUsers()` SDK
invalidation, which refreshes more than this page needs. In local
testing, that broad path could combine with profile prefetching and
trigger many per-user requests for profile details, contact channels,
and OAuth providers.
This PR narrows the automatic Users-page refresh:
- table rows reload through the table data source
- total-user counts refresh through the user-count metrics endpoint
- KPI cards refresh through the metrics endpoint
- table/profile links can opt out of dashboard `UrlPrefetcher` with
`prefetch={false}`
- dense Users table profile links use `prefetch={false}`
- the delete-dialog profile link also opts out of prefetching
This keeps the generic `DataGrid` component unchanged. The Users page
owns the fact that a user mutation happened, and the table only exposes
the reload function it already has.
---
## KPI / Navigation Consistency
The Users page now keeps a page-local metrics snapshot for the total
count and KPI cards. It refreshes that snapshot after mutations and on
page restore, so navigating into a user profile and then returning with
browser back does not leave the KPI cards showing old counts.
Passive page-load/page-restore metrics refreshes use non-alert async
handling, so a background metrics failure is reported normally instead
of showing a blocking browser alert. User-triggered refreshes and
mutation-triggered refreshes still use the dashboard's alert-backed
async handling.
---
## Screenshots of the Delete User Dialog
Before
After
---
## Summary by cubic
Fixes delete dialog click-through and makes the Users page auto-refresh
the table and metrics without flicker. KPIs and total users render
instantly from a snapshot and refresh in the background for smoother
navigation.
- **Bug Fixes**
- Block row navigation from all row-action and delete-dialog clicks; add
a non-prefetching profile link in `DeleteUserDialog` that closes the
dialog on click. Dialog now accepts `profileHref`, fires `onDeleted`,
and URL-encodes `projectId`/`user.id`; `redirectTo` still works.
- Safer links: `Link` supports `prefetch={false}` and disables internal
prefetch; applied to profile links in the table and dialogs.
- **Refactors**
- Instant metrics: preload a snapshot via
`fetchMetricsOrThrow`/`fetchMetricsUserCountsOrThrow` and pass it to
Total Users and KPI cards; auto-refresh on tab restore and after user
mutations.
- Targeted reloads: `UserTable` exposes `onReloadChange`; page, dialogs,
and 2FA removal call `onUserMutated` to reload rows and refresh metrics.
The refresh button uses the same path.
- Internals: add `sendRequest` and schema-validated metrics fetchers
with backward-compatible defaults.
Written for commit 5e5f641bbeb4ba11e4a4a6bb736dc9b03d01190f.
Summary will update on new commits.
## Summary by CodeRabbit
* **New Features**
* User pages now display KPI metrics and total-user counts immediately
when available, with improved loading/skeleton behavior.
* User actions (create/update/delete/2FA changes) now automatically
refresh the table and KPI data.
* Delete confirmation now includes a direct link to the user profile.
* **Bug Fixes**
* Improved navigation after user deletion using safer, encoded redirect
paths.
* Reduced duplicate error reporting for repeated metrics-loading
failures.
* Disabled unintended prefetching on user profile links for more
predictable navigation.
---
.../users/[userId]/page-client.tsx | 8 +-
.../[projectId]/users/page-client.tsx | 83 ++++++--
.../[projectId]/users/users-kpi-cards.tsx | 6 +-
.../src/components/data-table/user-table.tsx | 180 ++++++++++--------
.../src/components/entity-kpi-cards.tsx | 28 ++-
apps/dashboard/src/components/link.tsx | 5 +-
apps/dashboard/src/components/user-dialog.tsx | 5 +
.../dashboard/src/components/user-dialogs.tsx | 21 +-
.../src/lib/hexclave-app-internals.ts | 68 +++++++
9 files changed, 301 insertions(+), 103 deletions(-)
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx
index 81fb3e3ef..9c2885bc1 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx
@@ -181,7 +181,13 @@ function UserHeader({ user }: UserHeaderProps) {
]}
/>
-
+ setImpersonateSnippet(null)} />
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx
index e98862fba..6cecd771d 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx
@@ -4,11 +4,18 @@ import { UserTable } from "@/components/data-table/user-table";
import { StyledLink } from "@/components/link";
import { Alert, Button, SimpleTooltip, Skeleton } from "@/components/ui";
import { UserDialog } from "@/components/user-dialog";
-import { useMetricsUserCountsOrThrow } from "@/lib/hexclave-app-internals";
+import {
+ fetchMetricsOrThrow,
+ fetchMetricsUserCountsOrThrow,
+ type MetricsResponse,
+ type MetricsUserCounts,
+ useMetricsUserCountsOrThrow,
+} from "@/lib/hexclave-app-internals";
import { captureError } from "@hexclave/shared/dist/utils/errors";
+import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { ArrowsClockwiseIcon } from "@phosphor-icons/react";
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
-import { Suspense, useState } from "react";
+import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
@@ -27,7 +34,13 @@ function captureUsersMetricsErrorOnce(error: Error) {
function TotalUsersDisplay() {
const hexclaveAdminApp = useAdminApp();
const metrics = useMetricsUserCountsOrThrow(hexclaveAdminApp);
+ return ;
+}
+function TotalUsersText(props: {
+ metrics: MetricsUserCounts,
+}) {
+ const { metrics } = props;
const anonymousUsersCount = metrics.anonymous_users;
const nonAnonymousUsersCount = metrics.total_users - anonymousUsersCount;
@@ -55,15 +68,54 @@ function TotalUsersErrorComponent(props: { error: Error }) {
return <>Unavailable>;
}
+type UsersMetricsSnapshot = {
+ metrics: MetricsResponse,
+ userCounts: MetricsUserCounts,
+};
+
export default function PageClient() {
const hexclaveAdminApp = useAdminApp();
const firstUserPage = hexclaveAdminApp.useUsers({ limit: 1 });
- const [refreshKey, setRefreshKey] = useState(0);
+ const tableReloadRef = useRef<() => void>(() => {});
+ const [usersMetricsSnapshot, setUsersMetricsSnapshot] = useState(null);
- const handleRefresh = async () => {
- await (hexclaveAdminApp as any)._refreshUsers();
- setRefreshKey((k) => k + 1);
- };
+ const refreshUsersMetrics = useCallback(async () => {
+ const [metrics, userCounts] = await Promise.all([
+ fetchMetricsOrThrow(hexclaveAdminApp, false),
+ fetchMetricsUserCountsOrThrow(hexclaveAdminApp),
+ ]);
+ setUsersMetricsSnapshot({ metrics, userCounts });
+ }, [hexclaveAdminApp]);
+
+ useEffect(() => {
+ const refresh = () => runAsynchronously(refreshUsersMetrics);
+ const refreshAfterPageRestore = (event: PageTransitionEvent) => {
+ if (event.persisted) {
+ refresh();
+ }
+ };
+ refresh();
+ window.addEventListener("pageshow", refreshAfterPageRestore);
+ return () => window.removeEventListener("pageshow", refreshAfterPageRestore);
+ }, [refreshUsersMetrics]);
+
+ const handleTableReloadChange = useCallback((reload: () => void) => {
+ tableReloadRef.current = reload;
+ }, []);
+
+ const handleUserMutated = useCallback(() => {
+ tableReloadRef.current();
+ runAsynchronouslyWithAlert(refreshUsersMetrics);
+ }, [refreshUsersMetrics]);
+
+ const handleRefresh = useCallback(() => {
+ tableReloadRef.current();
+ runAsynchronouslyWithAlert(refreshUsersMetrics);
+ }, [refreshUsersMetrics]);
+
+ const hasUsers = usersMetricsSnapshot != null
+ ? usersMetricsSnapshot.userCounts.total_users - usersMetricsSnapshot.userCounts.anonymous_users > 0
+ : firstUserPage.length > 0;
return (
@@ -72,9 +124,13 @@ export default function PageClient() {
description={<>
Total:{" "}
- Calculating}>
-
-
+ {usersMetricsSnapshot != null ? (
+
+ ) : (
+ Calculating}>
+
+
+ )}
>}
actions={
@@ -87,20 +143,21 @@ export default function PageClient() {
Create User}
+ onUserMutated={handleUserMutated}
/>
}
>
- {firstUserPage.length > 0 ? null : (
+ {hasUsers ? null : (
Congratulations on starting your project! Check the documentation to add your first users.
)}
-
+