From c493101b6024fb7d22085262554d251a60d8bb37 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 18:46:29 +0800 Subject: [PATCH] feat(web): password policy, sign-out, dashboard isolation, activity tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/actions/users.test.ts | 29 ++++++- apps/web/src/actions/users.ts | 16 ++-- apps/web/src/app/activity/page.tsx | 61 ++------------- apps/web/src/app/layout.tsx | 3 +- apps/web/src/app/settings/page.tsx | 10 ++- .../settings/users/add-user-form-client.tsx | 2 +- .../app/settings/users/user-row-client.tsx | 5 +- apps/web/src/components/app-shell.test.tsx | 49 ++++++------ apps/web/src/components/app-shell.tsx | 76 ++++++++++++++++--- apps/web/src/lib/password-policy.test.ts | 69 +++++++++++++++++ apps/web/src/lib/password-policy.ts | 37 +++++++++ apps/web/src/lib/queries.ts | 15 +++- packages/db/src/scripts/set-password.ts | 18 ++++- 13 files changed, 274 insertions(+), 116 deletions(-) create mode 100644 apps/web/src/lib/password-policy.test.ts create mode 100644 apps/web/src/lib/password-policy.ts diff --git a/apps/web/src/actions/users.test.ts b/apps/web/src/actions/users.test.ts index a493efc..25e76ad 100644 --- a/apps/web/src/actions/users.test.ts +++ b/apps/web/src/actions/users.test.ts @@ -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); }); }); diff --git a/apps/web/src/actions/users.ts b/apps/web/src/actions/users.ts index 1498ca0..0dcbd28 100644 --- a/apps/web/src/actions/users.ts +++ b/apps/web/src/actions/users.ts @@ -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), }); diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx index 890d9bc..e2a3b6f 100644 --- a/apps/web/src/app/activity/page.tsx +++ b/apps/web/src/app/activity/page.tsx @@ -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, string[]> = { +const FILTER_STATUSES: Record, 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 ( - - - - - - - Clear all run history? - - This permanently removes every reminder run record, including - runs from reminders that have already been deleted. Reminders - themselves are not affected. - - - -
- -
-
-
- - ) : undefined - } - > + {/* 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 */} - + {label} @@ -420,11 +377,7 @@ export default async function ActivityPage({ searchParams }: PageProps) { / attributes after the @@ -52,7 +53,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo - {children} + {children} {/* SSE → browser notification bridge. Renders no DOM. */} diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 47daa2f..7cd9e43 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -17,13 +17,15 @@ export default async function SettingsPage() { Operator - - - - + {isAdmin && ( + <> + + + + )} diff --git a/apps/web/src/app/settings/users/add-user-form-client.tsx b/apps/web/src/app/settings/users/add-user-form-client.tsx index c032bb4..e9aa537 100644 --- a/apps/web/src/app/settings/users/add-user-form-client.tsx +++ b/apps/web/src/app/settings/users/add-user-form-client.tsx @@ -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" /> diff --git a/apps/web/src/app/settings/users/user-row-client.tsx b/apps/web/src/app/settings/users/user-row-client.tsx index fe52d3a..bff154a 100644 --- a/apps/web/src/app/settings/users/user-row-client.tsx +++ b/apps/web/src/app/settings/users/user-row-client.tsx @@ -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)
setResetPw(e.target.value)} maxLength={256} @@ -176,7 +177,7 @@ export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps) +
+ ); +} + +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[] }) { + +
+ +
@@ -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[] }) { })} - {/* Footer: theme toggle */} + {/* Footer: signed-in user + sign-out */}
- +
); @@ -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 */} - + {/* Mobile header (single row: brand · title · menu) */} - + {/* Main content Mobile: push down for the h-14 header (56px) plus a small gap diff --git a/apps/web/src/lib/password-policy.test.ts b/apps/web/src/lib/password-policy.test.ts new file mode 100644 index 0000000..eb25d24 --- /dev/null +++ b/apps/web/src/lib/password-policy.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/password-policy.ts b/apps/web/src/lib/password-policy.ts new file mode 100644 index 0000000..2befe77 --- /dev/null +++ b/apps/web/src/lib/password-policy.ts @@ -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 }; +} diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index ae57880..9ebe1b7 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -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 diff --git a/packages/db/src/scripts/set-password.ts b/packages/db/src/scripts/set-password.ts index 53130f5..e777cd1 100644 --- a/packages/db/src/scripts/set-password.ts +++ b/packages/db/src/scripts/set-password.ts @@ -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);