mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
(fix): delete dialog on click behavior; fix user page syncing issues (#1684)
<!--
Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/hexclave/hexclave/blob/dev/CONTRIBUTING.md
-->
## 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
<img width="524" height="302" alt="old-user-delete-dialog"
src="https://github.com/user-attachments/assets/d75f25d3-e462-4ae3-ac3b-d133f6ddbbf6"
/>
After
<img width="520" height="281" alt="new-user-delete-dialog"
src="https://github.com/user-attachments/assets/4783f61e-76b6-45c5-8952-0f76fa48eba1"
/>
<!-- This is an auto-generated description by cubic. -->
---
## 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.
<sup>Written for commit 5e5f641bbe.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1684?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
eba479b7cc
commit
a5e884db91
@ -181,7 +181,13 @@ function UserHeader({ user }: UserHeaderProps) {
|
||||
]}
|
||||
/>
|
||||
<RestrictionDialog user={user} open={restrictionDialogOpen} onOpenChange={setRestrictionDialogOpen} />
|
||||
<DeleteUserDialog user={user} open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen} redirectTo={`/projects/${hexclaveAdminApp.projectId}/users`} />
|
||||
<DeleteUserDialog
|
||||
user={user}
|
||||
open={isDeleteModalOpen}
|
||||
onOpenChange={setIsDeleteModalOpen}
|
||||
profileHref={`/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`}
|
||||
redirectTo={`/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users`}
|
||||
/>
|
||||
<ImpersonateUserDialog user={user} impersonateSnippet={impersonateSnippet} onClose={() => setImpersonateSnippet(null)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 <TotalUsersText metrics={metrics} />;
|
||||
}
|
||||
|
||||
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<UsersMetricsSnapshot | null>(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 (
|
||||
<AppEnabledGuard appId="authentication">
|
||||
@ -72,9 +124,13 @@ export default function PageClient() {
|
||||
description={<>
|
||||
Total:{" "}
|
||||
<ErrorBoundary errorComponent={TotalUsersErrorComponent}>
|
||||
<Suspense fallback={<Skeleton className="inline"><span>Calculating</span></Skeleton>}>
|
||||
<TotalUsersDisplay key={refreshKey} />
|
||||
</Suspense>
|
||||
{usersMetricsSnapshot != null ? (
|
||||
<TotalUsersText metrics={usersMetricsSnapshot.userCounts} />
|
||||
) : (
|
||||
<Suspense fallback={<Skeleton className="inline"><span>Calculating</span></Skeleton>}>
|
||||
<TotalUsersDisplay />
|
||||
</Suspense>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</>}
|
||||
actions={
|
||||
@ -87,20 +143,21 @@ export default function PageClient() {
|
||||
<UserDialog
|
||||
type="create"
|
||||
trigger={<Button>Create User</Button>}
|
||||
onUserMutated={handleUserMutated}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{firstUserPage.length > 0 ? null : (
|
||||
{hasUsers ? null : (
|
||||
<Alert variant='success'>
|
||||
Congratulations on starting your project! Check the <StyledLink href="https://docs.hexclave.com">documentation</StyledLink> to add your first users.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<UsersKpiCards />
|
||||
<UsersKpiCards metrics={usersMetricsSnapshot?.metrics} />
|
||||
|
||||
<div data-walkthrough="users-table">
|
||||
<UserTable key={refreshKey} />
|
||||
<UserTable onUserMutated={handleUserMutated} onReloadChange={handleTableReloadChange} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { EntityKpiCards } from "@/components/entity-kpi-cards";
|
||||
import type { MetricsResponse } from "@/lib/hexclave-app-internals";
|
||||
|
||||
export function UsersKpiCards() {
|
||||
export function UsersKpiCards(props: {
|
||||
metrics?: MetricsResponse,
|
||||
}) {
|
||||
return (
|
||||
<EntityKpiCards
|
||||
errorTag="users-kpi-cards-error-boundary"
|
||||
metrics={props.metrics}
|
||||
source={(metrics) => ({
|
||||
dailyNew: metrics.daily_users.map((p) => p.activity),
|
||||
splitTotal: metrics.auth_overview.daily_active_users_split.total.map((p) => p.activity),
|
||||
|
||||
@ -132,70 +132,74 @@ function parseEmailDomains(input: string) {
|
||||
|
||||
// ─── Column definitions ──────────────────────────────────────────────
|
||||
|
||||
const USER_TABLE_COLUMNS: DataGridColumnDef<ExtendedServerUser>[] = [
|
||||
{
|
||||
id: "user",
|
||||
header: "User",
|
||||
width: 180,
|
||||
flex: 1,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => <UserIdentityCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
header: "Email",
|
||||
width: 180,
|
||||
flex: 1,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => <UserEmailCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "userId",
|
||||
header: "User ID",
|
||||
width: 130,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => <UserIdCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "emailStatus",
|
||||
header: "Email Verified",
|
||||
width: 110,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => <EmailStatusCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "lastActiveAt",
|
||||
header: "Last active",
|
||||
width: 110,
|
||||
renderCell: ({ row }) => <DateMetaCell value={row.lastActiveAt} emptyLabel="Never" />,
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
header: "Auth methods",
|
||||
width: 150,
|
||||
sortable: false,
|
||||
cellOverflow: "wrap",
|
||||
renderCell: ({ row }) => <AuthMethodsCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "signedUpAt",
|
||||
header: "Signed up",
|
||||
width: 110,
|
||||
renderCell: ({ row }) => <DateMetaCell value={row.signedUpAt} emptyLabel="Unknown" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
width: 44,
|
||||
minWidth: 44,
|
||||
maxWidth: 44,
|
||||
sortable: false,
|
||||
hideable: false,
|
||||
resizable: false,
|
||||
align: "right",
|
||||
renderCell: ({ row }) => <UserActions user={row} />,
|
||||
},
|
||||
];
|
||||
function createUserTableColumns(
|
||||
onUserMutated: () => void | Promise<void>,
|
||||
): DataGridColumnDef<ExtendedServerUser>[] {
|
||||
return [
|
||||
{
|
||||
id: "user",
|
||||
header: "User",
|
||||
width: 180,
|
||||
flex: 1,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => <UserIdentityCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
header: "Email",
|
||||
width: 180,
|
||||
flex: 1,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => <UserEmailCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "userId",
|
||||
header: "User ID",
|
||||
width: 130,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => <UserIdCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "emailStatus",
|
||||
header: "Email Verified",
|
||||
width: 110,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => <EmailStatusCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "lastActiveAt",
|
||||
header: "Last active",
|
||||
width: 110,
|
||||
renderCell: ({ row }) => <DateMetaCell value={row.lastActiveAt} emptyLabel="Never" />,
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
header: "Auth methods",
|
||||
width: 150,
|
||||
sortable: false,
|
||||
cellOverflow: "wrap",
|
||||
renderCell: ({ row }) => <AuthMethodsCell user={row} />,
|
||||
},
|
||||
{
|
||||
id: "signedUpAt",
|
||||
header: "Signed up",
|
||||
width: 110,
|
||||
renderCell: ({ row }) => <DateMetaCell value={row.signedUpAt} emptyLabel="Unknown" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
width: 44,
|
||||
minWidth: 44,
|
||||
maxWidth: 44,
|
||||
sortable: false,
|
||||
hideable: false,
|
||||
resizable: false,
|
||||
align: "right",
|
||||
renderCell: ({ row }) => <UserActions user={row} onUserMutated={onUserMutated} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const USER_EXPORT_FIELDS: DataGridExportField<ExtendedServerUser>[] = [
|
||||
{ key: "id", label: "User ID", enabled: true, getValue: (user) => user.id },
|
||||
@ -218,10 +222,13 @@ const USER_EXPORT_FIELDS: DataGridExportField<ExtendedServerUser>[] = [
|
||||
|
||||
// ─── UserTable ───────────────────────────────────────────────────────
|
||||
|
||||
export function UserTable() {
|
||||
export function UserTable(props: {
|
||||
onUserMutated: () => void | Promise<void>,
|
||||
onReloadChange?: (reload: () => void) => void,
|
||||
}) {
|
||||
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS);
|
||||
|
||||
return <UserTableBody filters={filters} setFilters={setFilters} />;
|
||||
return <UserTableBody filters={filters} setFilters={setFilters} onUserMutated={props.onUserMutated} onReloadChange={props.onReloadChange} />;
|
||||
}
|
||||
|
||||
// ─── Body (imperative fetching — no Suspense flash) ──────────────────
|
||||
@ -229,12 +236,15 @@ export function UserTable() {
|
||||
function UserTableBody(props: {
|
||||
filters: FilterState,
|
||||
setFilters: React.Dispatch<React.SetStateAction<FilterState>>,
|
||||
onUserMutated: () => void | Promise<void>,
|
||||
onReloadChange?: (reload: () => void) => void,
|
||||
}) {
|
||||
const { filters, setFilters } = props;
|
||||
const { filters, setFilters, onUserMutated, onReloadChange } = props;
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const router = useRouter();
|
||||
const columns = useMemo(() => createUserTableColumns(onUserMutated), [onUserMutated]);
|
||||
|
||||
const [gridState, setGridState] = useDataGridUrlState(USER_TABLE_COLUMNS, {
|
||||
const [gridState, setGridState] = useDataGridUrlState(columns, {
|
||||
paramPrefix: "users",
|
||||
initial: {
|
||||
sorting: [{ columnId: "signedUpAt", direction: DEFAULT_FILTERS.signedUpOrder }],
|
||||
@ -314,7 +324,7 @@ function UserTableBody(props: {
|
||||
|
||||
const gridData = useDataSource({
|
||||
dataSource,
|
||||
columns: USER_TABLE_COLUMNS,
|
||||
columns,
|
||||
getRowId,
|
||||
sorting: gridState.sorting,
|
||||
quickSearch: debouncedQuickSearch,
|
||||
@ -322,6 +332,11 @@ function UserTableBody(props: {
|
||||
paginationMode: "infinite",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onReloadChange?.(gridData.reload);
|
||||
return () => onReloadChange?.(() => {});
|
||||
}, [gridData.reload, onReloadChange]);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setGridState((prev) => ({
|
||||
@ -400,7 +415,7 @@ function UserTableBody(props: {
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
columns={USER_TABLE_COLUMNS}
|
||||
columns={columns}
|
||||
rows={gridData.rows}
|
||||
getRowId={getRowId}
|
||||
isLoading={gridData.isLoading}
|
||||
@ -595,17 +610,26 @@ function EmailDomainFilter(props: {
|
||||
|
||||
// ─── Cell components ─────────────────────────────────────────────────
|
||||
|
||||
function UserActions(props: { user: ExtendedServerUser }) {
|
||||
const { user } = props;
|
||||
function UserActions(props: {
|
||||
user: ExtendedServerUser,
|
||||
onUserMutated: () => void | Promise<void>,
|
||||
}) {
|
||||
const { user, onUserMutated } = props;
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const router = useRouter();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||
const [impersonateSnippet, setImpersonateSnippet] = useState<string | null>(null);
|
||||
const profileUrl = `/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DeleteUserDialog user={user} open={isDeleteOpen} onOpenChange={setIsDeleteOpen} />
|
||||
<div
|
||||
className="flex justify-end"
|
||||
data-no-row-click
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DeleteUserDialog user={user} open={isDeleteOpen} onOpenChange={setIsDeleteOpen} profileHref={profileUrl} onDeleted={onUserMutated} />
|
||||
<ImpersonateUserDialog user={user} impersonateSnippet={impersonateSnippet} onClose={() => setImpersonateSnippet(null)} />
|
||||
<CreateCheckoutDialog
|
||||
open={isCheckoutOpen}
|
||||
@ -620,9 +644,7 @@ function UserActions(props: { user: ExtendedServerUser }) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(`/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`)
|
||||
}
|
||||
onClick={() => router.push(profileUrl)}
|
||||
>
|
||||
View details
|
||||
</DropdownMenuItem>
|
||||
@ -649,6 +671,7 @@ function UserActions(props: { user: ExtendedServerUser }) {
|
||||
onClick={() =>
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
await user.update({ totpMultiFactorSecret: null });
|
||||
runAsynchronouslyWithAlert(Promise.resolve().then(() => onUserMutated()));
|
||||
})
|
||||
}
|
||||
>
|
||||
@ -674,7 +697,7 @@ function UserIdentityCell(props: { user: ExtendedServerUser }) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={profileUrl} className="rounded-full shrink-0">
|
||||
<Link href={profileUrl} className="rounded-full shrink-0" prefetch={false}>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={user.profileImageUrl ?? undefined} alt={user.displayName ?? user.primaryEmail ?? "User avatar"} />
|
||||
<AvatarFallback>{fallback}</AvatarFallback>
|
||||
@ -683,6 +706,7 @@ function UserIdentityCell(props: { user: ExtendedServerUser }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={profileUrl}
|
||||
prefetch={false}
|
||||
className="block truncate text-sm font-semibold text-foreground hover:text-foreground"
|
||||
title={displayName}
|
||||
>
|
||||
|
||||
@ -34,6 +34,7 @@ type EntityKpiCardsProps = {
|
||||
/** Pulls the four series out of the shared metrics response. */
|
||||
source: (metrics: Metrics) => EntityKpiSeries,
|
||||
labels: EntityKpiLabels,
|
||||
metrics?: Metrics,
|
||||
};
|
||||
|
||||
const capturedKpiErrors = new WeakMap<Error, Set<string>>();
|
||||
@ -59,9 +60,12 @@ function formatCompact(n: number): string {
|
||||
return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
||||
}
|
||||
|
||||
function KpiGridContent({ source, labels }: { source: EntityKpiCardsProps["source"], labels: EntityKpiLabels }) {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const metrics = useMetricsOrThrow(hexclaveAdminApp, false);
|
||||
function KpiGrid(props: {
|
||||
metrics: Metrics,
|
||||
source: EntityKpiCardsProps["source"],
|
||||
labels: EntityKpiLabels,
|
||||
}) {
|
||||
const { metrics, source, labels } = props;
|
||||
const { dailyNew, splitTotal, splitNew, totalCount } = source(metrics);
|
||||
|
||||
const new7 = sumLast(dailyNew, 7);
|
||||
@ -145,6 +149,12 @@ function KpiGridContent({ source, labels }: { source: EntityKpiCardsProps["sourc
|
||||
);
|
||||
}
|
||||
|
||||
function KpiGridContent({ source, labels }: { source: EntityKpiCardsProps["source"], labels: EntityKpiLabels }) {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const metrics = useMetricsOrThrow(hexclaveAdminApp, false);
|
||||
return <KpiGrid metrics={metrics} source={source} labels={labels} />;
|
||||
}
|
||||
|
||||
function KpiSkeletonGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@ -155,16 +165,20 @@ function KpiSkeletonGrid() {
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityKpiCards({ errorTag, source, labels }: EntityKpiCardsProps) {
|
||||
export function EntityKpiCards({ errorTag, source, labels, metrics }: EntityKpiCardsProps) {
|
||||
const ErrorComponent = ({ error }: { error: Error }) => {
|
||||
captureKpiErrorOnce(error, errorTag);
|
||||
return null;
|
||||
};
|
||||
return (
|
||||
<ErrorBoundary errorComponent={ErrorComponent}>
|
||||
<Suspense fallback={<KpiSkeletonGrid />}>
|
||||
<KpiGridContent source={source} labels={labels} />
|
||||
</Suspense>
|
||||
{metrics != null ? (
|
||||
<KpiGrid metrics={metrics} source={source} labels={labels} />
|
||||
) : (
|
||||
<Suspense fallback={<KpiSkeletonGrid />}>
|
||||
<KpiGridContent source={source} labels={labels} />
|
||||
</Suspense>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,13 +19,14 @@ type LinkProps = {
|
||||
title?: string,
|
||||
};
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(({ onClick, href, children, ...rest }, ref) => {
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(({ onClick, href, children, prefetch, ...rest }, ref) => {
|
||||
const router = useRouter();
|
||||
const { needConfirm } = useRouterConfirm();
|
||||
|
||||
return <NextLink
|
||||
ref={ref}
|
||||
href={href}
|
||||
prefetch={prefetch}
|
||||
{...rest}
|
||||
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (needConfirm) {
|
||||
@ -38,7 +39,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(({ onClick, h
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UrlPrefetcher href={href} />
|
||||
{prefetch !== false ? <UrlPrefetcher href={href} /> : null}
|
||||
{children}
|
||||
</NextLink>;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
|
||||
import { ServerUser } from "@hexclave/next";
|
||||
import { KnownErrors } from "@hexclave/shared";
|
||||
import { countryCodeSchema, emailSchema, jsonStringOrEmptySchema, passwordSchema } from "@hexclave/shared/dist/schema-fields";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Typography } from "@/components/ui";
|
||||
import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components";
|
||||
import { WarningCircleIcon } from "@phosphor-icons/react";
|
||||
@ -18,6 +19,7 @@ const metadataDocsUrl = "https://docs.hexclave.com/guides/getting-started/user-f
|
||||
export function UserDialog(props: {
|
||||
open?: boolean,
|
||||
onOpenChange?: (open: boolean) => void,
|
||||
onUserMutated?: () => void | Promise<void>,
|
||||
trigger?: React.ReactNode,
|
||||
} & ({
|
||||
type: 'create',
|
||||
@ -146,6 +148,9 @@ export function UserDialog(props: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (props.onUserMutated) {
|
||||
runAsynchronouslyWithAlert(Promise.resolve().then(() => props.onUserMutated?.()));
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import { ServerUser } from '@hexclave/next';
|
||||
import { ActionDialog, CopyField, Typography } from "@/components/ui";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { deindent } from "@hexclave/shared/dist/utils/strings";
|
||||
import { Link } from './link';
|
||||
import { useRouter } from './router';
|
||||
|
||||
|
||||
export function DeleteUserDialog(props: {
|
||||
user: ServerUser,
|
||||
open: boolean,
|
||||
profileHref: string,
|
||||
redirectTo?: string,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
onDeleted?: () => void | Promise<void>,
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const userLabel = props.user.displayName?.trim() || props.user.primaryEmail?.trim() || props.user.id;
|
||||
return <ActionDialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
@ -20,6 +25,9 @@ export function DeleteUserDialog(props: {
|
||||
okButton={{
|
||||
label: "Delete User", onClick: async () => {
|
||||
await props.user.delete();
|
||||
if (props.onDeleted) {
|
||||
runAsynchronouslyWithAlert(Promise.resolve().then(() => props.onDeleted?.()));
|
||||
}
|
||||
if (props.redirectTo) {
|
||||
router.push(props.redirectTo);
|
||||
}
|
||||
@ -27,7 +35,18 @@ export function DeleteUserDialog(props: {
|
||||
}}
|
||||
confirmText="I understand that this action cannot be undone."
|
||||
>
|
||||
{`Are you sure you want to delete the user ${props.user.displayName ? '"' + props.user.displayName + '"' : ''} with ID ${props.user.id}?`}
|
||||
<Typography>
|
||||
Are you sure you want to delete the user "<Link
|
||||
href={props.profileHref}
|
||||
className="inline underline underline-offset-2"
|
||||
prefetch={false}
|
||||
onClick={() => {
|
||||
props.onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{userLabel}
|
||||
</Link>"?
|
||||
</Typography>
|
||||
</ActionDialog>;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import {
|
||||
MetricsResponseBodySchema,
|
||||
type MetricsResponse,
|
||||
MetricsUserCountsSchema,
|
||||
type MetricsUserCounts,
|
||||
type UserActivityResponse,
|
||||
} from "@hexclave/shared/dist/interface/admin-metrics";
|
||||
@ -58,6 +60,7 @@ type AdminAppInternalsHooks = {
|
||||
useMetrics: (includeAnonymous: boolean, filters?: AnalyticsOverviewFilters) => MetricsResponse,
|
||||
useUserActivity: (userId: string) => UserActivityResponse,
|
||||
useMetricsUserCounts: () => MetricsUserCounts,
|
||||
sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise<Response>,
|
||||
};
|
||||
|
||||
function getInternalsHookOrThrow<K extends keyof AdminAppInternalsHooks>(adminApp: object, hookName: K): AdminAppInternalsHooks[K] {
|
||||
@ -95,3 +98,68 @@ export function useUserActivityOrThrow(adminApp: object, userId: string): UserAc
|
||||
export function useMetricsUserCountsOrThrow(adminApp: object): MetricsUserCounts {
|
||||
return getInternalsHookOrThrow(adminApp, "useMetricsUserCounts")();
|
||||
}
|
||||
|
||||
function getMetricsQueryString(includeAnonymous: boolean, filters?: AnalyticsOverviewFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
if (includeAnonymous) {
|
||||
params.append("include_anonymous", "true");
|
||||
}
|
||||
if (filters?.country_code) params.append("filter_country_code", filters.country_code);
|
||||
if (filters?.referrer) params.append("filter_referrer", filters.referrer);
|
||||
if (filters?.browser) params.append("filter_browser", filters.browser);
|
||||
if (filters?.os) params.append("filter_os", filters.os);
|
||||
if (filters?.device) params.append("filter_device", filters.device);
|
||||
if (filters?.since) params.append("filter_since", filters.since);
|
||||
if (filters?.until) params.append("filter_until", filters.until);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function applyMetricsResponseDefaults(body: MetricsResponse): MetricsResponse {
|
||||
// Keep this in sync with HexclaveAdminInterface.getMetrics(). These defaults
|
||||
// preserve one-release-cycle tolerance for dashboards talking to older servers.
|
||||
const rawBody: Partial<MetricsResponse> = body;
|
||||
const rawAnalytics: Partial<MetricsResponse["analytics_overview"]> = body.analytics_overview;
|
||||
return {
|
||||
...body,
|
||||
live_users: rawBody.live_users ?? 0,
|
||||
hourly_users: rawBody.hourly_users ?? [],
|
||||
hourly_active_users: rawBody.hourly_active_users ?? [],
|
||||
analytics_overview: {
|
||||
...body.analytics_overview,
|
||||
hourly_page_views: rawAnalytics.hourly_page_views ?? [],
|
||||
hourly_active_users: rawAnalytics.hourly_active_users ?? [],
|
||||
hourly_visitors: rawAnalytics.hourly_visitors ?? [],
|
||||
daily_anonymous_visitors_fallback: rawAnalytics.daily_anonymous_visitors_fallback ?? [],
|
||||
anonymous_visitors_fallback: rawAnalytics.anonymous_visitors_fallback ?? 0,
|
||||
top_regions: rawAnalytics.top_regions ?? [],
|
||||
bounce_rate: rawAnalytics.bounce_rate ?? 0,
|
||||
daily_bounce_rate: rawAnalytics.daily_bounce_rate ?? [],
|
||||
daily_avg_session_seconds: rawAnalytics.daily_avg_session_seconds ?? [],
|
||||
top_browsers: rawAnalytics.top_browsers ?? [],
|
||||
top_operating_systems: rawAnalytics.top_operating_systems ?? [],
|
||||
top_devices: rawAnalytics.top_devices ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJsonOrThrow(adminApp: object, path: string): Promise<unknown> {
|
||||
const response = await getInternalsHookOrThrow(adminApp, "sendRequest")(path, { method: "GET" }, "admin");
|
||||
if (!response.ok) {
|
||||
throw new HexclaveAssertionError(`Admin app internals request failed: ${path}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function fetchMetricsOrThrow(
|
||||
adminApp: object,
|
||||
includeAnonymous: boolean,
|
||||
filters?: AnalyticsOverviewFilters,
|
||||
): Promise<MetricsResponse> {
|
||||
const queryString = getMetricsQueryString(includeAnonymous, filters);
|
||||
const path = `/internal/metrics${queryString ? `?${queryString}` : ""}`;
|
||||
return applyMetricsResponseDefaults(await MetricsResponseBodySchema.validate(await fetchJsonOrThrow(adminApp, path)));
|
||||
}
|
||||
|
||||
export async function fetchMetricsUserCountsOrThrow(adminApp: object): Promise<MetricsUserCounts> {
|
||||
return await MetricsUserCountsSchema.validate(await fetchJsonOrThrow(adminApp, "/internal/metrics/user-counts"));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user