mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-03 21:02:05 +08:00
feat: add Emails tab to User Detail page (#1616)
This commit is contained in:
parent
07449da7e9
commit
c7036da485
@ -0,0 +1,25 @@
|
||||
import type { AdminEmailOutbox } from "@hexclave/next";
|
||||
|
||||
export type EmailWithDeliveredAt = AdminEmailOutbox & {
|
||||
deliveredAt?: Date | string | null,
|
||||
};
|
||||
|
||||
export function hasDeliveredAt(email: AdminEmailOutbox): email is EmailWithDeliveredAt {
|
||||
return "deliveredAt" in email;
|
||||
}
|
||||
|
||||
export function getRecipientDisplay(email: AdminEmailOutbox): string {
|
||||
const to = email.to;
|
||||
if (to.type === "user-primary-email") {
|
||||
return `User: ${to.userId.slice(0, 8)}...`;
|
||||
}
|
||||
if (to.type === "user-custom-emails") {
|
||||
return to.emails[0] ?? `User: ${to.userId.slice(0, 8)}...`;
|
||||
}
|
||||
return to.emails[0] ?? "No recipients";
|
||||
}
|
||||
|
||||
export function getEmailTimestamp(email: AdminEmailOutbox): Date {
|
||||
const deliveredAt = hasDeliveredAt(email) ? email.deliveredAt : undefined;
|
||||
return deliveredAt != null ? new Date(deliveredAt) : email.scheduledAt;
|
||||
}
|
||||
@ -17,32 +17,9 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
|
||||
import { useAdminApp, useProjectId } from "../use-admin-app";
|
||||
import { DomainReputationCard } from "./domain-reputation-card";
|
||||
import { STATUS_LABELS, computeEmailStats, getStatusBadgeColor } from "./email-status-utils";
|
||||
import { getRecipientDisplay, getEmailTimestamp } from "./email-outbox-utils";
|
||||
import { StatsBar } from "./stats-bar";
|
||||
|
||||
type EmailWithDeliveredAt = AdminEmailOutbox & {
|
||||
deliveredAt?: Date | string | null,
|
||||
};
|
||||
|
||||
function hasDeliveredAt(email: AdminEmailOutbox): email is EmailWithDeliveredAt {
|
||||
return "deliveredAt" in email;
|
||||
}
|
||||
|
||||
function getRecipientDisplay(email: AdminEmailOutbox): string {
|
||||
const to = email.to;
|
||||
if (to.type === "user-primary-email") {
|
||||
return `User: ${to.userId.slice(0, 8)}...`;
|
||||
}
|
||||
if (to.type === "user-custom-emails") {
|
||||
return to.emails[0] ?? `User: ${to.userId.slice(0, 8)}...`;
|
||||
}
|
||||
return to.emails[0] ?? "No recipients";
|
||||
}
|
||||
|
||||
function getEmailTimestamp(email: AdminEmailOutbox): Date {
|
||||
const deliveredAt = hasDeliveredAt(email) ? email.deliveredAt : undefined;
|
||||
return deliveredAt ? new Date(deliveredAt) : email.scheduledAt;
|
||||
}
|
||||
|
||||
const emailColumns: DataGridColumnDef<AdminEmailOutbox>[] = [
|
||||
{
|
||||
id: "recipient",
|
||||
|
||||
@ -63,6 +63,7 @@ import { useAdminApp } from "../../use-admin-app";
|
||||
import { UserAnalyticsSection } from "./user-analytics";
|
||||
import { UserPageTableSection } from "./user-page-table-section";
|
||||
import { UserPaymentsSection } from "./user-payments";
|
||||
import { UserEmailsSection } from "./user-emails";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// The session-replays page is ~2k LOC and pulls rrweb in via dynamic imports.
|
||||
@ -1869,6 +1870,7 @@ type UserPageTabConfig = {
|
||||
const USER_PAGE_TABS = [
|
||||
{ id: "authentication", label: "Authentication", appId: "authentication" },
|
||||
{ id: "teams", label: "Teams", appId: "teams" },
|
||||
{ id: "emails", label: "Emails", appId: "emails" },
|
||||
{ id: "payments", label: "Payments", appId: "payments" },
|
||||
{ id: "analytics", label: "Analytics", appId: "analytics" },
|
||||
{ id: "session-replays", label: "Session Replays", appId: "session-replays" },
|
||||
@ -1994,6 +1996,11 @@ function UserPage({ user }: { user: ServerUser }) {
|
||||
<UserTeamsSection user={user} />
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "emails" && (
|
||||
<Suspense fallback={<TabContentSkeleton sections={1} />}>
|
||||
<UserEmailsSection user={user} />
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "payments" && (
|
||||
<Suspense fallback={<TabContentSkeleton sections={1} />}>
|
||||
<UserPaymentsSection user={user} />
|
||||
|
||||
@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { DesignBadge } from "@/components/design-components";
|
||||
import { useRouter } from "@/components/router";
|
||||
import { Skeleton, Typography } from "@/components/ui";
|
||||
import { EnvelopeSimpleIcon } from "@phosphor-icons/react";
|
||||
import type { DataGridColumnDef } from "@hexclave/dashboard-ui-components";
|
||||
import type { AdminEmailOutbox, ServerUser } from "@hexclave/next";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { urlString } from "@hexclave/shared/dist/utils/urls";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAdminApp, useProjectId } from "../../use-admin-app";
|
||||
import { UserPageTableSection } from "./user-page-table-section";
|
||||
import { STATUS_LABELS, computeEmailStats, getStatusBadgeColor } from "../../email-sent/email-status-utils";
|
||||
import { getRecipientDisplay, getEmailTimestamp } from "../../email-sent/email-outbox-utils";
|
||||
import { StatsBar } from "../../email-sent/stats-bar";
|
||||
|
||||
function isEmailForUser(email: AdminEmailOutbox, userId: string): boolean {
|
||||
const to = email.to;
|
||||
if (to.type === "user-primary-email" && to.userId === userId) return true;
|
||||
if (to.type === "user-custom-emails" && to.userId === userId) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailColumns: DataGridColumnDef<AdminEmailOutbox>[] = [
|
||||
{
|
||||
id: "subject",
|
||||
header: "Subject",
|
||||
width: 240,
|
||||
flex: 1,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => {
|
||||
if (!row.hasRendered) {
|
||||
return <span className="text-muted-foreground italic">Not yet rendered</span>;
|
||||
}
|
||||
return <span className="truncate">{row.subject}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "recipient",
|
||||
header: "Recipient",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => (
|
||||
<span className="truncate text-sm text-muted-foreground">{getRecipientDisplay(row)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "time",
|
||||
header: "Time",
|
||||
width: 160,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{getEmailTimestamp(row).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: ({ row }) => (
|
||||
<DesignBadge label={STATUS_LABELS[row.status]} color={getStatusBadgeColor(row.status)} size="sm" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function UserEmailsSection({ user }: { user: ServerUser }) {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const projectId = useProjectId();
|
||||
const router = useRouter();
|
||||
const [emails, setEmails] = useState<AdminEmailOutbox[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetches all pages of the outbox so client-side filtering by userId
|
||||
// doesn't silently miss emails on later pages.
|
||||
const refreshEmails = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const allEmails: AdminEmailOutbox[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const result = await hexclaveAdminApp.listOutboxEmails(cursor != null ? { cursor } : undefined);
|
||||
allEmails.push(...result.items);
|
||||
cursor = result.nextCursor ?? undefined;
|
||||
} while (cursor != null);
|
||||
setEmails(allEmails);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load emails");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hexclaveAdminApp]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
await refreshEmails();
|
||||
if (cancelled) return;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshEmails]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => emails
|
||||
.filter((e) => isEmailForUser(e, user.id))
|
||||
.sort((a, b) => getEmailTimestamp(b).getTime() - getEmailTimestamp(a).getTime()),
|
||||
[emails, user.id],
|
||||
);
|
||||
const stats = useMemo(() => computeEmailStats(filtered), [filtered]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-[40px] rounded-2xl" />
|
||||
<Skeleton className="h-[180px] rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<EnvelopeSimpleIcon className="h-6 w-6 text-destructive" />
|
||||
<Typography className="text-sm font-medium">Failed to load emails</Typography>
|
||||
<Typography variant="secondary" className="text-sm">{error}</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{filtered.length > 0 && (
|
||||
<div className="py-1">
|
||||
<div className="mb-2 text-sm text-center">
|
||||
<span className="font-medium">{filtered.length} email{filtered.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<StatsBar data={stats} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UserPageTableSection
|
||||
title="Sent Emails"
|
||||
urlStateKey="useremails"
|
||||
columns={emailColumns}
|
||||
rows={filtered}
|
||||
getRowId={(email) => email.id}
|
||||
emptyLabel="No emails sent to this user"
|
||||
paginated
|
||||
onRowClick={(row) => {
|
||||
router.push(urlString`/projects/${projectId}/email-viewer/${row.id}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user