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:
parent
b92ead3a97
commit
c493101b60
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
<Separator />
|
||||
<Row label="Role" value={op.role} mono />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<Row label="Role" value={op.role} mono />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>,
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
69
apps/web/src/lib/password-policy.test.ts
Normal file
69
apps/web/src/lib/password-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
37
apps/web/src/lib/password-policy.ts
Normal file
37
apps/web/src/lib/password-policy.ts
Normal 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 };
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user