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 }]);
const r = await createUserAction({
username: "bob",
password: "longenoughpw",
password: "longpw1",
role: "user",
});
expect(r).toEqual({ ok: true, userId: USER.id });
@ -155,7 +155,7 @@ describe("resetUserPasswordAction", () => {
it("admin can reset another user's password", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
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(updateMock).toHaveBeenCalled();
});
@ -163,7 +163,30 @@ describe("resetUserPasswordAction", () => {
it("rejects too-short passwords", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
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);
});
});

View File

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

View File

@ -14,15 +14,6 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
@ -38,7 +29,6 @@ import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries";
import {
archiveRunAction,
clearHistoryAction,
deleteRunAction,
unarchiveRunAction,
} 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 }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "paused", label: "Paused" },
{ 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
// 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, "all" | "archived">, string[]> = {
const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
success: ["success"],
paused: ["paused", "partial"],
failed: ["failed", "partial"],
@ -189,51 +178,19 @@ export default async function ActivityPage({ searchParams }: PageProps) {
sp.filter === "failed" ||
sp.filter === "archived"
? sp.filter
: "all";
: "success";
const showingArchived = filter === "archived";
const op = await getSeededOperator();
const runs = await listActivityRuns(op.id, { archived: showingArchived });
const filtered =
filter === "all" || filter === "archived"
filter === "archived"
? runs
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
const hasAny = runs.length > 0;
return (
<PageShell
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
}
>
<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. */}
@ -247,7 +204,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
className="h-8 grow basis-20"
>
{/* 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}
</Link>
</TabsTrigger>
@ -420,11 +377,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
<EmptyState
icon={ActivityIcon}
title={
filter === "all"
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`
showingArchived ? "No archived runs." : `No ${filter} runs yet.`
}
description={
hasAny

View File

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

View File

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

View File

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

View File

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

View File

@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("renders a fixed top header that hides on sm+ breakpoints", () => {
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<main>page</main>
</AppShell>,
);
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("brand mark on the left links to /", () => {
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
for (const c of cases) {
pathnameMock.mockReturnValue(c.path);
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</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", () => {
pathnameMock.mockReturnValue("/unknown-route");
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("menu button on the right uses aria-label='Open menu'", () => {
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</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", () => {
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</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'", () => {
pathnameMock.mockReturnValue("/reminders");
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</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 "/".
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</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)", () => {
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</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", () => {
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
it("renders the sidebar nav with every NAV_ITEM", () => {
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</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(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
// appear in the sidebar (we removed it from the mobile drawer).
expect(html).toContain('data-testid="theme-toggle"');
// Theme toggle was dropped from the shell per request; the footer
// now carries the Sign out affordance + the signed-in username.
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", () => {
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
@ -264,7 +265,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
// reader users on a wide-window split-screen don't hear two
// identical announcements when both are visible.
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</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", () => {
const html = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
@ -297,7 +298,7 @@ describe("AppShell — role-based nav filtering", () => {
it("hides the Admin entry from BOTH surfaces when role=user", () => {
const html = renderToStaticMarkup(
<AppShell role="user">
<AppShell role="user" username="alice">
<div />
</AppShell>,
);
@ -307,7 +308,7 @@ describe("AppShell — role-based nav filtering", () => {
it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => {
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell role={null}>
<AppShell role={null} username={null}>
<div />
</AppShell>,
);
@ -316,12 +317,12 @@ describe("AppShell — role-based nav filtering", () => {
it("does NOT hide entries that have no visibleTo restriction for either role", () => {
const adminHtml = renderToStaticMarkup(
<AppShell role="admin">
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
const userHtml = renderToStaticMarkup(
<AppShell role="user">
<AppShell role="user" username="alice">
<div />
</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", () => {
pathnameMock.mockReturnValue("/login");
const html = renderToStaticMarkup(
<AppShell role={null}>
<AppShell role={null} username={null}>
<div />
</AppShell>,
);

View File

@ -1,11 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MenuIcon } from "lucide-react";
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { logoutAction } from "@/actions/auth";
import {
Sheet,
SheetContent,
@ -20,7 +21,6 @@ import {
type NavItem,
type NavRole,
} from "@/components/nav-config";
import { ThemeToggle } from "@/components/theme-toggle";
// ---------------------------------------------------------------------------
// 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
// 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 [open, setOpen] = useState(false);
@ -99,7 +141,7 @@ function MobileHeader({ items }: { items: NavItem[] }) {
<nav
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 }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
@ -126,6 +168,10 @@ function MobileHeader({ items }: { items: NavItem[] }) {
);
})}
</nav>
<div className="mt-auto border-t border-border p-3">
<SignOutButton username={username} />
</div>
</SheetContent>
</Sheet>
</header>
@ -135,7 +181,13 @@ function MobileHeader({ items }: { items: NavItem[] }) {
// ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm)
// ---------------------------------------------------------------------------
function Sidebar({ items }: { items: NavItem[] }) {
function Sidebar({
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname();
return (
@ -181,9 +233,9 @@ function Sidebar({ items }: { items: NavItem[] }) {
})}
</nav>
{/* Footer: theme toggle */}
{/* Footer: signed-in user + sign-out */}
<div className="border-t border-sidebar-border p-3">
<ThemeToggle />
<SignOutButton username={username} />
</div>
</aside>
);
@ -220,9 +272,11 @@ interface AppShellProps {
children: React.ReactNode;
/** Role of the signed-in user, or null when unauthenticated. */
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 isAuthRoute = pathname === "/login";
@ -243,10 +297,10 @@ export function AppShell({ children, role }: AppShellProps) {
return (
<>
{/* Desktop sidebar */}
<Sidebar items={items} />
<Sidebar items={items} username={username} />
{/* Mobile header (single row: brand · title · menu) */}
<MobileHeader items={items} />
<MobileHeader items={items} username={username} />
{/* Main content
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({
where: (a, { eq }) => eq(a.operatorId, operatorId),
});
// All reminder rows so the dashboard can show active/total in one query.
// Status enum today is active / ended (paused will join in a later phase).
const allReminders = await db.query.reminders.findMany();
// Reminders scoped to this operator's accounts. The previous
// findMany() with no filter leaked global counts across users — a
// 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
// ownership filter widens to: either the reminder still exists and the
// 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("");
rl.close();
process.stdout.write("\n");
if (password.length < 10) {
console.error("Password must be at least 10 characters.");
// Mirrors apps/web/src/lib/password-policy.ts so the CLI bootstrap
// 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);
}
const hash = await bcrypt.hash(password, 12);