yiekheng c493101b60 feat(web): password policy, sign-out, dashboard isolation, activity tweaks
Multi-fix batch from a rapid feedback round:

- Password policy mirrors Facebook's documented rule (≥6 chars + mix of
  letters with numbers/symbols). Centralised in
  apps/web/src/lib/password-policy.ts; createUserAction,
  resetUserPasswordAction, the AddUser form, and the row Reset-password
  flow all use it. CLI scripts/set-password.ts inlines the same check
  so the bootstrap path stays consistent.
- App shell adds a Sign-out button in both the desktop sidebar footer
  and the mobile drawer footer, with the signed-in username next to it.
  Layout passes username down alongside role. Theme toggle was removed
  from the shell per request — operators don't need it in the chrome.
- Dashboard stats: getDashboardStats was running findMany on reminders
  with NO operator filter, so a brand-new user saw global counts from
  every tenant. Switched to an INNER JOIN on whatsapp_accounts so the
  card on / only counts this user's reminders. (Counts had been showing
  '1 / 1 / 3 / 5' to a fresh user — the cross-tenant leak the user
  flagged.)
- /activity drops the All tab and the Clear-history button. Default
  filter is now Success when no ?filter= is set; Partial keeps fanning
  into Paused + Failed; Skipped still merges into Archived.
- /settings drops the Display name row entirely and only shows the Role
  row to admins. Layout receives username so the shell can also surface
  it next to the Sign-out button.
- Tests: password-policy.test.ts (11 cases), updated users.test.ts to
  use policy-compliant passwords + cover letters-only / digits-only
  rejection, sidebar-footer assertion swapped from theme-toggle to the
  new Sign-out + username markup. 453 tests green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:46:29 +08:00

392 lines
14 KiB
TypeScript

import Link from "next/link";
import {
ActivityIcon,
AlertTriangleIcon,
ArchiveIcon,
ArchiveRestoreIcon,
CheckCircle2Icon,
MinusCircleIcon,
PauseCircleIcon,
PlayIcon,
Trash2Icon,
XCircleIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries";
import {
archiveRunAction,
deleteRunAction,
unarchiveRunAction,
} from "@/actions/history";
import { SwipeableRow } from "@/components/swipeable-row";
import { ResumeRunButton } from "@/components/activity/resume-run-button";
function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (diffSec < 60) return rtf.format(-diffSec, "second");
if (diffSec < 3600) return rtf.format(-Math.floor(diffSec / 60), "minute");
if (diffSec < 86400) return rtf.format(-Math.floor(diffSec / 3600), "hour");
return rtf.format(-Math.floor(diffSec / 86400), "day");
}
const RUN_STATUS_CONFIG: Record<
string,
{ label: string; className: string; icon: React.ElementType }
> = {
success: {
label: "Success",
className:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
icon: CheckCircle2Icon,
},
paused: {
label: "Paused",
className:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
icon: PauseCircleIcon,
},
partial: {
label: "Partial",
className:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
icon: AlertTriangleIcon,
},
failed: {
label: "Failed",
className:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
icon: XCircleIcon,
},
skipped: {
label: "Skipped",
className:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
icon: MinusCircleIcon,
},
};
function RunStatusBadge({ status }: { status: string }) {
const cfg = RUN_STATUS_CONFIG[status] ?? {
label: status,
className: "bg-secondary text-secondary-foreground border-transparent",
icon: ActivityIcon,
};
const Icon = cfg.icon;
return (
<Badge variant="secondary" className={cfg.className}>
<Icon className="size-3 mr-0.5" />
{cfg.label}
</Badge>
);
}
type FilterValue = "success" | "paused" | "failed" | "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "success", label: "Success" },
{ value: "paused", label: "Paused" },
{ value: "failed", label: "Failed" },
{ value: "archived", label: "Archived" },
];
// Partial runs (some recipients ok, some failed) surface under BOTH the
// Paused and Failed tabs — the operator wants to see anything that didn't
// fully succeed on either page. Skipped runs collapse into Archived since
// they're effectively "history that the operator chose not to send".
const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
success: ["success"],
paused: ["paused", "partial"],
failed: ["failed", "partial"],
};
interface PageProps {
searchParams: Promise<{ filter?: string }>;
}
interface ShelfButtonProps {
runId: string;
isArchived: boolean;
}
/**
* Left-shelf (revealed by swiping the row RIGHT). Hard-delete button.
* iOS-Mail-style: destructive action lives on the leading edge.
*/
function DeleteShelfButton({ runId }: ShelfButtonProps) {
return (
<form action={deleteRunAction} className="flex w-full">
<input type="hidden" name="runId" value={runId} />
<button
type="submit"
aria-label="Delete"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium"
>
<Trash2Icon className="size-4" />
Delete
</button>
</form>
);
}
/**
* Right-shelf (revealed by swiping the row LEFT). Archive (or Restore
* when the row is already archived). Non-destructive trailing action.
*/
function ArchiveShelfButton({ runId, isArchived }: ShelfButtonProps) {
return (
<form
action={isArchived ? unarchiveRunAction : archiveRunAction}
className="flex w-full"
>
<input type="hidden" name="runId" value={runId} />
<button
type="submit"
aria-label={isArchived ? "Restore" : "Archive"}
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30 text-xs font-medium"
>
{isArchived ? (
<ArchiveRestoreIcon className="size-4" />
) : (
<ArchiveIcon className="size-4" />
)}
{isArchived ? "Restore" : "Archive"}
</button>
</form>
);
}
export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filter: FilterValue =
sp.filter === "success" ||
sp.filter === "paused" ||
sp.filter === "failed" ||
sp.filter === "archived"
? sp.filter
: "success";
const showingArchived = filter === "archived";
const op = await getSeededOperator();
const runs = await listActivityRuns(op.id, { archived: showingArchived });
const filtered =
filter === "archived"
? runs
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
const hasAny = runs.length > 0;
return (
<PageShell title="Activity">
{/* Filter tabs span the full row and wrap onto a second line when the
viewport can't fit them all. Each trigger has a small basis so they
share space evenly while still keeping a readable label on mobile. */}
<Tabs value={filter}>
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger
key={value}
value={value}
asChild
className="h-8 grow basis-20"
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/activity?filter=${value}` as any}>
{label}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{filtered.length > 0 ? (
<>
<p className="text-xs text-muted-foreground sm:hidden">
Swipe left to Delete, or right to {showingArchived ? "Restore" : "Archive"}.
</p>
{/* Mobile: swipeable cards */}
<div className="flex flex-col gap-2 sm:hidden">
{filtered.map((run) => {
const clickable = run.reminderId && !run.isDeleted;
const inner = (
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{run.reminderName}
{run.isDeleted && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
(deleted)
</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{relativeTime(run.firedAt)}
</p>
</div>
<RunStatusBadge status={run.status} />
</CardContent>
);
const card = (
<Card
size="sm"
className={
clickable
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer rounded-none border-0 ring-0"
: "rounded-none border-0 ring-0"
}
>
{clickable ? (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminderId}` as any}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{inner}
</Link>
) : (
inner
)}
</Card>
);
return (
<SwipeableRow
// Key includes the archived flag so flipping it
// remounts the row with a fresh offset (closed shelf).
key={`${run.id}-${run.archivedAt ? "1" : "0"}`}
// Right swipe → reveal left shelf → Archive (non-destructive).
leftActions={
<ArchiveShelfButton runId={run.id} isArchived={Boolean(run.archivedAt)} />
}
// Left swipe → reveal right shelf → Delete (destructive).
rightActions={<DeleteShelfButton runId={run.id} isArchived={Boolean(run.archivedAt)} />}
>
{card}
</SwipeableRow>
);
})}
</div>
{/* Desktop: table with hover-revealed actions */}
<div className="hidden sm:block">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Reminder</TableHead>
<TableHead>Status</TableHead>
<TableHead>Fired</TableHead>
<TableHead className="w-1 text-right pr-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((run) => {
const clickable = run.reminderId && !run.isDeleted;
const isArchived = Boolean(run.archivedAt);
return (
<TableRow
key={run.id}
className={clickable ? "hover:bg-muted/50" : undefined}
>
<TableCell className="font-medium">
{clickable ? (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminderId}` as any}
className="block focus-visible:outline-none focus-visible:underline"
>
{run.reminderName}
</Link>
) : (
<span className="text-muted-foreground italic">
{run.reminderName}
{run.isDeleted && " (deleted)"}
</span>
)}
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{relativeTime(run.firedAt)}
</TableCell>
<TableCell className="text-right pr-2 whitespace-nowrap">
<div className="inline-flex items-center gap-0.5">
{run.status === "paused" && (
<ResumeRunButton runId={run.id} />
)}
<form
action={
isArchived ? unarchiveRunAction : archiveRunAction
}
>
<input type="hidden" name="runId" value={run.id} />
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label={isArchived ? "Restore" : "Archive"}
className="text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400"
>
{isArchived ? (
<ArchiveRestoreIcon className="size-4" />
) : (
<ArchiveIcon className="size-4" />
)}
</Button>
</form>
<form action={deleteRunAction}>
<input type="hidden" name="runId" value={run.id} />
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label="Delete"
className="text-muted-foreground hover:text-destructive"
>
<Trash2Icon className="size-4" />
</Button>
</form>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<EmptyState
icon={ActivityIcon}
title={
showingArchived ? "No archived runs." : `No ${filter} runs yet.`
}
description={
hasAny
? "Runs in other states aren't shown by this filter."
: "Reminder fire events will appear here."
}
/>
)}
</PageShell>
);
}