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. )} - +
- +
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/users-kpi-cards.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/users-kpi-cards.tsx index 3506e1f2d..d30ad6117 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/users-kpi-cards.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/users-kpi-cards.tsx @@ -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 ( ({ dailyNew: metrics.daily_users.map((p) => p.activity), splitTotal: metrics.auth_overview.daily_active_users_split.total.map((p) => p.activity), diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index f0f44411c..1e83a94a3 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -132,70 +132,74 @@ function parseEmailDomains(input: string) { // ─── Column definitions ────────────────────────────────────────────── -const USER_TABLE_COLUMNS: DataGridColumnDef[] = [ - { - id: "user", - header: "User", - width: 180, - flex: 1, - sortable: false, - renderCell: ({ row }) => , - }, - { - id: "email", - header: "Email", - width: 180, - flex: 1, - sortable: false, - renderCell: ({ row }) => , - }, - { - id: "userId", - header: "User ID", - width: 130, - sortable: false, - renderCell: ({ row }) => , - }, - { - id: "emailStatus", - header: "Email Verified", - width: 110, - sortable: false, - renderCell: ({ row }) => , - }, - { - id: "lastActiveAt", - header: "Last active", - width: 110, - renderCell: ({ row }) => , - }, - { - id: "auth", - header: "Auth methods", - width: 150, - sortable: false, - cellOverflow: "wrap", - renderCell: ({ row }) => , - }, - { - id: "signedUpAt", - header: "Signed up", - width: 110, - renderCell: ({ row }) => , - }, - { - id: "actions", - header: "", - width: 44, - minWidth: 44, - maxWidth: 44, - sortable: false, - hideable: false, - resizable: false, - align: "right", - renderCell: ({ row }) => , - }, -]; +function createUserTableColumns( + onUserMutated: () => void | Promise, +): DataGridColumnDef[] { + return [ + { + id: "user", + header: "User", + width: 180, + flex: 1, + sortable: false, + renderCell: ({ row }) => , + }, + { + id: "email", + header: "Email", + width: 180, + flex: 1, + sortable: false, + renderCell: ({ row }) => , + }, + { + id: "userId", + header: "User ID", + width: 130, + sortable: false, + renderCell: ({ row }) => , + }, + { + id: "emailStatus", + header: "Email Verified", + width: 110, + sortable: false, + renderCell: ({ row }) => , + }, + { + id: "lastActiveAt", + header: "Last active", + width: 110, + renderCell: ({ row }) => , + }, + { + id: "auth", + header: "Auth methods", + width: 150, + sortable: false, + cellOverflow: "wrap", + renderCell: ({ row }) => , + }, + { + id: "signedUpAt", + header: "Signed up", + width: 110, + renderCell: ({ row }) => , + }, + { + id: "actions", + header: "", + width: 44, + minWidth: 44, + maxWidth: 44, + sortable: false, + hideable: false, + resizable: false, + align: "right", + renderCell: ({ row }) => , + }, + ]; +} const USER_EXPORT_FIELDS: DataGridExportField[] = [ { key: "id", label: "User ID", enabled: true, getValue: (user) => user.id }, @@ -218,10 +222,13 @@ const USER_EXPORT_FIELDS: DataGridExportField[] = [ // ─── UserTable ─────────────────────────────────────────────────────── -export function UserTable() { +export function UserTable(props: { + onUserMutated: () => void | Promise, + onReloadChange?: (reload: () => void) => void, +}) { const [filters, setFilters] = useState(DEFAULT_FILTERS); - return ; + return ; } // ─── Body (imperative fetching — no Suspense flash) ────────────────── @@ -229,12 +236,15 @@ export function UserTable() { function UserTableBody(props: { filters: FilterState, setFilters: React.Dispatch>, + onUserMutated: () => void | Promise, + 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 ( void | Promise, +}) { + const { user, onUserMutated } = props; const hexclaveAdminApp = useAdminApp(); const router = useRouter(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); const [impersonateSnippet, setImpersonateSnippet] = useState(null); + const profileUrl = `/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`; return ( -
- +
event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + > + setImpersonateSnippet(null)} /> - router.push(`/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`) - } + onClick={() => router.push(profileUrl)} > View details @@ -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 (
- + {fallback} @@ -683,6 +706,7 @@ function UserIdentityCell(props: { user: ExtendedServerUser }) {
diff --git a/apps/dashboard/src/components/entity-kpi-cards.tsx b/apps/dashboard/src/components/entity-kpi-cards.tsx index 85d954681..69f59b571 100644 --- a/apps/dashboard/src/components/entity-kpi-cards.tsx +++ b/apps/dashboard/src/components/entity-kpi-cards.tsx @@ -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>(); @@ -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 ; +} + function KpiSkeletonGrid() { return (
@@ -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 ( - }> - - + {metrics != null ? ( + + ) : ( + }> + + + )} ); } diff --git a/apps/dashboard/src/components/link.tsx b/apps/dashboard/src/components/link.tsx index d2a72be7f..df8b08743 100644 --- a/apps/dashboard/src/components/link.tsx +++ b/apps/dashboard/src/components/link.tsx @@ -19,13 +19,14 @@ type LinkProps = { title?: string, }; -export const Link = React.forwardRef(({ onClick, href, children, ...rest }, ref) => { +export const Link = React.forwardRef(({ onClick, href, children, prefetch, ...rest }, ref) => { const router = useRouter(); const { needConfirm } = useRouterConfirm(); return ) => { if (needConfirm) { @@ -38,7 +39,7 @@ export const Link = React.forwardRef(({ onClick, h } }} > - + {prefetch !== false ? : null} {children} ; diff --git a/apps/dashboard/src/components/user-dialog.tsx b/apps/dashboard/src/components/user-dialog.tsx index 688562744..6660a5f91 100644 --- a/apps/dashboard/src/components/user-dialog.tsx +++ b/apps/dashboard/src/components/user-dialog.tsx @@ -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, 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 <> diff --git a/apps/dashboard/src/components/user-dialogs.tsx b/apps/dashboard/src/components/user-dialogs.tsx index 66cdcaa3d..4e8d20709 100644 --- a/apps/dashboard/src/components/user-dialogs.tsx +++ b/apps/dashboard/src/components/user-dialogs.tsx @@ -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, }) { const router = useRouter(); + const userLabel = props.user.displayName?.trim() || props.user.primaryEmail?.trim() || props.user.id; return { 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}?`} + + Are you sure you want to delete the user " { + props.onOpenChange(false); + }} + > + {userLabel} + "? + ; } diff --git a/apps/dashboard/src/lib/hexclave-app-internals.ts b/apps/dashboard/src/lib/hexclave-app-internals.ts index c1be230d2..45ab1985c 100644 --- a/apps/dashboard/src/lib/hexclave-app-internals.ts +++ b/apps/dashboard/src/lib/hexclave-app-internals.ts @@ -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, }; function getInternalsHookOrThrow(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 = body; + const rawAnalytics: Partial = 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 { + 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 { + 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 { + return await MetricsUserCountsSchema.validate(await fetchJsonOrThrow(adminApp, "/internal/metrics/user-counts")); +}