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>
392 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|