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>
This commit is contained in:
yiekheng 2026-05-10 18:46:29 +08:00
parent b92ead3a97
commit c493101b60
13 changed files with 274 additions and 116 deletions

View File

@ -81,7 +81,7 @@ describe("createUserAction", () => {
insertReturningMock.mockResolvedValue([{ id: USER.id }]); insertReturningMock.mockResolvedValue([{ id: USER.id }]);
const r = await createUserAction({ const r = await createUserAction({
username: "bob", username: "bob",
password: "longenoughpw", password: "longpw1",
role: "user", role: "user",
}); });
expect(r).toEqual({ ok: true, userId: USER.id }); expect(r).toEqual({ ok: true, userId: USER.id });
@ -155,7 +155,7 @@ describe("resetUserPasswordAction", () => {
it("admin can reset another user's password", async () => { it("admin can reset another user's password", async () => {
requireAdminMock.mockResolvedValue(ADMIN); requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER); findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "longenoughpw" }); const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "alpha7!" });
expect(r).toEqual({ ok: true }); expect(r).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalled(); expect(updateMock).toHaveBeenCalled();
}); });
@ -163,7 +163,30 @@ describe("resetUserPasswordAction", () => {
it("rejects too-short passwords", async () => { it("rejects too-short passwords", async () => {
requireAdminMock.mockResolvedValue(ADMIN); requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER); findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "short" }); const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "ab1" });
expect(r.ok).toBe(false);
});
it("rejects letters-only passwords (no number or symbol)", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({
userId: USER.id,
newPassword: "abcdefghij",
});
expect(r).toEqual({
ok: false,
error: "Password must mix letters with numbers or symbols.",
});
});
it("rejects digits-only passwords", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({
userId: USER.id,
newPassword: "1234567890",
});
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
}); });

View File

@ -8,8 +8,8 @@ import { operators } from "@cmbot/db";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { requireAdmin } from "@/lib/auth"; import { requireAdmin } from "@/lib/auth";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/lib/rate-limit";
import { validatePassword } from "@/lib/password-policy";
const MIN_PASSWORD_LEN = 10;
const MAX_FIELD_LEN = 256; const MAX_FIELD_LEN = 256;
async function rateLimit(key: string): Promise<{ limited: boolean }> { async function rateLimit(key: string): Promise<{ limited: boolean }> {
@ -35,9 +35,8 @@ export async function createUserAction(input: {
if (u.length < 3 || u.length > MAX_FIELD_LEN) { if (u.length < 3 || u.length > MAX_FIELD_LEN) {
return { ok: false, error: "Username must be 3..256 chars." }; return { ok: false, error: "Username must be 3..256 chars." };
} }
if (!input.password || input.password.length < MIN_PASSWORD_LEN || input.password.length > MAX_FIELD_LEN) { const pwCheck = validatePassword(input.password);
return { ok: false, error: `Password must be at least ${MIN_PASSWORD_LEN} chars.` }; if (!pwCheck.ok) return pwCheck;
}
if (input.role !== "admin" && input.role !== "user") { if (input.role !== "admin" && input.role !== "user") {
return { ok: false, error: "Role must be admin or user." }; return { ok: false, error: "Role must be admin or user." };
} }
@ -124,13 +123,8 @@ export async function resetUserPasswordAction(input: {
await requireAdmin(); await requireAdmin();
const rl = await rateLimit("reset-password"); const rl = await rateLimit("reset-password");
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." }; if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
if ( const pwCheck = validatePassword(input.newPassword);
!input.newPassword || if (!pwCheck.ok) return pwCheck;
input.newPassword.length < MIN_PASSWORD_LEN ||
input.newPassword.length > MAX_FIELD_LEN
) {
return { ok: false, error: `Password must be at least ${MIN_PASSWORD_LEN} chars.` };
}
const target = await db.query.operators.findFirst({ const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId), where: (o, { eq: dEq }) => dEq(o.id, input.userId),
}); });

View File

@ -14,15 +14,6 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
Table, Table,
TableBody, TableBody,
@ -38,7 +29,6 @@ import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries"; import { listActivityRuns } from "@/lib/queries";
import { import {
archiveRunAction, archiveRunAction,
clearHistoryAction,
deleteRunAction, deleteRunAction,
unarchiveRunAction, unarchiveRunAction,
} from "@/actions/history"; } from "@/actions/history";
@ -106,9 +96,8 @@ function RunStatusBadge({ status }: { status: string }) {
); );
} }
type FilterValue = "all" | "success" | "paused" | "failed" | "archived"; type FilterValue = "success" | "paused" | "failed" | "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" }, { value: "success", label: "Success" },
{ value: "paused", label: "Paused" }, { value: "paused", label: "Paused" },
{ value: "failed", label: "Failed" }, { value: "failed", label: "Failed" },
@ -119,7 +108,7 @@ const FILTER_TABS: { value: FilterValue; label: string }[] = [
// Paused and Failed tabs — the operator wants to see anything that didn't // Paused and Failed tabs — the operator wants to see anything that didn't
// fully succeed on either page. Skipped runs collapse into Archived since // fully succeed on either page. Skipped runs collapse into Archived since
// they're effectively "history that the operator chose not to send". // they're effectively "history that the operator chose not to send".
const FILTER_STATUSES: Record<Exclude<FilterValue, "all" | "archived">, string[]> = { const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
success: ["success"], success: ["success"],
paused: ["paused", "partial"], paused: ["paused", "partial"],
failed: ["failed", "partial"], failed: ["failed", "partial"],
@ -189,51 +178,19 @@ export default async function ActivityPage({ searchParams }: PageProps) {
sp.filter === "failed" || sp.filter === "failed" ||
sp.filter === "archived" sp.filter === "archived"
? sp.filter ? sp.filter
: "all"; : "success";
const showingArchived = filter === "archived"; const showingArchived = filter === "archived";
const op = await getSeededOperator(); const op = await getSeededOperator();
const runs = await listActivityRuns(op.id, { archived: showingArchived }); const runs = await listActivityRuns(op.id, { archived: showingArchived });
const filtered = const filtered =
filter === "all" || filter === "archived" filter === "archived"
? runs ? runs
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status)); : runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
const hasAny = runs.length > 0; const hasAny = runs.length > 0;
return ( return (
<PageShell <PageShell title="Activity">
title="Activity"
action={
hasAny && !showingArchived ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
<Trash2Icon />
Clear history
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear all run history?</DialogTitle>
<DialogDescription>
This permanently removes every reminder run record, including
runs from reminders that have already been deleted. Reminders
themselves are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, clear history
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
) : undefined
}
>
{/* Filter tabs span the full row and wrap onto a second line when the {/* 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 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. */} share space evenly while still keeping a readable label on mobile. */}
@ -247,7 +204,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
className="h-8 grow basis-20" className="h-8 grow basis-20"
> >
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}> <Link href={`/activity?filter=${value}` as any}>
{label} {label}
</Link> </Link>
</TabsTrigger> </TabsTrigger>
@ -420,11 +377,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
<EmptyState <EmptyState
icon={ActivityIcon} icon={ActivityIcon}
title={ title={
filter === "all" showingArchived ? "No archived runs." : `No ${filter} runs yet.`
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`
} }
description={ description={
hasAny hasAny

View File

@ -40,6 +40,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
// AppShell short-circuits to the bare header anyway. // AppShell short-circuits to the bare header anyway.
const me = await getCurrentUser(); const me = await getCurrentUser();
const role = me?.role ?? null; const role = me?.role ?? null;
const username = me?.username ?? null;
return ( return (
// `suppressHydrationWarning` here is for *attribute* differences only. // `suppressHydrationWarning` here is for *attribute* differences only.
// Two sources legitimately mutate <html>/<body> attributes after the // Two sources legitimately mutate <html>/<body> attributes after the
@ -52,7 +53,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<html lang="en" suppressHydrationWarning className={GeistSans.className}> <html lang="en" suppressHydrationWarning className={GeistSans.className}>
<body suppressHydrationWarning> <body suppressHydrationWarning>
<ThemeProvider> <ThemeProvider>
<AppShell role={role}>{children}</AppShell> <AppShell role={role} username={username}>{children}</AppShell>
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
{/* SSE → browser notification bridge. Renders no DOM. */} {/* SSE → browser notification bridge. Renders no DOM. */}
<NotificationManager /> <NotificationManager />

View File

@ -17,13 +17,15 @@ export default async function SettingsPage() {
<CardTitle>Operator</CardTitle> <CardTitle>Operator</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<Row label="Display name" value={op.displayName} />
<Separator />
<Row label="Username" value={op.username} mono /> <Row label="Username" value={op.username} mono />
<Separator /> <Separator />
<Row label="Default timezone" value={op.defaultTimezone} mono /> <Row label="Default timezone" value={op.defaultTimezone} mono />
{isAdmin && (
<>
<Separator /> <Separator />
<Row label="Role" value={op.role} mono /> <Row label="Role" value={op.role} mono />
</>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -58,7 +58,7 @@ export function AddUserFormClient() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
maxLength={256} maxLength={256}
placeholder="≥10 characters" placeholder="≥6 chars · letters + number/symbol"
/> />
</div> </div>
</div> </div>

View File

@ -26,6 +26,7 @@ import {
resetUserPasswordAction, resetUserPasswordAction,
deleteUserAction, deleteUserAction,
} from "@/actions/users"; } from "@/actions/users";
import { validatePassword } from "@/lib/password-policy";
interface UserRowClientProps { interface UserRowClientProps {
user: { id: string; username: string; role: "admin" | "user" }; user: { id: string; username: string; role: "admin" | "user" };
@ -168,7 +169,7 @@ export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps)
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
type="password" type="password"
placeholder="New password (≥10 chars)" placeholder="New password (≥6 chars · letters + number/symbol)"
value={resetPw} value={resetPw}
onChange={(e) => setResetPw(e.target.value)} onChange={(e) => setResetPw(e.target.value)}
maxLength={256} maxLength={256}
@ -176,7 +177,7 @@ export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps)
<Button <Button
type="button" type="button"
size="sm" size="sm"
disabled={pending || resetPw.length < 10} disabled={pending || !validatePassword(resetPw).ok}
onClick={() => { onClick={() => {
run( run(
resetUserPasswordAction({ resetUserPasswordAction({

View File

@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("renders a fixed top header that hides on sm+ breakpoints", () => { it("renders a fixed top header that hides on sm+ breakpoints", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<main>page</main> <main>page</main>
</AppShell>, </AppShell>,
); );
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("brand mark on the left links to /", () => { it("brand mark on the left links to /", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
for (const c of cases) { for (const c of cases) {
pathnameMock.mockReturnValue(c.path); pathnameMock.mockReturnValue(c.path);
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -104,7 +104,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => { it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
pathnameMock.mockReturnValue("/unknown-route"); pathnameMock.mockReturnValue("/unknown-route");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("menu button on the right uses aria-label='Open menu'", () => { it("menu button on the right uses aria-label='Open menu'", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -134,7 +134,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("renders one nav link per NAV_ITEM, in order", () => { it("renders one nav link per NAV_ITEM, in order", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -157,7 +157,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("marks the active route's link with aria-current='page'", () => { it("marks the active route's link with aria-current='page'", () => {
pathnameMock.mockReturnValue("/reminders"); pathnameMock.mockReturnValue("/reminders");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -174,7 +174,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
// every page. The header uses an exact-match check for "/". // every page. The header uses an exact-match check for "/".
pathnameMock.mockReturnValue("/accounts"); pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -185,7 +185,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("does NOT include a theme toggle in the mobile drawer (per request)", () => { it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -195,7 +195,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("drawer header carries the brand wording and a screen-reader description", () => { it("drawer header carries the brand wording and a screen-reader description", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
it("renders the sidebar nav with every NAV_ITEM", () => { it("renders the sidebar nav with every NAV_ITEM", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -232,21 +232,22 @@ describe("AppShell — desktop sidebar (SSR)", () => {
} }
}); });
it("keeps the theme toggle in the sidebar footer", () => { it("renders a Sign out button in the sidebar footer", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must // Theme toggle was dropped from the shell per request; the footer
// appear in the sidebar (we removed it from the mobile drawer). // now carries the Sign out affordance + the signed-in username.
expect(html).toContain('data-testid="theme-toggle"'); expect(html).toContain('aria-label="Sign out"');
expect(html).toContain("admin");
}); });
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => { it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
pathnameMock.mockReturnValue("/accounts"); pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -264,7 +265,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
// reader users on a wide-window split-screen don't hear two // reader users on a wide-window split-screen don't hear two
// identical announcements when both are visible. // identical announcements when both are visible.
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -284,7 +285,7 @@ describe("AppShell — role-based nav filtering", () => {
it("shows the Admin entry in BOTH the sidebar and drawer when role=admin", () => { it("shows the Admin entry in BOTH the sidebar and drawer when role=admin", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -297,7 +298,7 @@ describe("AppShell — role-based nav filtering", () => {
it("hides the Admin entry from BOTH surfaces when role=user", () => { it("hides the Admin entry from BOTH surfaces when role=user", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="user"> <AppShell role="user" username="alice">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -307,7 +308,7 @@ describe("AppShell — role-based nav filtering", () => {
it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => { it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => {
pathnameMock.mockReturnValue("/accounts"); pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role={null}> <AppShell role={null} username={null}>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -316,12 +317,12 @@ describe("AppShell — role-based nav filtering", () => {
it("does NOT hide entries that have no visibleTo restriction for either role", () => { it("does NOT hide entries that have no visibleTo restriction for either role", () => {
const adminHtml = renderToStaticMarkup( const adminHtml = renderToStaticMarkup(
<AppShell role="admin"> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
const userHtml = renderToStaticMarkup( const userHtml = renderToStaticMarkup(
<AppShell role="user"> <AppShell role="user" username="alice">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -335,7 +336,7 @@ describe("AppShell — role-based nav filtering", () => {
it("/login route renders the bare header — no nav, no drawer, regardless of role", () => { it("/login route renders the bare header — no nav, no drawer, regardless of role", () => {
pathnameMock.mockReturnValue("/login"); pathnameMock.mockReturnValue("/login");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role={null}> <AppShell role={null} username={null}>
<div /> <div />
</AppShell>, </AppShell>,
); );

View File

@ -1,11 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useTransition } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { MenuIcon } from "lucide-react"; import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { logoutAction } from "@/actions/auth";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -20,7 +21,6 @@ import {
type NavItem, type NavItem,
type NavRole, type NavRole,
} from "@/components/nav-config"; } from "@/components/nav-config";
import { ThemeToggle } from "@/components/theme-toggle";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mobile header (sm:hidden) // Mobile header (sm:hidden)
@ -35,7 +35,49 @@ import { ThemeToggle } from "@/components/theme-toggle";
// waiting for the page content to render. The menu button on the right // waiting for the page content to render. The menu button on the right
// opens a Sheet with the full nav list and the theme toggle. // opens a Sheet with the full nav list and the theme toggle.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function MobileHeader({ items }: { items: NavItem[] }) { // ---------------------------------------------------------------------------
// Sign-out button used by both the desktop sidebar footer and the mobile
// drawer footer. Server-action under the hood: clears the session
// cookie and redirects to /login. Disabled while in flight so a
// double-click doesn't fire two redirects.
// ---------------------------------------------------------------------------
function SignOutButton({ username }: { username: string | null }) {
const [pending, start] = useTransition();
return (
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
{username && (
<p className="text-xs text-muted-foreground truncate" aria-label="Signed in as">
{username}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
onClick={() => start(() => logoutAction())}
aria-label="Sign out"
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LogOutIcon className="size-4" />
)}
Sign out
</Button>
</div>
);
}
function MobileHeader({
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname(); const pathname = usePathname();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -99,7 +141,7 @@ function MobileHeader({ items }: { items: NavItem[] }) {
<nav <nav
aria-label="Primary navigation" aria-label="Primary navigation"
className="flex flex-col gap-0.5 p-2 flex-1" className="flex flex-col gap-0.5 p-2"
> >
{items.map(({ key, href, label, icon: Icon }) => { {items.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
@ -126,6 +168,10 @@ function MobileHeader({ items }: { items: NavItem[] }) {
); );
})} })}
</nav> </nav>
<div className="mt-auto border-t border-border p-3">
<SignOutButton username={username} />
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</header> </header>
@ -135,7 +181,13 @@ function MobileHeader({ items }: { items: NavItem[] }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm) // Sidebar (desktop only — hidden below sm)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function Sidebar({ items }: { items: NavItem[] }) { function Sidebar({
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
@ -181,9 +233,9 @@ function Sidebar({ items }: { items: NavItem[] }) {
})} })}
</nav> </nav>
{/* Footer: theme toggle */} {/* Footer: signed-in user + sign-out */}
<div className="border-t border-sidebar-border p-3"> <div className="border-t border-sidebar-border p-3">
<ThemeToggle /> <SignOutButton username={username} />
</div> </div>
</aside> </aside>
); );
@ -220,9 +272,11 @@ interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
/** Role of the signed-in user, or null when unauthenticated. */ /** Role of the signed-in user, or null when unauthenticated. */
role: NavRole | null; role: NavRole | null;
/** Username of the signed-in user, surfaced in the footer + sign-out hint. */
username: string | null;
} }
export function AppShell({ children, role }: AppShellProps) { export function AppShell({ children, role, username }: AppShellProps) {
const pathname = usePathname(); const pathname = usePathname();
const isAuthRoute = pathname === "/login"; const isAuthRoute = pathname === "/login";
@ -243,10 +297,10 @@ export function AppShell({ children, role }: AppShellProps) {
return ( return (
<> <>
{/* Desktop sidebar */} {/* Desktop sidebar */}
<Sidebar items={items} /> <Sidebar items={items} username={username} />
{/* Mobile header (single row: brand · title · menu) */} {/* Mobile header (single row: brand · title · menu) */}
<MobileHeader items={items} /> <MobileHeader items={items} username={username} />
{/* Main content {/* Main content
Mobile: push down for the h-14 header (56px) plus a small gap Mobile: push down for the h-14 header (56px) plus a small gap

View File

@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import {
validatePassword,
MIN_PASSWORD_LEN,
MAX_PASSWORD_LEN,
} from "./password-policy";
describe("validatePassword", () => {
it("accepts the canonical mixed-case + digit example", () => {
expect(validatePassword("hengs3rver").ok).toBe(true);
});
it("accepts the bare minimum length with a number", () => {
// 6 chars, letter + digit. Boundary case for MIN_PASSWORD_LEN.
expect(validatePassword("abc12!").ok).toBe(true);
});
it("accepts symbols in place of digits", () => {
expect(validatePassword("abcde!").ok).toBe(true);
});
it("rejects passwords shorter than the minimum", () => {
const r = validatePassword("ab1!");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/at least 6/);
});
it("rejects letters-only passwords", () => {
const r = validatePassword("abcdefgh");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/letters with numbers or symbols/);
});
it("rejects digits-only passwords", () => {
const r = validatePassword("12345678");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/letters/);
});
it("rejects symbols-only passwords (no letters)", () => {
const r = validatePassword("!!!!!!!!");
expect(r.ok).toBe(false);
});
it("rejects passwords longer than MAX_PASSWORD_LEN", () => {
const tooLong = "a".repeat(MAX_PASSWORD_LEN) + "1";
const r = validatePassword(tooLong);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/too long/);
});
it("rejects empty input", () => {
expect(validatePassword("").ok).toBe(false);
});
it("rejects non-string input defensively", () => {
// Server actions are typed but a malformed FormData payload could land
// here as null/undefined; the validator must not throw.
// @ts-expect-error - defensive runtime guard
expect(validatePassword(null).ok).toBe(false);
// @ts-expect-error - defensive runtime guard
expect(validatePassword(undefined).ok).toBe(false);
});
it("exposes the documented Facebook-aligned thresholds", () => {
expect(MIN_PASSWORD_LEN).toBe(6);
expect(MAX_PASSWORD_LEN).toBe(256);
});
});

View File

@ -0,0 +1,37 @@
/**
* Password policy modeled after Facebook's documented requirement
* (https://www.facebook.com/help/124904560921566): at least 6
* characters, with a recommended mix of letters and numbers/punctuation.
*
* We enforce the hard minimum (6) and the recommended-mix rule on
* password creation/reset (admin-only flows). Sign-in itself stays
* permissive old short passwords keep working until they're reset
* since rejecting them at login would lock people out without a recovery
* path.
*/
export const MIN_PASSWORD_LEN = 6;
export const MAX_PASSWORD_LEN = 256;
export type PasswordCheck = { ok: true } | { ok: false; error: string };
export function validatePassword(pw: string): PasswordCheck {
if (typeof pw !== "string" || pw.length < MIN_PASSWORD_LEN) {
return {
ok: false,
error: `Password must be at least ${MIN_PASSWORD_LEN} characters.`,
};
}
if (pw.length > MAX_PASSWORD_LEN) {
return { ok: false, error: "Password is too long." };
}
const hasLetter = /[A-Za-z]/.test(pw);
const hasNonLetter = /[^A-Za-z]/.test(pw);
if (!hasLetter || !hasNonLetter) {
return {
ok: false,
error: "Password must mix letters with numbers or symbols.",
};
}
return { ok: true };
}

View File

@ -6,9 +6,18 @@ export async function getDashboardStats(operatorId: string) {
const accounts = await db.query.whatsappAccounts.findMany({ const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId), where: (a, { eq }) => eq(a.operatorId, operatorId),
}); });
// All reminder rows so the dashboard can show active/total in one query. // Reminders scoped to this operator's accounts. The previous
// Status enum today is active / ended (paused will join in a later phase). // findMany() with no filter leaked global counts across users — a
const allReminders = await db.query.reminders.findMany(); // brand-new user would see another operator's totals on the
// dashboard. INNER JOIN on whatsapp_accounts.operator_id keeps each
// user's view isolated.
const reminderRows = await db.execute(sql`
SELECT r.id, r.status
FROM reminders r
INNER JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
`);
const allReminders = reminderRows.rows as Array<{ id: string; status: string }>;
// LEFT JOIN so runs whose reminder has been deleted still appear. The // LEFT JOIN so runs whose reminder has been deleted still appear. The
// ownership filter widens to: either the reminder still exists and the // ownership filter widens to: either the reminder still exists and the
// operator owns its account, OR the reminder is gone but the run row // operator owns its account, OR the reminder is gone but the run row

View File

@ -22,8 +22,22 @@ async function main() {
const password = await rl.question(""); const password = await rl.question("");
rl.close(); rl.close();
process.stdout.write("\n"); process.stdout.write("\n");
if (password.length < 10) { // Mirrors apps/web/src/lib/password-policy.ts so the CLI bootstrap
console.error("Password must be at least 10 characters."); // path and the server actions stay in sync. Facebook's documented
// minimum is 6 chars, with a recommended mix of letters and
// numbers/punctuation.
if (password.length < 6) {
console.error("Password must be at least 6 characters.");
process.exit(2);
}
if (password.length > 256) {
console.error("Password is too long.");
process.exit(2);
}
const hasLetter = /[A-Za-z]/.test(password);
const hasNonLetter = /[^A-Za-z]/.test(password);
if (!hasLetter || !hasNonLetter) {
console.error("Password must mix letters with numbers or symbols.");
process.exit(2); process.exit(2);
} }
const hash = await bcrypt.hash(password, 12); const hash = await bcrypt.hash(password, 12);