Compare commits

..

No commits in common. "47d7c53fdabc78f49c0e0560bfc44ffb7ad5b154" and "4cb401566641f732f92543739b3911497f688108" have entirely different histories.

91 changed files with 427 additions and 11631 deletions

View File

@ -4,7 +4,7 @@ SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media MEDIA_DIR=/data/media
BOT_HEALTH_PORT=8081 BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=debug BOT_LOG_LEVEL=debug
SEED_OPERATOR_USERNAME=admin SEED_OPERATOR_TELEGRAM_ID=818380985
SEED_OPERATOR_NAME="yiekheng (dev)" SEED_OPERATOR_NAME="yiekheng (dev)"
WEB_PORT=9000 WEB_PORT=9000
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c

View File

@ -3,7 +3,7 @@ import type { Notification } from "pg";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { handleStartPairing } from "./pair-handler.js"; import { handleStartPairing } from "./pair-handler.js";
import { handleUnpair, handleDelete } from "./unpair-handler.js"; import { handleUnpair } from "./unpair-handler.js";
import { handleSyncGroups } from "./sync-groups-handler.js"; import { handleSyncGroups } from "./sync-groups-handler.js";
import { handleSendTest } from "./send-test-handler.js"; import { handleSendTest } from "./send-test-handler.js";
import { import {
@ -14,11 +14,6 @@ import {
export type BotCommand = export type BotCommand =
| { type: "account.start_pairing"; accountId: string } | { type: "account.start_pairing"; accountId: string }
| { type: "account.unpair"; accountId: string } | { type: "account.unpair"; accountId: string }
// Like unpair, but tells WhatsApp to drop this device from the
// user's linked-devices list first via socket.logout(). The web
// action calls this immediately before deleting the row so the
// operator's phone doesn't keep showing a phantom linked device.
| { type: "account.delete"; accountId: string }
| { type: "account.sync_groups"; accountId: string } | { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string } | { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string } | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
@ -79,9 +74,6 @@ export function registerDefaultHandlers(): void {
registerHandler("account.unpair", async (cmd) => { registerHandler("account.unpair", async (cmd) => {
await handleUnpair(cmd.accountId); await handleUnpair(cmd.accountId);
}); });
registerHandler("account.delete", async (cmd) => {
await handleDelete(cmd.accountId);
});
registerHandler("account.sync_groups", async (cmd) => { registerHandler("account.sync_groups", async (cmd) => {
await handleSyncGroups(cmd.accountId); await handleSyncGroups(cmd.accountId);
}); });

View File

@ -10,16 +10,6 @@ export type WebEvent =
| { type: "session.connected"; accountId: string; phoneNumber: string | null } | { type: "session.connected"; accountId: string; phoneNumber: string | null }
| { type: "session.disconnected"; accountId: string } | { type: "session.disconnected"; accountId: string }
| { type: "session.timeout"; accountId: string } | { type: "session.timeout"; accountId: string }
// Operator scanned the QR with a phone that's already linked to another
// account row. We park the new pairing instead of letting two account
// rows fight over the same WhatsApp device. existingLabel surfaces in
// the UI so the operator knows which account already owns the phone.
| {
type: "session.duplicate";
accountId: string;
phoneNumber: string;
existingLabel: string;
}
| { type: "groups.synced"; accountId: string; count: number } | { type: "groups.synced"; accountId: string; count: number }
| { | {
type: "reminder.fired"; type: "reminder.fired";

View File

@ -10,23 +10,11 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js";
import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
import { writeAuditLog } from "../audit.js"; import { writeAuditLog } from "../audit.js";
import { pgNotifyWeb } from "./notify.js"; import { pgNotifyWeb } from "./notify.js";
import {
decidePairListenerOnClose,
findDuplicateExistingAccount,
nextWarmingUpAfterEvent,
} from "./pair-state.js";
const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
const offByAccount = new Map<string, () => void>(); const offByAccount = new Map<string, () => void>();
const lastQrPayload = new Map<string, string>(); const lastQrPayload = new Map<string, string>();
const pairTimeouts = new Map<string, NodeJS.Timeout>(); const pairTimeouts = new Map<string, NodeJS.Timeout>();
// "Warming" set: while present, the just-attached listener will ignore
// close events. Cleared the moment a qr/open arrives. This prevents the
// old session's close (broadcast asynchronously by sessionManager after
// our await sessionManager.stop() returns) from being mis-read as the
// NEW session timing out — which manifested as: get QR → go back →
// click Pair again → instantly see "Pairing timed out".
const pairingWarmingUp = new Set<string>();
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> { async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
const account = await db.query.whatsappAccounts.findFirst({ const account = await db.query.whatsappAccounts.findFirst({
@ -46,7 +34,6 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
pairTimeouts.delete(accountId); pairTimeouts.delete(accountId);
} }
lastQrPayload.delete(accountId); lastQrPayload.delete(accountId);
pairingWarmingUp.delete(accountId);
if (sessionManager.hasSession(accountId)) { if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId); await sessionManager.stop(accountId);
} }
@ -93,17 +80,10 @@ export async function handleStartPairing(accountId: string): Promise<void> {
.set({ lastQrPng: null }) .set({ lastQrPng: null })
.where(eq(whatsappAccounts.id, accountId)); .where(eq(whatsappAccounts.id, accountId));
// Mark the new attempt as warming up. Cleared by the first qr/open we
// observe; while set, any close event is treated as the leaked tail of
// the previous session being torn down (see comment near
// `pairingWarmingUp` declaration).
pairingWarmingUp.add(accountId);
const off = sessionManager.on(async (id, _state, event) => { const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return; if (id !== accountId) return;
try { try {
if (event.type === "qr") { if (event.type === "qr") {
pairingWarmingUp.delete(id);
// Dedupe by payload — Baileys can re-emit the same QR string in a // Dedupe by payload — Baileys can re-emit the same QR string in a
// burst. Different strings (a fresh QR) always pass through, so // burst. Different strings (a fresh QR) always pass through, so
// the user gets a new QR as soon as Baileys generates one. // the user gets a new QR as soon as Baileys generates one.
@ -122,7 +102,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
ts: Date.now(), ts: Date.now(),
}); });
} else if (event.type === "open") { } else if (event.type === "open") {
pairingWarmingUp.delete(id);
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);
if (t) { if (t) {
clearTimeout(t); clearTimeout(t);
@ -130,53 +109,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
} }
lastQrPayload.delete(id); lastQrPayload.delete(id);
offByAccount.delete(id); offByAccount.delete(id);
// Duplicate-pair guard. Operator scanned the QR with a phone
// that's already linked to another account row. Letting both
// rows claim the same WhatsApp device confuses Baileys and
// turns sends into a coin flip — abandon this pairing and
// surface a clear message to the UI.
const siblings = await db.query.whatsappAccounts.findMany({
where: (a, { eq: dEq }) => dEq(a.operatorId, account.operatorId),
columns: { id: true, phoneNumber: true, label: true },
});
const dup = findDuplicateExistingAccount({
currentAccountId: id,
currentPhoneNumber: event.phoneNumber,
siblings,
});
if (dup) {
logger.warn(
{
accountId: id,
phoneNumber: event.phoneNumber,
existingAccountId: dup.existingAccountId,
existingLabel: dup.existingLabel,
},
"pair: duplicate phone — abandoning new pairing",
);
// Stop the duplicate session, scrub the partial auth blob,
// and reset the row's status. We DO NOT logout() here — the
// original account's session remains valid and the operator
// hasn't actually added a new linked device on the phone yet
// (it'd just be the freshly-completed scan, which Baileys
// hasn't yet committed to the WhatsApp side).
await sessionManager.stop(id, { intentional: true });
await rm(join(env.SESSIONS_DIR, id), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null, phoneNumber: null })
.where(eq(whatsappAccounts.id, id));
await pgNotifyWeb({
type: "session.duplicate",
accountId: id,
phoneNumber: event.phoneNumber!,
existingLabel: dup.existingLabel,
});
off();
return;
}
const session = sessionManager.getSession(id); const session = sessionManager.getSession(id);
let synced = 0; let synced = 0;
if (session) { if (session) {
@ -202,42 +134,27 @@ export async function handleStartPairing(accountId: string): Promise<void> {
count: synced, count: synced,
}); });
off(); off();
} else if (event.type === "close" && event.restartRequired) {
// After the user scans, WhatsApp tells Baileys to "restart"
// the connection. The socket closes with status 515 and the
// session-manager will reopen it with the new credentials —
// the next `open` event is what completes the pairing.
// This is NOT a failure: keep the listener attached so we see
// that subsequent `open` event, and don't surface a timeout
// to the UI. The DB row stays in `pending` until `open`.
logger.info(
{ accountId: id },
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
);
// The session-manager handles the actual reconnect; nothing to
// do here other than NOT tear our listener / DB state down.
} else if (event.type === "close") { } else if (event.type === "close") {
const decision = decidePairListenerOnClose({ // During the pairing window, any other close means the QR window
warmingUp: pairingWarmingUp.has(id), // ended without a successful link — Baileys' default is to
restartRequired: event.restartRequired, // close after exhausting QR refs (~2.5 min). Surface this to
}); // the UI so the user gets a "pairing timed out" screen, and
if (decision === "ignore-leaked-close") { // park the row in a stable state so it shows up cleanly on
logger.info( // the accounts list with a "Re-pair" affordance.
{ accountId: id },
"pair: ignoring close from previous attempt while warming up",
);
return;
}
if (decision === "post-pair-restart") {
// After the user scans, WhatsApp tells Baileys to "restart"
// the connection. The socket closes with status 515 and the
// session-manager will reopen it with the new credentials —
// the next `open` event finishes the pairing. Keep the
// listener attached and don't surface a timeout to the UI.
//
// Re-arm the warming-up flag: the session-manager schedules a
// cleanup `stop().then(start())` to kick off the reconnect.
// That stop emits another close event that lands on this
// listener BEFORE the new open arrives — without warming-up,
// we'd treat it as a timeout and detach right when the user
// actually paired successfully. Cleared again on the next
// qr / open from the freshly-reopened session.
pairingWarmingUp.add(id);
logger.info(
{ accountId: id },
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
);
return;
}
// decision === "treat-as-timeout": ephemeral close on a live
// attempt. Park the row as `unpaired` and push session.timeout
// so the operator sees the "Re-pair" affordance.
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);
if (t) { if (t) {
clearTimeout(t); clearTimeout(t);

View File

@ -2,9 +2,6 @@ import { describe, it, expect } from "vitest";
import { import {
decideOnPairClose, decideOnPairClose,
decideOnPairTimeout, decideOnPairTimeout,
decidePairListenerOnClose,
findDuplicateExistingAccount,
nextWarmingUpAfterEvent,
shouldAutoReconnect, shouldAutoReconnect,
} from "./pair-state.js"; } from "./pair-state.js";
@ -85,225 +82,3 @@ describe("shouldAutoReconnect", () => {
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false); expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
}); });
}); });
describe("decidePairListenerOnClose (back→re-pair flicker regression)", () => {
it("ignores a close while warming up — even if also restartRequired", () => {
// The exact bug: stop() was awaited, listener attached, then the OLD
// session's close arrives and races our new listener. Warming-up
// wins over every other branch so the UI never sees a spurious
// session.timeout before the new QR is rendered.
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: false }),
).toBe("ignore-leaked-close");
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
).toBe("ignore-leaked-close");
});
it("treats a close on a live attempt (warmingUp=false) as a real timeout", () => {
// Refs exhausted, network blip, etc. — operator gets the
// "Pairing timed out" screen and a Re-pair affordance.
expect(
decidePairListenerOnClose({ warmingUp: false, restartRequired: false }),
).toBe("treat-as-timeout");
expect(decidePairListenerOnClose({ warmingUp: false })).toBe("treat-as-timeout");
});
it("preserves the restart-required (post-pair-success) branch when not warming up", () => {
// Status 515 close: the session-manager will reconnect and the next
// `open` finishes the pair. We must NOT push session.timeout here.
expect(
decidePairListenerOnClose({ warmingUp: false, restartRequired: true }),
).toBe("post-pair-restart");
});
it("warming-up overrides restartRequired so 515 from a stale session is also swallowed", () => {
// Defense-in-depth: if Baileys' restart-required close from the OLD
// session somehow leaks through, treating it as a real 515 would
// KEEP the listener attached forever (no reconnect comes from a
// session we just stopped). Ignore it entirely until a fresh qr/open.
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
).toBe("ignore-leaked-close");
});
});
describe("nextWarmingUpAfterEvent (pair-listener flag transitions)", () => {
it("first qr from the live session clears warming-up", () => {
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "qr" })).toBe(false);
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "qr" })).toBe(false);
});
it("first open from the live session clears warming-up", () => {
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "open" })).toBe(false);
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "open" })).toBe(false);
});
it("RE-ARMS warming-up on a restart-required close (post-pair-success)", () => {
// The regression: after the user scans, Baileys closes with status
// 515 and the session-manager schedules a stop().then(start())
// reconnect. That cleanup-stop emits a SECOND close that arrives
// before the new socket reopens. If warming-up isn't re-armed
// between the two closes, the second one resolves to
// 'treat-as-timeout' and detaches the listener right at the
// moment the user actually paired successfully — UI never gets
// session.connected.
expect(
nextWarmingUpAfterEvent({ warmingUp: false, event: "close", restartRequired: true }),
).toBe(true);
expect(
nextWarmingUpAfterEvent({ warmingUp: true, event: "close", restartRequired: true }),
).toBe(true);
});
it("plain close leaves warming-up unchanged", () => {
// The pair-handler decides what to DO with a non-restart close
// separately (decidePairListenerOnClose). The warming-up flag
// doesn't change as a side effect — the listener either detaches
// (treat-as-timeout) or already returned (ignore-leaked-close).
expect(
nextWarmingUpAfterEvent({ warmingUp: false, event: "close" }),
).toBe(false);
expect(
nextWarmingUpAfterEvent({ warmingUp: true, event: "close" }),
).toBe(true);
});
it("end-to-end successful-pair sequence: warming → qr → restart-required close → open", () => {
// Full lifecycle the helper has to thread correctly so the user
// sees 'Account connected!' instead of 'Pairing timed out'.
let warming = true; // freshly attached listener after a re-pair
// First QR arrives — clears the leak-protection flag.
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "qr" });
expect(warming).toBe(false);
// User scans → Baileys closes with restartRequired=true.
// Re-arms because session-manager will run another stop+start.
warming = nextWarmingUpAfterEvent({
warmingUp: warming,
event: "close",
restartRequired: true,
});
expect(warming).toBe(true);
// The cleanup-stop's second close arrives. The CALLER decides via
// decidePairListenerOnClose to ignore it (warmingUp=true wins).
expect(
decidePairListenerOnClose({ warmingUp: warming, restartRequired: false }),
).toBe("ignore-leaked-close");
// Flag stays armed because a plain close doesn't change it.
warming = nextWarmingUpAfterEvent({
warmingUp: warming,
event: "close",
restartRequired: false,
});
expect(warming).toBe(true);
// Fresh socket opens with the new credentials → success.
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "open" });
expect(warming).toBe(false);
});
});
describe("findDuplicateExistingAccount (one-phone-per-account guard)", () => {
const sibling = (id: string, phone: string | null, label: string) => ({
id,
phoneNumber: phone,
label,
});
it("flags a sibling that already holds this phone number", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("new", null, "scratch"),
sibling("existing", "60123456789", "Yiekheng-my"),
sibling("other", "60987654321", "WaBot Test"),
],
});
expect(r).toEqual({
existingAccountId: "existing",
existingLabel: "Yiekheng-my",
});
});
it("returns null when the phone is unique", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("new", null, "scratch"),
sibling("other", "60987654321", "WaBot"),
],
});
expect(r).toBeNull();
});
it("excludes the current account from comparison (a row's own phone isn't a 'duplicate')", () => {
// After session-manager.handleEvent runs first it has already
// written phone_number on the current row. The check must skip
// that row, otherwise EVERY successful pair would match itself
// and look like a duplicate.
const r = findDuplicateExistingAccount({
currentAccountId: "self",
currentPhoneNumber: "60123456789",
siblings: [sibling("self", "60123456789", "Self")],
});
expect(r).toBeNull();
});
it("returns null for null/empty/whitespace phone numbers (don't false-positive on unset)", () => {
const siblings = [
sibling("new", null, "scratch"),
sibling("a", null, "Old A"),
sibling("b", "", "Old B"),
sibling("c", " ", "Old C"),
];
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: null,
siblings,
}),
).toBeNull();
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "",
siblings,
}),
).toBeNull();
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: " ",
siblings,
}),
).toBeNull();
});
it("normalises whitespace on both sides before comparing", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: " 60123456789 ",
siblings: [sibling("existing", "60123456789", "Existing")],
});
expect(r?.existingAccountId).toBe("existing");
});
it("picks the FIRST matching sibling when (somehow) two rows share the phone", () => {
// Defensive: this state shouldn't exist in production but the helper
// should at least be deterministic so the message is consistent.
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("first", "60123456789", "First"),
sibling("second", "60123456789", "Second"),
],
});
expect(r?.existingAccountId).toBe("first");
});
});

View File

@ -80,106 +80,3 @@ export function decideOnPairTimeout({ current }: { current: AccountStatus }): St
if (current !== "pending") return null; if (current !== "pending") return null;
return { next: "unpaired", clearQrPng: true }; return { next: "unpaired", clearQrPng: true };
} }
/**
* Decide how the pair-handler should react to a `close` event delivered
* to its listener. Three outcomes:
*
* - "ignore-leaked-close": the new attempt is still warming up and
* we're seeing the OLD session's tail close. Do nothing don't
* emit timeout to the UI, don't touch the DB row.
* - "post-pair-restart": status-515 close from a successful scan.
* The session-manager will reconnect; we keep the listener alive
* and wait for the subsequent `open` event.
* - "treat-as-timeout": a real ephemeral close on a live attempt
* (refs exhausted, etc.). Park the row as `unpaired` and push
* `session.timeout` to the UI.
*
* Captures the regression where, after the user pulled up a QR and
* navigated back, clicking Pair again would instantly flash "Pairing
* timed out" because the await on stop() returned before
* sessionManager.handleEvent finished broadcasting the old session's
* close and the new listener was already attached.
*/
export type PairListenerCloseDecision =
| "ignore-leaked-close"
| "post-pair-restart"
| "treat-as-timeout";
export function decidePairListenerOnClose(input: {
warmingUp: boolean;
restartRequired?: boolean;
}): PairListenerCloseDecision {
if (input.warmingUp) return "ignore-leaked-close";
if (input.restartRequired) return "post-pair-restart";
return "treat-as-timeout";
}
/**
* Step the pair-listener's warming-up flag forward through one Baileys
* event. Captures three rules in one place so they're test-locked:
*
* - First `qr` / `open` from the live session clears warming-up
* (we've seen real session activity, future closes are real).
* - `close + restartRequired` (post-pair-success / status 515)
* RE-ARMS warming-up. The session-manager will schedule a
* `stop().then(start())` reconnect; that stop emits a second close
* before the new socket reopens. Without re-arming, the leaked
* close from the cleanup-stop reaches us with warming-up=false and
* resolves to `treat-as-timeout` detaching the listener right at
* the moment the user actually paired successfully (regression).
* - Any other `close` keeps warming-up unchanged (the listener
* either ignored it because we're warming, or processed it as a
* real timeout / restart and is leaving the loop anyway).
*/
export function nextWarmingUpAfterEvent(input: {
warmingUp: boolean;
event: "qr" | "open" | "close";
restartRequired?: boolean;
}): boolean {
if (input.event === "qr" || input.event === "open") return false;
if (input.event === "close" && input.restartRequired) return true;
return input.warmingUp;
}
/**
* Decide whether a freshly-paired account is a duplicate of an
* existing account row owned by the same operator. The operator
* cannot legitimately link the same WhatsApp number to two account
* rows Baileys keeps one auth blob per phone and the second row
* would just hijack the first's session.
*
* Inputs:
* - `currentAccountId` the row that just received the open event
* - `currentPhoneNumber` the JID-derived phone string (or null)
* - `siblings` every other operator-owned account row
*
* Returns `null` if the phone is unique (proceed normally), or a
* descriptor with the existing-row's id+label so the caller can park
* the duplicate row and surface a clear "already linked" message to
* the UI. A null/empty phone never reports a duplicate (we'd be
* comparing apples and we'd block legitimate first pairs that
* haven't received the WID yet).
*/
export interface DuplicatePairInput {
currentAccountId: string;
currentPhoneNumber: string | null | undefined;
siblings: Array<{ id: string; phoneNumber: string | null; label: string }>;
}
export interface DuplicatePairFinding {
existingAccountId: string;
existingLabel: string;
}
export function findDuplicateExistingAccount(
input: DuplicatePairInput,
): DuplicatePairFinding | null {
const phone = (input.currentPhoneNumber ?? "").trim();
if (!phone) return null;
for (const s of input.siblings) {
if (s.id === input.currentAccountId) continue;
if ((s.phoneNumber ?? "").trim() === phone) {
return { existingAccountId: s.id, existingLabel: s.label };
}
}
return null;
}

View File

@ -1,128 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Hoisted spies so the vi.mock factories can reach them.
const {
stopMock,
logoutAndStopMock,
rmMock,
findFirstMock,
writeAuditLogMock,
pgNotifyWebMock,
} = vi.hoisted(() => ({
stopMock: vi.fn(async () => undefined),
logoutAndStopMock: vi.fn(async () => undefined),
rmMock: vi.fn(async () => undefined),
findFirstMock: vi.fn(async () => ({ operatorId: "op-1", label: "WaBot" })),
writeAuditLogMock: vi.fn(async () => undefined),
pgNotifyWebMock: vi.fn(async () => undefined),
}));
vi.mock("node:fs/promises", () => ({
rm: (...args: unknown[]) => rmMock(...args),
}));
vi.mock("../db.js", () => ({
db: {
query: { whatsappAccounts: { findFirst: (...a: unknown[]) => findFirstMock(...a) } },
},
}));
vi.mock("../env.js", () => ({ env: { SESSIONS_DIR: "/data/sessions" } }));
vi.mock("../whatsapp/session-manager.js", () => ({
sessionManager: {
stop: (...a: unknown[]) => stopMock(...a),
logoutAndStop: (...a: unknown[]) => logoutAndStopMock(...a),
},
}));
vi.mock("../audit.js", () => ({
writeAuditLog: (...a: unknown[]) => writeAuditLogMock(...a),
}));
vi.mock("./notify.js", () => ({
pgNotifyWeb: (...a: unknown[]) => pgNotifyWebMock(...a),
}));
vi.mock("../logger.js", () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
import { handleUnpair, handleDelete } from "./unpair-handler.js";
beforeEach(() => {
stopMock.mockReset();
stopMock.mockResolvedValue(undefined);
logoutAndStopMock.mockReset();
logoutAndStopMock.mockResolvedValue(undefined);
rmMock.mockReset();
rmMock.mockResolvedValue(undefined);
findFirstMock.mockReset();
findFirstMock.mockResolvedValue({ operatorId: "op-1", label: "WaBot" });
writeAuditLogMock.mockReset();
writeAuditLogMock.mockResolvedValue(undefined);
pgNotifyWebMock.mockReset();
pgNotifyWebMock.mockResolvedValue(undefined);
});
describe("handleUnpair", () => {
it("stops the session WITHOUT logout, removes files, audits, notifies", async () => {
await handleUnpair("acct-A");
// The unpair flow MUST NOT call logoutAndStop — that would tell
// WhatsApp to drop the linked device, which the operator might
// re-pair shortly after. logoutAndStop is only for permanent
// delete.
expect(logoutAndStopMock).not.toHaveBeenCalled();
expect(stopMock).toHaveBeenCalledWith("acct-A", { intentional: true });
expect(rmMock).toHaveBeenCalled();
expect(writeAuditLogMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "account.unpaired", targetId: "acct-A" }),
);
expect(pgNotifyWebMock).toHaveBeenCalledWith({
type: "session.disconnected",
accountId: "acct-A",
});
});
});
describe("handleDelete (logout-before-teardown)", () => {
it("calls logoutAndStop BEFORE rm so WhatsApp drops the linked device first", async () => {
await handleDelete("acct-A");
expect(logoutAndStopMock).toHaveBeenCalledTimes(1);
expect(logoutAndStopMock).toHaveBeenCalledWith("acct-A");
expect(rmMock).toHaveBeenCalledTimes(1);
// Order: logout-and-stop must invoke before rm (otherwise the
// socket was torn down on disk before WhatsApp could be told to
// drop the linked device).
expect(logoutAndStopMock.mock.invocationCallOrder[0]).toBeLessThan(
rmMock.mock.invocationCallOrder[0]!,
);
});
it("does NOT call the plain stop() — that's reserved for unpair", async () => {
// Sanity guard: a refactor that swaps logoutAndStop for stop()
// would silently regress the linked-device cleanup. The test
// pins the contract.
await handleDelete("acct-A");
expect(stopMock).not.toHaveBeenCalled();
});
it("writes an account.deleted audit log carrying the row's label", async () => {
findFirstMock.mockResolvedValueOnce({ operatorId: "op-7", label: "Yiekheng-my" });
await handleDelete("acct-X");
expect(writeAuditLogMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "account.deleted",
operatorId: "op-7",
targetId: "acct-X",
payload: { label: "Yiekheng-my" },
}),
);
});
it("still completes when the audit-log lookup fails (best-effort)", async () => {
// The web action runs the cascade DELETE right after; if the row
// is gone before this handler reads it, the audit lookup throws.
// Delete must not strand on that.
findFirstMock.mockRejectedValueOnce(new Error("no such row"));
await expect(handleDelete("acct-A")).resolves.toBeUndefined();
expect(rmMock).toHaveBeenCalled();
expect(pgNotifyWebMock).toHaveBeenCalled();
});
});

View File

@ -39,41 +39,3 @@ export async function handleUnpair(accountId: string): Promise<void> {
} }
await pgNotifyWeb({ type: "session.disconnected", accountId }); await pgNotifyWeb({ type: "session.disconnected", accountId });
} }
/**
* Delete-account flow on the bot side. Distinct from unpair because
* we want WhatsApp to drop this device from the user's linked-devices
* list otherwise the phone keeps showing a phantom entry that has
* to be manually removed from WhatsApp's UI.
*
* Order is important:
* 1. socket.logout() over the still-connected socket WhatsApp
* removes the linked device on the server side.
* 2. close() the local Baileys session.
* 3. rm() the on-disk auth blob so the next pairing starts clean.
*
* Step 1 is best-effort if the socket is already torn down or the
* RPC fails the delete still proceeds. The web action then deletes
* the row (cascade FKs handle groups/reminders/runs).
*/
export async function handleDelete(accountId: string): Promise<void> {
await sessionManager.logoutAndStop(accountId);
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
try {
const row = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
columns: { operatorId: true, label: true },
});
await writeAuditLog(db, {
operatorId: row?.operatorId ?? null,
source: "web",
action: "account.deleted",
targetType: "whatsapp_account",
targetId: accountId,
payload: { label: row?.label ?? null },
});
} catch (err) {
logger.warn({ err, accountId }, "delete: audit log failed (non-fatal)");
}
await pgNotifyWeb({ type: "session.disconnected", accountId });
}

View File

@ -108,51 +108,6 @@ describe("fireReminder", () => {
expect(accountMutex.run).not.toHaveBeenCalled(); expect(accountMutex.run).not.toHaveBeenCalled();
}); });
it("BAILS OUT INSIDE the mutex when a concurrent job inserted a run while we were queued (TOCTOU repro)", async () => {
// Repro: three pg-boss jobs arrive in the same microsecond. All
// three pass the OUTER recent-run check (no run exists yet) and
// queue up on the per-account mutex. The first acquires, INSERTs
// a run, sends. The second acquires AFTER the first finished —
// its inner check now sees the just-inserted run and must bail,
// otherwise the message would be sent twice (or three times for
// the third job). Without the inner check this regression
// produced "qwerd msg three times" in production.
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "active",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test",
});
// First call (outer check) returns no recent run → mutex acquired.
// Second call (inner check inside fireReminderInner) returns a
// freshly-inserted run from the concurrent winner, so the INSERT
// path bails. We never reach the .insert(reminderRuns) builder so
// the test passes by virtue of the inner-check log + early return.
findExistingRunMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
id: "run-just-inserted-by-the-other-worker",
reminderId: "r-1",
firedAt: new Date(),
status: "pending",
});
await fireReminder({ reminderId: "r-1" });
// The mutex DID get acquired (we got past the outer check), but
// the inner check should have stopped us before any side effects.
expect(accountMutex.run).toHaveBeenCalledTimes(1);
expect(findExistingRunMock).toHaveBeenCalledTimes(2);
});
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => { it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
// Two pg-boss jobs landing within microseconds for the same // Two pg-boss jobs landing within microseconds for the same
// reminder should NOT both fire. The first creates the run; the // reminder should NOT both fire. The first creates the run; the

View File

@ -154,32 +154,6 @@ async function fireReminderInner(
.set({ status: "pending", errorSummary: null }) .set({ status: "pending", errorSummary: null })
.where(eq(reminderRuns.id, runId)); .where(eq(reminderRuns.id, runId));
} else { } else {
// Re-check the dedupe window now that we're inside the per-account
// mutex. The outer check in fireReminder() is a fast-path bail-out
// but it's TOCTOU: three concurrent jobs can all read "no recent
// run" before any of them inserts, so the message gets sent 2-3
// times. Inside the mutex, the queue serialises us — by the time
// duplicate #2 reaches this point, duplicate #1 has already
// INSERTed and we'll find that row here.
const recent = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq, and: dAnd, gt: dGt }) =>
dAnd(
dEq(r.reminderId, reminder.id),
dGt(r.firedAt, new Date(Date.now() - DUPLICATE_FIRE_WINDOW_MS)),
),
orderBy: (r, { desc }) => [desc(r.firedAt)],
});
if (recent) {
logger.warn(
{
reminderId: reminder.id,
recentRunId: recent.id,
recentFiredAt: recent.firedAt,
},
"fire-reminder: duplicate fire detected inside mutex (a run was just inserted by a concurrent job), skipping",
);
return;
}
const [run] = await db const [run] = await db
.insert(reminderRuns) .insert(reminderRuns)
.values({ .values({

View File

@ -1,119 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// pg-boss + db mocks. Hoisted so the vi.mock factories below can refer
// to the spies — see https://vitest.dev/api/vi.html#vi-hoisted.
const {
bossSendMock,
dbExecuteMock,
} = vi.hoisted(() => ({
bossSendMock: vi.fn(async (..._args: unknown[]) => "new-job-id"),
dbExecuteMock: vi.fn(async (..._args: unknown[]) => ({ rows: [] as unknown[] })),
}));
vi.mock("../db.js", () => ({
db: { execute: (...a: unknown[]) => dbExecuteMock(...a) },
}));
vi.mock("../logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// We don't import pg-boss directly — scheduleReminderFire receives a
// PgBoss instance as its first arg. Build a minimal stub that exposes
// just the .send method (and createQueue / work for registerReminderJobs
// if we ever wire it here).
const fakeBoss = {
send: bossSendMock,
} as unknown as Parameters<typeof scheduleReminderFire>[0];
import { scheduleReminderFire } from "./reminder-jobs.js";
const REMINDER_ID = "11111111-1111-1111-1111-111111111111";
const SINGLETON_KEY = `reminder:${REMINDER_ID}`;
const FIRE_AT = new Date("2026-05-10T12:20:00.000Z");
beforeEach(() => {
bossSendMock.mockReset();
bossSendMock.mockResolvedValue("new-job-id");
dbExecuteMock.mockReset();
dbExecuteMock.mockResolvedValue({ rows: [] });
});
describe("scheduleReminderFire — pre-send cancel of stale created jobs", () => {
it("ALWAYS runs the cancel-stale UPDATE before boss.send (regression: stately dedupe dropped reschedules)", async () => {
// Repro of the dropped-fire bug: the queue was on policy=stately
// and a prior schedule had left a 'created' job in pg-boss with
// the same singletonKey. The new send returned null and the
// user's 8:20 PM fire was silently lost. Under the fix, we MUST
// tombstone any prior created jobs FIRST so the new send wins
// even under standard policy.
dbExecuteMock.mockResolvedValueOnce({ rows: [{ id: "stale-1" }] });
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
// Order matters: cancel happens before send.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(dbExecuteMock.mock.invocationCallOrder[0]).toBeLessThan(
bossSendMock.mock.invocationCallOrder[0]!,
);
expect(result).toBe("new-job-id");
});
it("scopes the cancel to state='created' only — active/completed jobs MUST survive", async () => {
// The cancel must NOT touch in-flight runs (state='active') nor
// historical fires (state='completed'). Otherwise we'd nuke the
// run that's currently sending and the user gets phantom 'failed'
// rows in the activity feed.
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
// Drizzle's sql template returns an SQL object; serialise to inspect.
const text = JSON.stringify(sqlStmt);
expect(text).toMatch(/state\s*=\s*'?created'?/);
expect(text).not.toMatch(/state\s*=\s*'?active'?/);
expect(text).not.toMatch(/state\s*=\s*'?completed'?/);
});
it("targets only THIS reminder's singletonKey (doesn't cross-cancel other reminders)", async () => {
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
const text = JSON.stringify(sqlStmt);
// The reminderId must appear in the WHERE clause's bound params
// (drizzle stores them in the serialised payload).
expect(text).toContain(REMINDER_ID);
});
it("passes the singleton key through to boss.send for diagnostics", async () => {
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const [, , opts] = bossSendMock.mock.calls[0]!;
expect(opts).toMatchObject({
singletonKey: SINGLETON_KEY,
startAfter: FIRE_AT,
retryLimit: 3,
});
});
it("still sends when the cancel UPDATE returns zero rows (first-time schedule)", async () => {
// First time scheduling a reminder — no stale rows exist.
dbExecuteMock.mockResolvedValueOnce({ rows: [] });
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(result).toBe("new-job-id");
});
it("DEGRADES SAFELY: if the cancel UPDATE throws, the send still runs", async () => {
// pg connection blip during cancel must not strand the schedule.
// Worst case we end up with two created jobs and the
// handler-level recent-run dedupe drops the duplicate fire.
dbExecuteMock.mockRejectedValueOnce(new Error("connection reset"));
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(result).toBe("new-job-id");
});
it("returns boss.send's null when pg-boss itself rejects the send", async () => {
// Defense check: if pg-boss returns null for any reason (queue
// missing, future stately-style policy quirks, etc), surface that
// up so the caller's logger captures jobId: null.
bossSendMock.mockResolvedValueOnce(null);
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(result).toBeNull();
});
});

View File

@ -1,39 +1,21 @@
import type { PgBoss } from "pg-boss"; import type { PgBoss } from "pg-boss";
import { sql } from "drizzle-orm";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { db } from "../db.js";
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js"; import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
export const REMINDER_FIRE_QUEUE = "reminder.fire"; export const REMINDER_FIRE_QUEUE = "reminder.fire";
export async function registerReminderJobs(boss: PgBoss): Promise<void> { export async function registerReminderJobs(boss: PgBoss): Promise<void> {
// 'standard' (the default) lets us enqueue a new fire even when an // 'stately' = at most 1 job per (state, singletonKey). Combined with
// older one for the same singletonKey is still 'created'. We need // singletonKey="reminder:<id>" on every send, that means a duplicate
// that for the recurring/edit path: when a reminder is rescheduled, // schedule call (e.g. operator double-clicked Save, or the
// scheduleReminderFire() first cancels the stale 'created' job for // pg_notify('bot.command') consumer fired twice in the same tick)
// this reminder and then sends a new one — under 'stately' the // is folded into the existing 'created' job instead of producing a
// SECOND send returns null (it dedupes against the first across // second run. The default 'standard' policy DOES NOT dedupe by
// states), so a reschedule silently dropped the new fire and the // singletonKey — that's how we ended up firing a reminder twice
// reminder never fired at the new time. Duplicate-fire safety is // when two reminder.fire jobs landed within microseconds.
// covered at the handler level by the inner-mutex recent-run check // https://github.com/timgit/pg-boss/blob/master/docs/usage.md#queue-policies
// in fire-reminder.ts (see DUPLICATE_FIRE_WINDOW_MS), which catches await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "stately" });
// the microsecond-spaced send case 'stately' was supposed to guard.
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "standard" });
// pg-boss v12's createQueue is idempotent and DOES NOT update the
// policy on an existing queue row. Earlier deployments forced
// policy='stately' here, which broke reschedules. Force-flip back to
// 'standard' on every boot so an old queue row doesn't strand us.
try {
await db.execute(
sql`UPDATE pgboss.queue SET policy = 'standard' WHERE name = ${REMINDER_FIRE_QUEUE} AND policy <> 'standard'`,
);
} catch (err) {
logger.warn(
{ err },
"reminder.fire: failed to force queue policy=standard (handler-level dedupe still applies)",
);
}
await boss.work<FireReminderPayload>( await boss.work<FireReminderPayload>(
REMINDER_FIRE_QUEUE, REMINDER_FIRE_QUEUE,
{ {
@ -61,33 +43,6 @@ export async function scheduleReminderFire(
reminderId: string, reminderId: string,
scheduledAt: Date, scheduledAt: Date,
): Promise<string | null> { ): Promise<string | null> {
const singletonKey = `reminder:${reminderId}`;
// Replace-then-send. Any 'created' (i.e. not yet started) job for
// this reminder is the stale next-fire from the previous schedule
// attempt; nuke it so the new schedule wins. Active/completed jobs
// are left alone — those represent in-flight or already-fired runs
// and the handler-level dedupe handles overlap.
try {
const cancelled = await db.execute(
sql`UPDATE pgboss.job
SET state = 'cancelled', completed_on = now()
WHERE name = ${REMINDER_FIRE_QUEUE}
AND singleton_key = ${singletonKey}
AND state = 'created'
RETURNING id`,
);
if (cancelled.rows.length > 0) {
logger.info(
{ reminderId, cancelled: cancelled.rows.length },
"reminder.fire: cancelled stale created jobs before reschedule",
);
}
} catch (err) {
// If the cancellation step fails, log but still try to send. Worst
// case we end up with two created jobs and the handler-level
// recent-run dedupe drops the duplicate fire.
logger.warn({ err, reminderId }, "reminder.fire: pre-send cancel failed");
}
const id = await boss.send( const id = await boss.send(
REMINDER_FIRE_QUEUE, REMINDER_FIRE_QUEUE,
{ reminderId }, { reminderId },
@ -96,10 +51,8 @@ export async function scheduleReminderFire(
retryLimit: 3, retryLimit: 3,
retryDelay: 30, retryDelay: 30,
retryBackoff: true, retryBackoff: true,
// Singleton key kept on the job row for diagnostics + the // Use the reminderId as a singleton key so re-scheduling cancels the old job
// pre-send cancel above, even though 'standard' policy doesn't singletonKey: `reminder:${reminderId}`,
// dedupe by it.
singletonKey,
}, },
); );
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled"); logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");

View File

@ -7,45 +7,35 @@ import { logger } from "../logger.js";
export async function syncGroupsForAccount( export async function syncGroupsForAccount(
accountId: string, accountId: string,
socket: WASocket, socket: WASocket,
): Promise<{ synced: number; archived: number }> { ): Promise<{ synced: number; removed: number }> {
const meta = await socket.groupFetchAllParticipating(); const meta = await socket.groupFetchAllParticipating();
const entries = Object.values(meta); const entries = Object.values(meta);
const liveJids = entries.map((g) => g.id); const liveJids = entries.map((g) => g.id);
// Mark DB rows as archived when they're no longer in the live // Remove DB rows for groups that are no longer in the live participant list
// participant list (group deleted, bot removed, etc). We don't // (group was deleted, bot was removed, etc.). Only run the delete when we
// physically DELETE because reminder_targets.group_id is a NOT // got at least one live group back — an empty result is more likely a
// NULL FK to this row — a hard delete throws "violates foreign // transient WA fetch failure than a genuine "all groups gone" signal, and
// key constraint reminder_targets_group_id_whatsapp_groups_id_fk" // we don't want to nuke valid data on a hiccup.
// and aborts the WHOLE group-sync transaction (which then strands let removed: { id: string }[] = [];
// the post-pair open event and the operator sees it as a failed
// pairing). Soft-archive keeps reminders that targeted the group
// intact and gives the operator the option to clean them up
// explicitly later. Only run the sweep when we got at least one
// live group back — an empty result is usually a transient WA
// fetch failure and we don't want to mass-archive valid data.
let archived = 0;
if (liveJids.length > 0) { if (liveJids.length > 0) {
const rows = await db removed = await db
.update(whatsappGroups) .delete(whatsappGroups)
.set({ isArchived: true, lastSyncedAt: new Date() })
.where( .where(
and( and(
eq(whatsappGroups.accountId, accountId), eq(whatsappGroups.accountId, accountId),
notInArray(whatsappGroups.waGroupJid, liveJids), notInArray(whatsappGroups.waGroupJid, liveJids),
eq(whatsappGroups.isArchived, false),
), ),
) )
.returning({ id: whatsappGroups.id }); .returning({ id: whatsappGroups.id });
archived = rows.length;
} }
if (entries.length === 0) { if (entries.length === 0) {
logger.info( logger.info(
{ accountId }, { accountId },
"group-sync: empty fetch — skipping archive sweep (treating as transient)", "group-sync: empty fetch — skipping delete sweep (treating as transient)",
); );
return { synced: 0, archived: 0 }; return { synced: 0, removed: 0 };
} }
const rows = entries.map((g) => ({ const rows = entries.map((g) => ({
@ -66,16 +56,12 @@ export async function syncGroupsForAccount(
name: sql`excluded.name`, name: sql`excluded.name`,
participantCount: sql`excluded.participant_count`, participantCount: sql`excluded.participant_count`,
lastSyncedAt: sql`excluded.last_synced_at`, lastSyncedAt: sql`excluded.last_synced_at`,
// If a previously-archived group reappears in the live list
// (operator was re-added, group was un-deleted, etc.), flip
// the flag back so it shows up in the picker again.
isArchived: sql`excluded.is_archived`,
}, },
}); });
logger.info( logger.info(
{ accountId, count: rows.length, archived }, { accountId, count: rows.length, removed: removed.length },
"group-sync: synced", "group-sync: synced",
); );
return { synced: rows.length, archived }; return { synced: rows.length, removed: removed.length };
} }

View File

@ -120,44 +120,6 @@ class SessionManager {
this.sessions.delete(accountId); this.sessions.delete(accountId);
} }
/**
* Tell WhatsApp to remove this device from the linked-devices list,
* then close the socket. Used by the delete-account flow so the
* operator's phone doesn't keep showing a phantom "linked device"
* pointing at a row that no longer exists. Best-effort: if the
* socket is already torn down or the logout RPC fails (network
* blip, already-disconnected, etc.) we still proceed to close +
* teardown no point stranding the delete because WhatsApp didn't
* acknowledge.
*/
async logoutAndStop(accountId: string): Promise<void> {
const timer = this.reconnectTimers.get(accountId);
if (timer) {
clearTimeout(timer);
this.reconnectTimers.delete(accountId);
}
const session = this.sessions.get(accountId);
if (!session) return;
// Suppress reconnect/handleEvent bookkeeping for the close that
// logout() emits — the row is about to be deleted entirely so
// status writes are pointless.
this.intentionalStops.add(accountId);
try {
await session.socket.logout();
} catch (err) {
logger.warn(
{ err, accountId },
"session-manager: socket.logout() failed (continuing with teardown)",
);
}
try {
await session.close();
} catch (err) {
logger.warn({ err, accountId }, "session-manager: post-logout close failed");
}
this.sessions.delete(accountId);
}
async stopAll(): Promise<void> { async stopAll(): Promise<void> {
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id))); await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
} }

View File

@ -1,27 +0,0 @@
# Required
DATABASE_URL=postgres://user:pass@host:5432/dbname
# Auth — sign cookies. 64+ random chars. Generate via scripts/gen_auth_secret.sh.
AUTH_SECRET=replace-me
# Bump to invalidate all live sessions instantly. Leave at 1 normally.
OPERATOR_TOKEN_VERSION=1
# File-storage paths inside the bot container
DATA_DIR=/data
SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media
# Bot fan-out tuning (see apps/bot/src/env.ts)
BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=info
BOT_FIRE_CONCURRENCY=8
BOT_GROUP_CONCURRENCY=3
BOT_MAX_SEND_PER_MINUTE=40
# Web
WEB_PORT=9000
# Seed (runs once via scripts/db.sh seed)
SEED_OPERATOR_USERNAME=admin
SEED_OPERATOR_NAME=Operator

View File

@ -21,7 +21,6 @@ const nextConfig: NextConfig = {
experimental: { experimental: {
typedRoutes: true, typedRoutes: true,
serverActions: { serverActions: {
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
// Default Server Action body limit is 1 MB — way under WhatsApp's // Default Server Action body limit is 1 MB — way under WhatsApp's
// 100 MB document cap. Lifted to 100 MB so document uploads reach // 100 MB document cap. Lifted to 100 MB so document uploads reach
// the action; the per-kind WhatsApp validator // the action; the per-kind WhatsApp validator

View File

@ -18,7 +18,6 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@serwist/next": "^9.5.11", "@serwist/next": "^9.5.11",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
@ -45,7 +44,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",

View File

@ -172,16 +172,8 @@ export async function unpairAccountAction(formData: FormData): Promise<void> {
.update(whatsappAccounts) .update(whatsappAccounts)
.set({ status: "unpaired", phoneNumber: null }) .set({ status: "unpaired", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId)); .where(eq(whatsappAccounts.id, accountId));
// Soft-archive synced groups instead of DELETEing. Hard delete // Wipe synced groups too — they belong to a different WA login now.
// failed with "violates foreign key constraint await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
// reminder_targets_group_id_whatsapp_groups_id_fk" whenever any
// group had ever been used in a reminder, which aborted the
// unpair. Archived groups vanish from the picker; a re-pair flips
// them back via the on-conflict upsert in syncGroupsForAccount.
await db
.update(whatsappGroups)
.set({ isArchived: true })
.where(eq(whatsappGroups.accountId, accountId));
revalidatePath("/accounts"); revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`); revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -201,12 +193,8 @@ export async function deleteAccountAction(formData: FormData): Promise<void> {
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
}); });
if (!account) return; if (!account) return;
// Tell the bot to logout() over the live socket FIRST (so WhatsApp // Stop any live session / clean session files first.
// drops this device from the operator's linked-devices list), then await pgNotifyBot({ type: "account.unpair", accountId });
// close + remove session files. Distinct from account.unpair which
// never calls logout — keeping linked-devices clean is specific to
// the delete flow.
await pgNotifyBot({ type: "account.delete", accountId });
// Cascade FKs handle groups, reminders, runs, run_targets, messages. // Cascade FKs handle groups, reminders, runs, run_targets, messages.
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId)); await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
revalidatePath("/accounts"); revalidatePath("/accounts");

View File

@ -1,367 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import bcrypt from "bcryptjs";
const {
cookiesSetMock,
cookiesDeleteMock,
findUserMock,
headersGetMock,
headerStore,
checkRateLimitMock,
redirectMock,
loggerMock,
} = vi.hoisted(() => ({
cookiesSetMock: vi.fn(),
cookiesDeleteMock: vi.fn(),
findUserMock: vi.fn(),
headersGetMock: vi.fn(() => "127.0.0.1"),
headerStore: new Map<string, string>(),
checkRateLimitMock: vi.fn(),
redirectMock: vi.fn((_path: string) => {
throw new Error("redirect");
}),
loggerMock: { warn: vi.fn(), info: vi.fn() },
}));
vi.mock("next/headers", () => ({
cookies: async () => ({ set: cookiesSetMock, delete: cookiesDeleteMock }),
headers: async () => ({
get: (k: string) => {
const key = k.toLowerCase();
if (key === "x-forwarded-for") return headersGetMock();
// Tests opt-in to setting origin/host/etc. via headerStore;
// unset = null which lets hasSameOriginRequest treat the
// request as same-origin (Origin omitted = same-origin per RFC).
return headerStore.get(key) ?? null;
},
}),
}));
vi.mock("next/navigation", () => ({
redirect: (path: string) => redirectMock(path),
}));
vi.mock("@/lib/db", () => ({
db: {
query: {
operators: { findFirst: (...a: unknown[]) => findUserMock(...a) },
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
}));
vi.mock("@/lib/logger", () => ({ logger: loggerMock }));
const SECRET = "test-secret-not-real";
beforeEach(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
cookiesSetMock.mockReset();
cookiesDeleteMock.mockReset();
findUserMock.mockReset();
checkRateLimitMock.mockReset();
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
redirectMock.mockReset();
redirectMock.mockImplementation((_path: string) => {
throw new Error("redirect");
});
loggerMock.warn.mockReset();
headerStore.clear();
});
import { loginAction, logoutAction } from "./auth";
const REAL_HASH = bcrypt.hashSync("correct-horse", 10);
const ADMIN_ROW = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
displayName: "Admin",
defaultTimezone: "UTC",
passwordHash: REAL_HASH,
};
function fd(fields: Record<string, string>): FormData {
const f = new FormData();
for (const [k, v] of Object.entries(fields)) f.append(k, v);
return f;
}
describe("loginAction", () => {
it("issues a session cookie when credentials are correct", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prevEnv = process.env.NODE_ENV;
// @ts-expect-error - test override
process.env.NODE_ENV = "production";
try {
const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(
(e) => e,
);
// Successful login redirects, so the redirect mock throws.
expect((r as Error).message).toBe("redirect");
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
const [name, , attrs] = cookiesSetMock.mock.calls[0]!;
expect(name).toBe("session");
expect(attrs).toMatchObject({
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 30 * 86400,
});
} finally {
// @ts-expect-error - test restore
process.env.NODE_ENV = prevEnv;
}
});
it("sets secure=false on the cookie when NODE_ENV !== production", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prevEnv = process.env.NODE_ENV;
// @ts-expect-error - test override
process.env.NODE_ENV = "development";
try {
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
const [, , attrs] = cookiesSetMock.mock.calls[0]!;
expect(attrs).toMatchObject({ secure: false });
} finally {
// @ts-expect-error - test restore
process.env.NODE_ENV = prevEnv;
}
});
it("returns ok:false on wrong password and does NOT set a cookie", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const r = await loginAction(fd({ username: "admin", password: "wrong" }));
expect(r).toEqual({ ok: false, error: "Invalid username or password." });
expect(cookiesSetMock).not.toHaveBeenCalled();
expect(loggerMock.warn).toHaveBeenCalled();
});
it("returns ok:false on unknown username and STILL invokes bcrypt (timing equivalence)", async () => {
findUserMock.mockResolvedValue(undefined);
const cmpSpy = vi.spyOn(bcrypt, "compare");
await loginAction(fd({ username: "nobody", password: "irrelevant" }));
expect(cmpSpy).toHaveBeenCalled(); // compared against DUMMY_HASH
cmpSpy.mockRestore();
});
it("returns a clear error when the user has no password_hash set", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, passwordHash: null });
const r = await loginAction(fd({ username: "admin", password: "anything" }));
expect(r).toEqual({
ok: false,
error: "Set a password via scripts/set-password.sh before signing in.",
});
});
it("rejects empty username or password without hitting the DB", async () => {
const r = await loginAction(fd({ username: "", password: "x" }));
expect(r).toEqual({ ok: false, error: "Username and password are required." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("rejects username/password >256 chars without invoking bcrypt", async () => {
const cmpSpy = vi.spyOn(bcrypt, "compare");
const long = "x".repeat(300);
const r = await loginAction(fd({ username: long, password: long }));
expect(r).toEqual({ ok: false, error: "Input too long." });
expect(cmpSpy).not.toHaveBeenCalled();
cmpSpy.mockRestore();
});
it("matches username case-insensitively", async () => {
findUserMock.mockImplementation(async () => ADMIN_ROW);
await loginAction(fd({ username: "ADMIN", password: "correct-horse" })).catch(() => {});
expect(findUserMock).toHaveBeenCalled();
});
it("returns 429 when the rate limit is exhausted", async () => {
checkRateLimitMock.mockResolvedValue({ limited: true, count: 11 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("logs the failed attempt with username and ip but never the password", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headersGetMock.mockReturnValue("203.0.113.5, 10.0.0.1");
await loginAction(fd({ username: "admin", password: "wrong" }));
const [meta, msg] = loggerMock.warn.mock.calls[0]!;
expect(meta).toMatchObject({ username: "admin", ip: "203.0.113.5" });
expect(JSON.stringify(meta)).not.toContain("wrong");
expect(msg).toMatch(/login failed/i);
});
it("redirects to safeRedirect(next) on success", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
await loginAction(fd({
username: "admin",
password: "correct-horse",
next: "/dashboard",
})).catch(() => {});
expect(redirectMock).toHaveBeenCalledWith("/dashboard");
});
it("redirects to / when next is unsafe", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
await loginAction(fd({
username: "admin",
password: "correct-horse",
next: "//evil.com",
})).catch(() => {});
expect(redirectMock).toHaveBeenCalledWith("/");
});
});
describe("logoutAction", () => {
it("clears the session cookie and redirects to /login", async () => {
await logoutAction().catch(() => {});
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
expect(redirectMock).toHaveBeenCalledWith("/login");
});
it("is idempotent — clears the cookie even when no session exists", async () => {
// Real-world: a user with an expired cookie clicks Sign out. cookies.delete
// doesn't care about pre-existing state and we still issue the redirect.
cookiesDeleteMock.mockReset();
await logoutAction().catch(() => {});
expect(cookiesDeleteMock).toHaveBeenCalledTimes(1);
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
});
});
describe("loginAction — additional cases", () => {
it("issues a cookie that decrypts to role='user' for a non-admin user", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "user" });
await loginAction(fd({ username: "alice", password: "correct-horse" })).catch(() => {});
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
const [, cookieValue] = cookiesSetMock.mock.calls[0]!;
// The cookie is now AES-GCM encrypted, so we can't peel the payload
// off raw — decrypt with the same secret loginAction used. This
// also doubles as a confidentiality smoke test: 'user'/'alice'
// must NOT appear verbatim in the cookie bytes.
expect(cookieValue as string).not.toContain("alice");
expect(cookieValue as string).not.toContain("user");
const { verifySession } = await import("@/lib/auth-cookie");
const decoded = await verifySession(cookieValue as string, SECRET);
expect(decoded?.role).toBe("user");
expect(decoded?.userId).toBe(ADMIN_ROW.id);
});
it("rejects when the user row has an unrecognised role string", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "robot" });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Account is not enabled." });
expect(cookiesSetMock).not.toHaveBeenCalled();
});
it("returns ok:false when AUTH_SECRET is unset (server misconfig)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prev = process.env.AUTH_SECRET;
delete process.env.AUTH_SECRET;
try {
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Server is not configured for sign-in." });
expect(cookiesSetMock).not.toHaveBeenCalled();
} finally {
process.env.AUTH_SECRET = prev;
}
});
it("treats whitespace-only username as missing input", async () => {
const r = await loginAction(fd({ username: " ", password: "x" }));
expect(r).toEqual({ ok: false, error: "Username and password are required." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("uses three rate-limit layers: per-IP, per-username, global", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headersGetMock.mockReturnValue("198.51.100.42");
await loginAction(fd({ username: "Admin", password: "correct-horse" })).catch(() => {});
// Three checkRateLimit calls fired in parallel via Promise.all,
// in this order: ip / user / global.
expect(checkRateLimitMock).toHaveBeenCalledTimes(3);
const keys = checkRateLimitMock.mock.calls.map((c) => c[0] as string);
expect(keys[0]).toBe("login:198.51.100.42");
// Username key is normalised to lowercase so "Admin" and "admin"
// share the same bucket — otherwise an attacker rotating case
// would dodge per-username throttling.
expect(keys[1]).toBe("login-user:admin");
expect(keys[2]).toBe("login-global");
});
it("rejects login when the per-username limit alone is hit (rotating-IPs attacker)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
// First call (ip) passes, second (user) is over, third (global) passes.
checkRateLimitMock
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: true, count: 6 })
.mockResolvedValueOnce({ limited: false, count: 5 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
expect(findUserMock).not.toHaveBeenCalled();
// Logger captures which limit tripped so we can tune thresholds
// without leaking the answer to the attacker.
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
expect(meta).toMatchObject({ limit: "username" });
});
it("rejects login when the global limit alone is hit (everyone-pile-on backstop)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
checkRateLimitMock
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: true, count: 101 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
expect(meta).toMatchObject({ limit: "global" });
});
it("rejects a cross-origin POST before checking credentials", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "https://attacker.example");
headerStore.set("host", "wabot.04080616.xyz");
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
expect(checkRateLimitMock).not.toHaveBeenCalled();
expect(findUserMock).not.toHaveBeenCalled();
});
it("accepts a same-origin POST (Origin host matches Host header)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "https://wabot.04080616.xyz");
headerStore.set("host", "wabot.04080616.xyz");
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
// Got past the origin check → DB lookup ran.
expect(findUserMock).toHaveBeenCalled();
});
it("treats a missing Origin header as same-origin (legitimate native form POST)", async () => {
// Browsers don't always send Origin (e.g. plain top-level form
// submissions). Refusing those would brick login on some clients.
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.delete("origin");
headerStore.set("host", "wabot.04080616.xyz");
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
expect(findUserMock).toHaveBeenCalled();
});
it("rejects when Origin is malformed (non-URL string)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "not a url");
headerStore.set("host", "wabot.04080616.xyz");
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
});
it("still hits the DB and bcrypt on the unknown-user path so timing equivalence holds", async () => {
findUserMock.mockResolvedValue(undefined);
const cmpSpy = vi.spyOn(bcrypt, "compare");
await loginAction(fd({ username: "ghost", password: "anything" }));
// findFirst was called even though we know the user doesn't exist.
expect(findUserMock).toHaveBeenCalledTimes(1);
expect(cmpSpy).toHaveBeenCalled();
cmpSpy.mockRestore();
});
});

View File

@ -1,182 +0,0 @@
"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
COOKIE_NAME,
DEFAULT_TTL_SECONDS,
signSession,
type Role,
} from "@/lib/auth-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { safeRedirect } from "@/lib/safe-redirect";
import { logger } from "@/lib/logger";
export type LoginResult = { ok: true } | { ok: false; error: string };
const MAX_FIELD_LEN = 256;
// Precomputed bcryptjs hash of the throwaway string "x", cost 10.
// Compared against on the user-not-found path so timing matches the
// wrong-password path. Generating fresh per request would double the
// bcrypt work and create its own timing signal.
const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
async function clientIp(): Promise<string> {
const h = await headers();
const fwd = h.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]!.trim();
return h.get("x-real-ip") ?? "unknown";
}
/**
* Compare the inbound Origin to the request's Host. Server Actions
* already get an Origin check via Next 16's
* `serverActions.allowedOrigins`, but that's a global config running
* the same comparison here is cheap belt-and-braces and lets us log
* mismatches with action-level context. Returns true when:
* - no Origin header is present (same-origin POSTs from the same
* server), OR
* - Origin's host matches the Host header (same-origin)
* Anything else (cross-origin POST, malformed Origin, etc.) false.
*/
async function hasSameOriginRequest(): Promise<boolean> {
const h = await headers();
const origin = h.get("origin");
if (!origin) return true; // RFC: same-origin requests may omit Origin
const host = h.get("host");
if (!host) return false;
try {
const u = new URL(origin);
return u.host === host;
} catch {
return false;
}
}
export async function loginAction(formData: FormData): Promise<LoginResult> {
const username = (formData.get("username") ?? "").toString();
const password = (formData.get("password") ?? "").toString();
const next = (formData.get("next") ?? "").toString();
if (!username.trim() || !password) {
return { ok: false, error: "Username and password are required." };
}
if (username.length > MAX_FIELD_LEN || password.length > MAX_FIELD_LEN) {
return { ok: false, error: "Input too long." };
}
// Action-level Origin check. Next 16's serverActions.allowedOrigins
// already gates this at the framework boundary, but doing it here
// with action context lets us log the mismatch and surface a clean
// error instead of relying on the global config alone.
if (!(await hasSameOriginRequest())) {
logger.warn({}, "login rejected: cross-origin request");
return { ok: false, error: "Cross-origin request blocked." };
}
const ip = await clientIp();
// Three-layer rate limit:
// per-IP — typical brute-forcer
// per-username — attacker who rotates IPs (X-Forwarded-For
// spoofing, residential proxy pool) but pounds
// a single account
// global — backstop. If the attacker controls enough
// IP+username combos to slip past the first two,
// this caps the total login attempts per minute
// across the install. Lock occurs at the FIRST
// limit hit; we don't reveal which one.
const usernameKey = username.trim().toLowerCase();
const [rlIp, rlUser, rlGlobal] = await Promise.all([
checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 }),
checkRateLimit(`login-user:${usernameKey}`, { max: 5, windowSec: 900 }),
checkRateLimit(`login-global`, { max: 100, windowSec: 60 }),
]);
if (rlIp.limited || rlUser.limited || rlGlobal.limited) {
logger.warn(
{
ip,
username: usernameKey,
limit: rlIp.limited ? "ip" : rlUser.limited ? "username" : "global",
},
"login rate-limited",
);
return { ok: false, error: "Too many attempts. Try again later." };
}
const row = await db.query.operators.findFirst({
where: (o) => sql`lower(${o.username}) = lower(${username})`,
});
// User exists but has no password configured: this is a server-side
// setup error, not a credential mismatch. Surface a distinct message
// so the operator knows to run scripts/set-password.sh. We still ran
// the DB lookup, so the username-enumeration concern is not relevant
// here (the attacker would already need a known username).
if (row && row.passwordHash === null) {
return {
ok: false,
error: "Set a password via scripts/set-password.sh before signing in.",
};
}
// Run bcrypt regardless to keep the user-not-found path timing-
// equivalent to the wrong-password path.
const hash = row?.passwordHash ?? DUMMY_HASH;
const ok = await bcrypt.compare(password, hash);
if (!row || !ok) {
logger.warn({ username, ip }, "login failed");
return { ok: false, error: "Invalid username or password." };
}
if (row.role !== "admin" && row.role !== "user") {
return { ok: false, error: "Account is not enabled." };
}
const secret = process.env.AUTH_SECRET;
if (!secret) {
logger.warn({}, "AUTH_SECRET unset — cannot issue cookie");
return { ok: false, error: "Server is not configured for sign-in." };
}
const v = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
const now = Math.floor(Date.now() / 1000);
const cookie = await signSession(
{
userId: row.id,
role: row.role as Role,
iat: now,
exp: now + DEFAULT_TTL_SECONDS,
v,
},
secret,
);
const jar = await cookies();
// Secure: only require https in production. In dev we hit
// http://localhost:9000 directly, and Firefox/Safari silently drop
// Set-Cookie when Secure is set on http origins (Chrome has a
// localhost exception, others don't), which manifested as the
// session cookie never being persisted across requests.
jar.set(COOKIE_NAME, cookie, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: DEFAULT_TTL_SECONDS,
});
// Typed-routes is on (next.config.ts experimental.typedRoutes); the
// `next` value is a runtime string from the form so we cast through any.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(safeRedirect(next) as any);
}
export async function logoutAction(): Promise<void> {
const jar = await cookies();
jar.delete(COOKIE_NAME);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/login" as any);
}

View File

@ -33,12 +33,6 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
} }
const groupId = parsed.data.groupId;
const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 });
if (groupRl.limited) {
return { ok: false, error: "Too many tests for this group. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const group = await db.query.whatsappGroups.findFirst({ const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, parsed.data.groupId), where: (g, { eq }) => eq(g.id, parsed.data.groupId),

View File

@ -271,7 +271,7 @@ const createReminderSchema = z
path: ["messages"], path: ["messages"],
}, },
) )
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), { .refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 18), {
message: "Delivery window start must be earlier than end", message: "Delivery window start must be earlier than end",
path: ["deliveryWindowStartHour"], path: ["deliveryWindowStartHour"],
}); });
@ -328,11 +328,7 @@ export async function createReminderAction(
timezone, timezone,
} = parsed.data; } = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6; const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
// operator hasn't ticked the optional "Pause sending by" checkbox;
// fall back to 24 here so legacy payloads / direct API calls don't
// accidentally enable the deadline at 6pm.
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
const parts = resolveMessageParts(parsed.data); const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator(); const op = await getSeededOperator();
@ -446,11 +442,7 @@ export async function updateReminderAction(
timezone, timezone,
} = parsed.data; } = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6; const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
// operator hasn't ticked the optional "Pause sending by" checkbox;
// fall back to 24 here so legacy payloads / direct API calls don't
// accidentally enable the deadline at 6pm.
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
const parts = resolveMessageParts(parsed.data); const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator(); const op = await getSeededOperator();
@ -571,12 +563,6 @@ export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string
export async function resumeReminderRunAction(input: { export async function resumeReminderRunAction(input: {
runId: string; runId: string;
}): Promise<ResumeReminderRunResult> { }): Promise<ResumeReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input); const parsed = runIdSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
@ -627,12 +613,6 @@ export type CancelReminderRunResult = { ok: true } | { ok: false; error: string
export async function cancelReminderRunAction(input: { export async function cancelReminderRunAction(input: {
runId: string; runId: string;
}): Promise<CancelReminderRunResult> { }): Promise<CancelReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input); const parsed = runIdSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {

View File

@ -1,192 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const {
requireAdminMock,
findUserMock,
findManyAdminsMock,
insertReturningMock,
updateMock,
deleteMock,
checkRateLimitMock,
revalidateMock,
} = vi.hoisted(() => ({
requireAdminMock: vi.fn(),
findUserMock: vi.fn(),
findManyAdminsMock: vi.fn(),
insertReturningMock: vi.fn(),
updateMock: vi.fn(),
deleteMock: vi.fn(),
checkRateLimitMock: vi.fn(),
revalidateMock: vi.fn(),
}));
vi.mock("@/lib/auth", async () => {
const actual = await vi.importActual<typeof import("@/lib/auth")>("@/lib/auth");
return {
...actual,
requireAdmin: () => requireAdminMock(),
};
});
vi.mock("@/lib/db", () => ({
db: {
query: {
operators: {
findFirst: (...a: unknown[]) => findUserMock(...a),
findMany: (...a: unknown[]) => findManyAdminsMock(...a),
},
},
insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }),
update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }),
delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }),
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
}));
vi.mock("next/cache", () => ({ revalidatePath: revalidateMock }));
vi.mock("next/headers", () => ({
headers: async () => ({ get: () => "127.0.0.1" }),
}));
beforeEach(() => {
requireAdminMock.mockReset();
findUserMock.mockReset();
findManyAdminsMock.mockReset();
insertReturningMock.mockReset();
updateMock.mockReset();
deleteMock.mockReset();
checkRateLimitMock.mockReset();
revalidateMock.mockReset();
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
});
const ADMIN = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
};
const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" };
const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const };
import {
createUserAction,
setUserRoleAction,
resetUserPasswordAction,
deleteUserAction,
} from "./users";
describe("createUserAction", () => {
it("admin can create a user with role 'user'", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
insertReturningMock.mockResolvedValue([{ id: USER.id }]);
const r = await createUserAction({
username: "bob",
password: "longpw1",
role: "user",
});
expect(r).toEqual({ ok: true, userId: USER.id });
});
it("rejects username/password under length limits", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
const r = await createUserAction({ username: "a", password: "shortpw", role: "user" });
expect(r.ok).toBe(false);
});
});
describe("setUserRoleAction — self-demote guard", () => {
it("admin demoting themselves is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(ADMIN);
const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" });
expect(r).toEqual({
ok: false,
error: "You can't demote your own account.",
});
expect(updateMock).not.toHaveBeenCalled();
});
it("admin demoting another admin is allowed when others remain", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
expect(r).toEqual({ ok: true });
});
it("admin demoting the last remaining admin is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/last admin/i);
});
});
describe("deleteUserAction", () => {
it("admin deleting themselves is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(ADMIN);
const r = await deleteUserAction({ userId: ADMIN.id });
expect(r).toEqual({ ok: false, error: "You can't delete your own account." });
expect(deleteMock).not.toHaveBeenCalled();
});
it("admin deleting another user is allowed", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]);
const r = await deleteUserAction({ userId: USER.id });
expect(r).toEqual({ ok: true });
});
it("admin deleting the last admin is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total
const r = await deleteUserAction({ userId: OTHER_ADMIN.id });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/last admin/i);
});
});
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: "alpha7!" });
expect(r).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalled();
});
it("rejects too-short passwords", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
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

@ -1,139 +0,0 @@
"use server";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
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 MAX_FIELD_LEN = 256;
async function rateLimit(key: string): Promise<{ limited: boolean }> {
const h = await headers();
const ip =
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
return checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 });
}
export type CreateUserResult =
| { ok: true; userId: string }
| { ok: false; error: string };
export async function createUserAction(input: {
username: string;
password: string;
role: "admin" | "user";
}): Promise<CreateUserResult> {
await requireAdmin();
const rl = await rateLimit("create-user");
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
const u = input.username.trim();
if (u.length < 3 || u.length > MAX_FIELD_LEN) {
return { ok: false, error: "Username must be 3..256 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." };
}
const hash = await bcrypt.hash(input.password, 12);
const [row] = await db
.insert(operators)
.values({
username: u,
passwordHash: hash,
displayName: u,
role: input.role,
defaultTimezone: "Asia/Kuala_Lumpur",
})
.returning({ id: operators.id });
revalidatePath("/settings/users");
return { ok: true, userId: row!.id };
}
export type SetRoleResult = { ok: true } | { ok: false; error: string };
export async function setUserRoleAction(input: {
userId: string;
role: "admin" | "user";
}): Promise<SetRoleResult> {
const me = await requireAdmin();
if (input.userId === me.id && input.role !== "admin") {
return { ok: false, error: "You can't demote your own account." };
}
const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
});
if (!target) return { ok: false, error: "User not found." };
// If we're demoting an admin, make sure at least one admin remains.
if (target.role === "admin" && input.role !== "admin") {
const admins = await db.query.operators.findMany({
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
});
if (admins.length <= 1) {
return { ok: false, error: "Can't demote the last admin. Promote another user first." };
}
}
await db
.update(operators)
.set({ role: input.role })
.where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}
export type DeleteUserResult = { ok: true } | { ok: false; error: string };
export async function deleteUserAction(input: {
userId: string;
}): Promise<DeleteUserResult> {
const me = await requireAdmin();
if (input.userId === me.id) {
return { ok: false, error: "You can't delete your own account." };
}
const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
});
if (!target) return { ok: false, error: "User not found." };
if (target.role === "admin") {
const admins = await db.query.operators.findMany({
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
});
if (admins.length <= 1) {
return { ok: false, error: "Can't delete the last admin. Promote another user first." };
}
}
await db.delete(operators).where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}
export type ResetPasswordResult = { ok: true } | { ok: false; error: string };
export async function resetUserPasswordAction(input: {
userId: string;
newPassword: string;
}): Promise<ResetPasswordResult> {
await requireAdmin();
const rl = await rateLimit("reset-password");
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
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),
});
if (!target) return { ok: false, error: "User not found." };
const hash = await bcrypt.hash(input.newPassword, 12);
await db
.update(operators)
.set({ passwordHash: hash })
.where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}

View File

@ -1,104 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { ChevronRightIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteAccountAction } from "@/actions/accounts";
interface DeleteAccountCardProps {
accountId: string;
accountLabel: string;
}
export function DeleteAccountCard({
accountId,
accountLabel,
}: DeleteAccountCardProps) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
function confirm() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await deleteAccountAction(fd);
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Card
role="button"
tabIndex={0}
aria-label="Delete account"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">
Delete Account
</p>
<p className="text-xs text-muted-foreground">
Remove the account and all its reminders, groups, and history
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
<strong>{accountLabel}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={confirm}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<Trash2Icon className="size-4" />
)}
Yes, delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -4,6 +4,7 @@ import {
ArrowLeftIcon, ArrowLeftIcon,
SearchIcon, SearchIcon,
UsersIcon, UsersIcon,
RefreshCwIcon,
Users2Icon, Users2Icon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,7 +16,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries"; import { listGroupsForAccount } from "@/lib/queries";
import { RefreshGroupsClient } from "./refresh-groups-client";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -57,7 +57,13 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
</Badge> </Badge>
</div> </div>
<RefreshGroupsClient accountId={account.id} /> {/* Refresh button — no-op placeholder, wired in Task 17 */}
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
<Button type="submit" variant="outline" size="sm" className="shrink-0">
<RefreshCwIcon />
Refresh Groups
</Button>
</form>
</div> </div>
{/* Search */} {/* Search */}

View File

@ -1,68 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon, RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEvents } from "@/hooks/use-events";
import { syncGroupsAction } from "@/actions/accounts";
interface RefreshGroupsClientProps {
accountId: string;
}
/**
* Two-stage refresh button:
* 1. Click server action pgNotifies the bot to start a sync.
* 2. Bot finishes emits `groups.synced` over SSE router.refresh()
* re-fetches the page so the new rows appear without the operator
* having to reload manually.
*
* The button stays in its "syncing" state until either the
* `groups.synced` event arrives for this account or 15 s pass (so a
* disconnected bot doesn't strand the spinner forever).
*/
export function RefreshGroupsClient({ accountId }: RefreshGroupsClientProps) {
const router = useRouter();
const [pending, start] = useTransition();
const [waiting, setWaiting] = useState(false);
useEvents({
"groups.synced": (data) => {
if (data.accountId !== accountId) return;
setWaiting(false);
router.refresh();
},
});
function trigger() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await syncGroupsAction(fd);
setWaiting(true);
// Belt-and-braces: if the bot is unreachable or the SSE channel
// drops, drop the spinner after 15 s instead of leaving it stuck.
window.setTimeout(() => setWaiting(false), 15_000);
});
}
const busy = pending || waiting;
return (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={busy}
onClick={trigger}
>
{busy ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
{busy ? "Syncing…" : "Refresh Groups"}
</Button>
);
}

View File

@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
UsersIcon, UsersIcon,
Trash2Icon,
ArrowLeftIcon, ArrowLeftIcon,
SmartphoneIcon, SmartphoneIcon,
CalendarIcon, CalendarIcon,
@ -9,6 +10,7 @@ import {
DatabaseIcon, DatabaseIcon,
PencilIcon, PencilIcon,
PowerIcon, PowerIcon,
PowerOffIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -18,12 +20,23 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AccountStatusBadge } from "@/components/account-status-badge"; import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries"; import { getAccount } from "@/lib/queries";
import { pairAccountAction } from "@/actions/accounts"; import {
import { DeleteAccountCard } from "./delete-account-card"; unpairAccountAction,
import { UnpairAccountCard } from "./unpair-account-card"; pairAccountAction,
deleteAccountAction,
} from "@/actions/accounts";
interface AccountDetailPageProps { interface AccountDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -143,11 +156,102 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</Card> </Card>
</Link> </Link>
<UnpairAccountCard accountId={account.id} accountLabel={account.label} /> {/* Unpair transparent <button> overlay opens the dialog
so we don't pass button-specific props onto the Card div
(Radix asChild does that and it produces a hydration
mismatch on a div). */}
<Dialog>
<Card className="relative transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-medium">Unpair</p>
<p className="text-xs text-muted-foreground">
Disconnect from WhatsApp; keep the account so you can re-pair later
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
<DialogTrigger asChild>
<button
type="button"
aria-label="Unpair WhatsApp"
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</DialogTrigger>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will disconnect from WhatsApp and
scheduled reminders using it will stop firing until you re-pair.
The account itself is kept; reminders and other data are not deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={unpairAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="default" size="sm">
<PowerOffIcon />
Yes, unpair
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
)} )}
<DeleteAccountCard accountId={account.id} accountLabel={account.label} /> {/* Delete — transparent <button> overlay opens the dialog. */}
<Dialog>
<Card className="relative transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">Delete Account</p>
<p className="text-xs text-muted-foreground">
Remove the account and all its reminders, groups, and history
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
<DialogTrigger asChild>
<button
type="button"
aria-label="Delete account"
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
/>
</DialogTrigger>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={deleteAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
<Card> <Card>

View File

@ -1,102 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { ChevronRightIcon, Loader2Icon, PowerOffIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { unpairAccountAction } from "@/actions/accounts";
interface UnpairAccountCardProps {
accountId: string;
accountLabel: string;
}
export function UnpairAccountCard({
accountId,
accountLabel,
}: UnpairAccountCardProps) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
function confirm() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await unpairAccountAction(fd);
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Card
role="button"
tabIndex={0}
aria-label="Unpair WhatsApp"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-medium">Unpair</p>
<p className="text-xs text-muted-foreground">
Disconnect from WhatsApp; keep the account so you can re-pair later
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{accountLabel}</strong> will disconnect from WhatsApp and
scheduled reminders using it will stop firing until you re-pair.
The account itself is kept; reminders and other data are not
deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
size="sm"
disabled={pending}
onClick={confirm}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PowerOffIcon className="size-4" />
)}
Yes, unpair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -14,6 +14,15 @@ 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,
@ -29,6 +38,7 @@ 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";
@ -96,24 +106,24 @@ function RunStatusBadge({ status }: { status: string }) {
); );
} }
type FilterValue = "success" | "paused" | "failed" | "archived"; type FilterValue =
| "all"
| "success"
| "paused"
| "partial"
| "failed"
| "skipped"
| "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: "partial", label: "Partial" },
{ value: "failed", label: "Failed" }, { value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ value: "archived", label: "Archived" }, { value: "archived", label: "Archived" },
]; ];
// Partial runs (some recipients ok, some failed) surface under BOTH the
// Paused and Failed tabs — the operator wants to see anything that didn't
// fully succeed on either page. Skipped runs collapse into Archived since
// they're effectively "history that the operator chose not to send".
const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
success: ["success"],
paused: ["paused", "partial"],
failed: ["failed", "partial"],
};
interface PageProps { interface PageProps {
searchParams: Promise<{ filter?: string }>; searchParams: Promise<{ filter?: string }>;
} }
@ -175,41 +185,76 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const filter: FilterValue = const filter: FilterValue =
sp.filter === "success" || sp.filter === "success" ||
sp.filter === "paused" || sp.filter === "paused" ||
sp.filter === "partial" ||
sp.filter === "failed" || sp.filter === "failed" ||
sp.filter === "skipped" ||
sp.filter === "archived" sp.filter === "archived"
? sp.filter ? sp.filter
: "success"; : "all";
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 === "archived" filter === "all" || filter === "archived"
? runs ? runs
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status)); : runs.filter((r) => r.status === filter);
const hasAny = runs.length > 0; const hasAny = runs.length > 0;
return ( return (
<PageShell title="Activity"> <PageShell
{/* Filter tabs span the full row and wrap onto a second line when the title="Activity"
viewport can't fit them all. Each trigger has a small basis so they action={
share space evenly while still keeping a readable label on mobile. */} 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
}
>
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
packed into a phone-width row left every label squeezed to
~50px. Wrap the list in an overflow-x scroller so each tab
keeps a readable label + comfortable touch target on mobile;
on desktop the row fits naturally and no scroll bar appears.
Negative margins extend the scroller to the page edges so the
first/last tabs don't look clipped against the container. */}
<Tabs value={filter}> <Tabs value={filter}>
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1"> <div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{FILTER_TABS.map(({ value, label }) => ( <TabsList>
<TabsTrigger {FILTER_TABS.map(({ value, label }) => (
key={value} <TabsTrigger key={value} value={value} asChild>
value={value} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
asChild <Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
className="h-8 grow basis-20" {label}
> </Link>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} </TabsTrigger>
<Link href={`/activity?filter=${value}` as any}> ))}
{label} </TabsList>
</Link> </div>
</TabsTrigger>
))}
</TabsList>
</Tabs> </Tabs>
{filtered.length > 0 ? ( {filtered.length > 0 ? (
@ -377,7 +422,11 @@ export default async function ActivityPage({ searchParams }: PageProps) {
<EmptyState <EmptyState
icon={ActivityIcon} icon={ActivityIcon}
title={ title={
showingArchived ? "No archived runs." : `No ${filter} runs yet.` filter === "all"
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`
} }
description={ description={
hasAny hasAny

View File

@ -4,14 +4,12 @@ import { ThemeProvider } from "@/components/theme-provider";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { NotificationManager } from "@/components/notification-manager"; import { NotificationManager } from "@/components/notification-manager";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { getCurrentUser } from "@/lib/auth";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "cm WhatsApp Bot", title: "cm WhatsApp Bot",
description: "Self-hosted WhatsApp reminder bot", description: "Self-hosted WhatsApp reminder bot",
applicationName: "cm WhatsApp Bot", applicationName: "cm WhatsApp Bot",
robots: { index: false, follow: false },
// PWA wiring: the manifest comes from the dynamic route at // PWA wiring: the manifest comes from the dynamic route at
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is // src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
// emitted from public/, and `appleWebApp.capable` lets iOS treat the // emitted from public/, and `appleWebApp.capable` lets iOS treat the
@ -34,13 +32,7 @@ export const viewport: Viewport = {
], ],
}; };
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
// Pass the role into AppShell so the nav can hide admin-only entries
// for the 'user' role. On /login getCurrentUser returns null and
// AppShell short-circuits to the bare header anyway.
const me = await getCurrentUser();
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
@ -53,7 +45,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} username={username}>{children}</AppShell> <AppShell>{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

@ -1,101 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2Icon, LockIcon, HelpCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { loginAction } from "@/actions/auth";
export function LoginFormClient({ next }: { next: string }) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
function handle(formData: FormData) {
formData.append("next", next);
start(async () => {
setError(null);
const r = await loginAction(formData);
// On success, the action redirects (no return). If we land here,
// something failed and `r` is the error shape.
if (r && !r.ok) setError(r.error);
});
}
return (
<form action={handle} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
autoComplete="username"
autoFocus
required
maxLength={256}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
maxLength={256}
/>
</div>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
<Button type="submit" disabled={pending} className="w-full gap-2">
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LockIcon className="size-4" />
)}
Sign in
</Button>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="link"
size="sm"
className="w-full text-xs text-muted-foreground hover:text-foreground"
>
<HelpCircleIcon className="size-3.5" />
Forgot password?
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Forgot your password?</DialogTitle>
<DialogDescription>
Contact your administrator to reset it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" size="sm">
Got it
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
);
}

View File

@ -1,25 +0,0 @@
import { Card, CardContent } from "@/components/ui/card";
import { LoginFormClient } from "./login-form-client";
export const metadata = {
title: "Sign in",
};
interface PageProps {
searchParams: Promise<{ next?: string }>;
}
export default async function LoginPage({ searchParams }: PageProps) {
const sp = await searchParams;
const next = sp.next ?? "/";
return (
<div className="flex items-center justify-center px-4 py-8 min-h-[calc(100dvh-3.5rem)]">
<Card className="w-full max-w-sm">
<CardContent className="pt-6">
<LoginFormClient next={next} />
</CardContent>
</Card>
</div>
);
}

View File

@ -217,7 +217,7 @@ export default async function DashboardPage() {
themselves are not affected. themselves are not affected.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter showCloseButton>
<form action={clearHistoryAction}> <form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm"> <Button type="submit" variant="destructive" size="sm">
<Trash2Icon /> <Trash2Icon />

View File

@ -177,7 +177,7 @@ function ConfirmCard(props: ConfirmCardProps) {
{error} {error}
</p> </p>
)} )}
<DialogFooter> <DialogFooter showCloseButton>
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
setSubmitting(true); setSubmitting(true);

View File

@ -230,28 +230,12 @@ export default async function ReminderDetailPage({ params }: Props) {
</p> </p>
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p> <p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
{reminder.rrule && reminder.scheduledAt ? ( {reminder.rrule && reminder.scheduledAt ? (
// Single-line summary with mid-string ellipsis. Long <p className="flex items-center gap-1.5 text-xs text-primary/80">
// descriptions ("Every month on days 4, 6, 11, 13, 18, <RepeatIcon className="size-3 shrink-0" />
// 20 +2 more at 11:32") truncate cleanly via `truncate` {describeRecurrence(
// (overflow-hidden + text-ellipsis + whitespace-nowrap)
// so the card height stays predictable. The native
// browser tooltip on `title` lets the operator read
// the full string without leaving the page; the edit
// form is the canonical full view.
<p
className="flex items-center gap-1.5 text-xs text-primary/80"
title={describeRecurrence(
specFromRrule(reminder.rrule), specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)} )}
>
<RepeatIcon className="size-3 shrink-0" />
<span className="truncate min-w-0">
{describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
</span>
</p> </p>
) : ( ) : (
<p className="text-xs text-muted-foreground">One-off</p> <p className="text-xs text-muted-foreground">One-off</p>

View File

@ -247,30 +247,15 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</p> </p>
</div> </div>
{/* Right meta column. Capped at ~14rem so a long <div className="shrink-0 text-right space-y-1">
recurrence description ("Every month on days <div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
4, 6, 7, 11, 13, 14 +6 more at 11:32") can't
starve the reminder name on the left. min-w-0
+ truncate on each span ellipsises overflow
inside the cap. Title tooltip preserves the
full text on hover. */}
<div className="min-w-0 max-w-[34%] sm:max-w-[9.5rem] text-right space-y-1">
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" /> <CalendarIcon className="size-3 shrink-0" />
<span className="truncate"> <span>{formatWhen(reminder.scheduledAt, tz)}</span>
{formatWhen(reminder.scheduledAt, tz)}
</span>
</div> </div>
{reminder.rrule && reminder.scheduledAt ? ( {reminder.rrule && reminder.scheduledAt ? (
<div <div className="flex items-center justify-end gap-1 text-xs text-primary/80">
className="flex min-w-0 items-center justify-end gap-1 text-xs text-primary/80"
title={describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
>
<RepeatIcon className="size-3 shrink-0" /> <RepeatIcon className="size-3 shrink-0" />
<span className="truncate"> <span>
{describeRecurrence( {describeRecurrence(
specFromRrule(reminder.rrule), specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
@ -279,9 +264,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</div> </div>
) : null} ) : null}
{reminder.groupCount > 0 && ( {reminder.groupCount > 0 && (
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground"> <div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" /> <UsersIcon className="size-3 shrink-0" />
<span className="truncate"> <span>
{reminder.groupCount}{" "} {reminder.groupCount}{" "}
{reminder.groupCount === 1 ? "group" : "groups"} {reminder.groupCount === 1 ? "group" : "groups"}
</span> </span>

View File

@ -1,5 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return { rules: [{ userAgent: "*", disallow: "/" }] };
}

View File

@ -7,7 +7,6 @@ import { PageShell } from "@/components/page-shell";
export default async function SettingsPage() { export default async function SettingsPage() {
const op = await getSeededOperator(); const op = await getSeededOperator();
const isAdmin = op.role === "admin";
return ( return (
<PageShell title="Settings" narrow> <PageShell title="Settings" narrow>
<Card> <Card>
@ -15,15 +14,13 @@ 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="Username" value={op.username} mono /> <Row label="Display name" value={op.displayName} />
<Separator />
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
<Separator /> <Separator />
<Row label="Default timezone" value={op.defaultTimezone} mono /> <Row label="Default timezone" value={op.defaultTimezone} mono />
{isAdmin && ( <Separator />
<> <Row label="Role" value={op.role} mono />
<Separator />
<Row label="Role" value={op.role} mono />
</>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -50,6 +47,10 @@ export default async function SettingsPage() {
<ThemeToggle /> <ThemeToggle />
</CardContent> </CardContent>
</Card> </Card>
<p className="text-center text-xs text-muted-foreground">
cm WhatsApp Bot · self-hosted
</p>
</PageShell> </PageShell>
); );
} }

View File

@ -1,95 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2Icon, UserPlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createUserAction } from "@/actions/users";
export function AddUserFormClient() {
const [pending, start] = useTransition();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState(false);
function submit() {
start(async () => {
setError(null);
setOk(false);
const r = await createUserAction({
username: username.trim(),
password,
role,
});
if (!r.ok) {
setError(r.error);
return;
}
setUsername("");
setPassword("");
setRole("user");
setOk(true);
});
}
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-username">Username</Label>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
maxLength={256}
placeholder="alice"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">Password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
maxLength={256}
placeholder="≥6 chars · letters + number/symbol"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-role">Role</Label>
<select
id="new-role"
value={role}
onChange={(e) => setRole(e.target.value === "admin" ? "admin" : "user")}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div className="flex items-center justify-end gap-2">
{error && <p className="mr-auto text-xs text-destructive">{error}</p>}
{ok && (
<p className="mr-auto text-xs text-emerald-600 dark:text-emerald-400">
User created.
</p>
)}
<Button type="button" size="sm" disabled={pending} onClick={submit}>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<UserPlusIcon className="size-4" />
)}
Add user
</Button>
</div>
</div>
);
}

View File

@ -1,62 +0,0 @@
import { requireAdmin } from "@/lib/auth";
import { db } from "@/lib/db";
import { PageShell } from "@/components/page-shell";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { UserRowClient } from "./user-row-client";
import { AddUserFormClient } from "./add-user-form-client";
export default async function UsersPage() {
const me = await requireAdmin();
const rows = await db.query.operators.findMany({
orderBy: (o, { asc }) => [asc(o.username)],
});
const adminCount = rows.filter((r) => r.role === "admin").length;
return (
<PageShell title="Users">
<Card>
<CardHeader>
<CardTitle>Add user</CardTitle>
<CardDescription>
Create a sign-in account. Passwords must be at least 10
characters.
</CardDescription>
</CardHeader>
<CardContent>
<AddUserFormClient />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All users</CardTitle>
<CardDescription>
Promote a user to admin, demote them back, reset their
password, or delete the account. The last admin cannot be
demoted or deleted.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rows.map((u) => (
<UserRowClient
key={u.id}
user={{
id: u.id,
username: u.username,
role: u.role === "admin" ? "admin" : "user",
}}
isSelf={u.id === me.id}
isLastAdmin={u.role === "admin" && adminCount === 1}
/>
))}
</CardContent>
</Card>
</PageShell>
);
}

View File

@ -1,197 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import {
Loader2Icon,
Trash2Icon,
KeyIcon,
ArrowUpIcon,
ArrowDownIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import {
setUserRoleAction,
resetUserPasswordAction,
deleteUserAction,
} from "@/actions/users";
import { validatePassword } from "@/lib/password-policy";
interface UserRowClientProps {
user: { id: string; username: string; role: "admin" | "user" };
isSelf: boolean;
/** True when this row is the only remaining admin. Disables demote+delete. */
isLastAdmin: boolean;
}
export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
const [resetVisible, setResetVisible] = useState(false);
const [resetPw, setResetPw] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
function run<T extends { ok: boolean; error?: string }>(promise: Promise<T>) {
start(async () => {
setError(null);
const r = await promise;
if (!r.ok) setError(r.error ?? "Failed");
});
}
const isAdmin = user.role === "admin";
// The role-toggle button is disabled if:
// - flipping yourself (admin self-demotion is rejected server-side too)
// - this row is the last remaining admin and would become a user
const roleToggleDisabled = pending || isSelf || (isAdmin && isLastAdmin);
const deleteDisabled = pending || isSelf || isLastAdmin;
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
{/* Row 1 identity: username on the left, role badge + "you"
chip on the right, all on one line. */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium truncate">
{user.username}
</p>
{isSelf && (
<span className="text-xs text-muted-foreground shrink-0">you</span>
)}
</div>
<Badge
variant="secondary"
className={
isAdmin
? "shrink-0 bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
: "shrink-0 bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
}
>
{user.role}
</Badge>
</div>
{/* Row 2 — actions: Promote/Demote, Reset, Delete, right-aligned. */}
<div className="flex flex-wrap justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
disabled={roleToggleDisabled}
onClick={() =>
run(
setUserRoleAction({
userId: user.id,
role: isAdmin ? "user" : "admin",
}),
)
}
>
{isAdmin ? (
<ArrowDownIcon className="size-3.5" />
) : (
<ArrowUpIcon className="size-3.5" />
)}
{isAdmin ? "Demote" : "Promote"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() => setResetVisible((v) => !v)}
>
<KeyIcon className="size-3.5" />
Reset
</Button>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive"
disabled={deleteDisabled}
>
<Trash2Icon className="size-3.5" />
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete user @{user.username}?</DialogTitle>
<DialogDescription>
This permanently removes the account. They will be
signed out on their next request and cannot sign in
again. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={() => {
setDeleteOpen(false);
run(deleteUserAction({ userId: user.id }));
}}
>
{pending ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Delete user
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{resetVisible && (
<div className="flex gap-2">
<Input
type="password"
placeholder="New password (≥6 chars · letters + number/symbol)"
value={resetPw}
onChange={(e) => setResetPw(e.target.value)}
maxLength={256}
/>
<Button
type="button"
size="sm"
disabled={pending || !validatePassword(resetPw).ok}
onClick={() => {
run(
resetUserPasswordAction({
userId: user.id,
newPassword: resetPw,
}),
);
setResetPw("");
setResetVisible(false);
}}
>
Save
</Button>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}

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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<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" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -232,22 +232,21 @@ describe("AppShell — desktop sidebar (SSR)", () => {
} }
}); });
it("renders a Sign out button in the sidebar footer", () => { it("keeps the theme toggle in the sidebar footer", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
// Theme toggle was dropped from the shell per request; the footer // The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
// now carries the Sign out affordance + the signed-in username. // appear in the sidebar (we removed it from the mobile drawer).
expect(html).toContain('aria-label="Sign out"'); expect(html).toContain('data-testid="theme-toggle"');
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" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -265,7 +264,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" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -274,79 +273,6 @@ describe("AppShell — desktop sidebar (SSR)", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// Role-gated nav (admin panel)
// ---------------------------------------------------------------------------
describe("AppShell — role-based nav filtering", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("shows the Admin entry in BOTH the sidebar and drawer when role=admin", () => {
const html = renderToStaticMarkup(
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
expect(html).toContain('href="/settings/users"');
// A label appears in both the sidebar and the drawer; either way the
// count must be >=2 (sidebar copy + drawer copy).
const occurrences = (html.match(/href="\/settings\/users"/g) ?? []).length;
expect(occurrences).toBeGreaterThanOrEqual(2);
});
it("hides the Admin entry from BOTH surfaces when role=user", () => {
const html = renderToStaticMarkup(
<AppShell role="user" username="alice">
<div />
</AppShell>,
);
expect(html).not.toContain('href="/settings/users"');
});
it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => {
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell role={null} username={null}>
<div />
</AppShell>,
);
expect(html).not.toContain('href="/settings/users"');
});
it("does NOT hide entries that have no visibleTo restriction for either role", () => {
const adminHtml = renderToStaticMarkup(
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
const userHtml = renderToStaticMarkup(
<AppShell role="user" username="alice">
<div />
</AppShell>,
);
for (const item of NAV_ITEMS) {
if (item.visibleTo) continue;
expect(adminHtml).toContain(`href="${item.href}"`);
expect(userHtml).toContain(`href="${item.href}"`);
}
});
it("/login route renders the bare header — no nav, no drawer, regardless of role", () => {
pathnameMock.mockReturnValue("/login");
const html = renderToStaticMarkup(
<AppShell role={null} username={null}>
<div />
</AppShell>,
);
expect(html).not.toContain("<aside");
expect(html).not.toContain('data-testid="sheet-content"');
expect(html).not.toContain('href="/settings/users"');
expect(html).toContain("WhatsApp Bot");
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// helpers // helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,12 +1,11 @@
"use client"; "use client";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react"; import { MenuIcon } 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,
@ -15,13 +14,8 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { import { NAV_ITEMS } from "@/components/nav-config";
NAV_ITEMS, import { ThemeToggle } from "@/components/theme-toggle";
navItemsForRole,
pickActiveNavKey,
type NavItem,
type NavRole,
} from "@/components/nav-config";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mobile header (sm:hidden) // Mobile header (sm:hidden)
@ -36,51 +30,8 @@ import {
// 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() {
// 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">
Signed in as <em className="italic font-medium text-foreground">{username}</em>
</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 activeKey = pickActiveNavKey(items, pathname);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Close the drawer when the route changes (i.e. the user picked a nav // Close the drawer when the route changes (i.e. the user picked a nav
@ -90,10 +41,6 @@ function MobileHeader({
setOpen(false); setOpen(false);
}, [pathname]); }, [pathname]);
// Use the full list (not the role-filtered one) for the title lookup
// so the page title still shows up correctly when a 'user' role hits
// a route they wouldn't normally see in the nav (e.g. arrives via a
// direct link), even though they can't navigate there from the menu.
const currentItem = NAV_ITEMS.find(({ href }) => const currentItem = NAV_ITEMS.find(({ href }) =>
href === "/" ? pathname === "/" : pathname.startsWith(href), href === "/" ? pathname === "/" : pathname.startsWith(href),
); );
@ -143,10 +90,10 @@ function MobileHeader({
<nav <nav
aria-label="Primary navigation" aria-label="Primary navigation"
className="flex flex-col gap-0.5 p-2" className="flex flex-col gap-0.5 p-2 flex-1"
> >
{items.map(({ key, href, label, icon: Icon }) => { {NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = activeKey === key; const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
key={key} key={key}
@ -170,10 +117,6 @@ function MobileHeader({
); );
})} })}
</nav> </nav>
<div className="mt-auto border-t border-border p-3">
<SignOutButton username={username} />
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</header> </header>
@ -183,15 +126,8 @@ function MobileHeader({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm) // Sidebar (desktop only — hidden below sm)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function Sidebar({ function Sidebar() {
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname(); const pathname = usePathname();
const activeKey = pickActiveNavKey(items, pathname);
return ( return (
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar"> <aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
@ -214,7 +150,7 @@ function Sidebar({
{/* Nav items */} {/* Nav items */}
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1"> <nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
{items.map(({ key, href, label, icon: Icon }) => { {NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
@ -236,74 +172,29 @@ function Sidebar({
})} })}
</nav> </nav>
{/* Footer: signed-in user + sign-out */} {/* Footer: theme toggle */}
<div className="border-t border-sidebar-border p-3"> <div className="border-t border-sidebar-border p-3">
<SignOutButton username={username} /> <ThemeToggle />
</div> </div>
</aside> </aside>
); );
} }
// ---------------------------------------------------------------------------
// Bare header for unauthenticated routes (/login). No sidebar, no mobile
// menu, no nav — just the centered brand mark + name. The user explicitly
// asked for nothing else here so the sign-in screen feels like a separate
// surface from the authenticated app.
// ---------------------------------------------------------------------------
function BareHeader() {
return (
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-center border-b border-border bg-background/95 backdrop-blur-sm px-3">
<div className="flex items-center gap-2">
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
<span className="text-sm font-semibold tracking-tight">
WhatsApp Bot
</span>
</div>
</header>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AppShell — the outer container // AppShell — the outer container
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; 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, username }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
const pathname = usePathname();
const isAuthRoute = pathname === "/login";
if (isAuthRoute) {
return (
<>
<BareHeader />
<main className="min-h-dvh pt-14">{children}</main>
</>
);
}
// Treat unauthenticated render of a protected route (shouldn't happen
// because middleware redirects, but defense-in-depth) as 'user': hides
// the admin-only entries.
const items = navItemsForRole(role ?? "user");
return ( return (
<> <>
{/* Desktop sidebar */} {/* Desktop sidebar */}
<Sidebar items={items} username={username} /> <Sidebar />
{/* Mobile header (single row: brand · title · menu) */} {/* Mobile header (single row: brand · title · menu) */}
<MobileHeader items={items} username={username} /> <MobileHeader />
{/* 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

@ -1,119 +0,0 @@
import { describe, it, expect } from "vitest";
import { NAV_ITEMS, navItemsForRole, pickActiveNavKey } from "./nav-config";
describe("navItemsForRole", () => {
it("includes every NAV_ITEM for an admin", () => {
const items = navItemsForRole("admin");
expect(items).toHaveLength(NAV_ITEMS.length);
for (const original of NAV_ITEMS) {
expect(items.find((i) => i.key === original.key)).toBeDefined();
}
});
it("hides admin-only entries for the 'user' role", () => {
const items = navItemsForRole("user");
const keys = items.map((i) => i.key);
expect(keys).not.toContain("admin");
});
it("returns the un-restricted entries (no visibleTo) for the 'user' role", () => {
const items = navItemsForRole("user");
const keys = items.map((i) => i.key);
expect(keys).toEqual(
expect.arrayContaining(["dashboard", "accounts", "reminders", "activity", "settings"]),
);
});
it("admin nav entry routes to /settings/users", () => {
const admin = NAV_ITEMS.find((i) => i.key === "admin");
expect(admin).toBeDefined();
expect(admin!.href).toBe("/settings/users");
expect(admin!.visibleTo).toEqual(["admin"]);
});
});
describe("pickActiveNavKey (longest-match active highlight)", () => {
// Use the real NAV_ITEMS so a future href change doesn't silently
// re-introduce the regression.
const adminItems = navItemsForRole("admin");
const userItems = navItemsForRole("user");
it("highlights ONLY the Admin entry on /settings/users (not Settings too)", () => {
// Repro of the user-reported regression. Naïve startsWith would
// light up both Settings (/settings) and Admin (/settings/users)
// because both prefixes match. The longest-match rule must pick
// the Admin entry alone.
const active = pickActiveNavKey(adminItems, "/settings/users");
expect(active).toBe("admin");
});
it("highlights Settings on /settings exact, with Admin NOT lit", () => {
const active = pickActiveNavKey(adminItems, "/settings");
expect(active).toBe("settings");
});
it("highlights Settings on a subpath that is NOT /settings/users", () => {
// Admin nav is admin-only; this test is just to confirm the
// longest-match still picks Settings when no admin descendant
// claims the path.
const active = pickActiveNavKey(adminItems, "/settings/profile");
expect(active).toBe("settings");
});
it("highlights Dashboard ONLY on '/' exact (not on every route)", () => {
expect(pickActiveNavKey(adminItems, "/")).toBe("dashboard");
expect(pickActiveNavKey(adminItems, "/accounts")).toBe("accounts");
expect(pickActiveNavKey(adminItems, "/reminders/abc-123")).toBe("reminders");
});
it("returns null when nothing matches (e.g. a route hidden from this role)", () => {
// /settings/users isn't visible to a 'user' role, so the helper
// must NOT highlight it as Settings just because /settings is a
// prefix — we'd be claiming an item is active when the user can't
// navigate to it from this nav.
expect(pickActiveNavKey(userItems, "/settings/users")).toBe("settings");
// Neither item's href matches a totally foreign route.
expect(pickActiveNavKey(adminItems, "/elsewhere")).toBeNull();
});
it("does NOT match a sibling that shares a prefix string", () => {
// /settingsfoo is NOT a child of /settings — startsWith would
// mistakenly mark Settings active. The strict descendant check
// (`href + '/'`) prevents that.
expect(pickActiveNavKey(adminItems, "/settingsfoo")).toBeNull();
});
it("each pathname highlights AT MOST one nav key (defense check)", () => {
// Walk a small representative set of routes and confirm we never
// light up two items at once. This is the contract the JSX in
// app-shell.tsx relies on.
const probes = [
"/",
"/accounts",
"/accounts/abc",
"/reminders",
"/reminders/abc",
"/activity",
"/activity?filter=success",
"/settings",
"/settings/users",
"/settings/users/something",
"/login",
"/elsewhere",
];
for (const path of probes) {
const matchCount = adminItems.filter((item) => {
if (item.href === "/") return path === "/";
return path === item.href || path.startsWith(item.href + "/");
}).length;
// If two prefixes both match, pickActiveNavKey must collapse
// them to one — that's the whole point of the helper.
const active = pickActiveNavKey(adminItems, path);
if (matchCount === 0) {
expect(active).toBeNull();
} else {
expect(active).not.toBeNull();
}
}
});
});

View File

@ -1,22 +1,11 @@
import { import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
Home,
Smartphone,
Calendar,
Activity,
Settings,
ShieldCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
export type NavRole = "admin" | "user";
export interface NavItem { export interface NavItem {
key: string; key: string;
href: string; href: string;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
/** When set, only roles listed here will see this nav entry. */
visibleTo?: NavRole[];
} }
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
@ -24,54 +13,5 @@ export const NAV_ITEMS: NavItem[] = [
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone }, { key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar }, { key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
{ key: "activity", href: "/activity", label: "Activity", icon: Activity }, { key: "activity", href: "/activity", label: "Activity", icon: Activity },
{
key: "admin",
href: "/settings/users",
label: "Admin",
icon: ShieldCheck,
visibleTo: ["admin"],
},
{ key: "settings", href: "/settings", label: "Settings", icon: Settings }, { key: "settings", href: "/settings", label: "Settings", icon: Settings },
]; ];
export function navItemsForRole(role: NavRole): NavItem[] {
return NAV_ITEMS.filter(
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
);
}
/**
* Pick the SINGLE active nav item for a given pathname. Solves the
* "Admin and Settings both highlighted on /settings/users" bug:
* naïve `pathname.startsWith(href)` matches BOTH /settings/users (the
* Admin entry) AND /settings (its parent). Two items lit up at once
* looks broken.
*
* Rules:
* - The Dashboard ('/') item only matches an exact pathname match;
* otherwise it would shadow every other route.
* - All other items match either an exact pathname or a strict
* descendant path (`<href>/...`). `pathname.startsWith(href)` on
* its own would also match `/settingsfoo`, which is wrong.
* - When two non-root items both match (parent + child), pick the
* LONGEST href so the more specific entry wins.
*
* Returns the active item's `key`, or null if no item matches (e.g.
* the user navigated to a route that isn't in the visible nav).
*/
export function pickActiveNavKey(items: NavItem[], pathname: string): string | null {
let best: NavItem | null = null;
for (const item of items) {
if (item.href === "/") {
if (pathname === "/") best = item;
continue;
}
const isMatch =
pathname === item.href || pathname.startsWith(item.href + "/");
if (!isMatch) continue;
if (!best || item.href.length > best.href.length) {
best = item;
}
}
return best?.key ?? null;
}

View File

@ -18,8 +18,7 @@ type PairingState =
| { phase: "waiting" } | { phase: "waiting" }
| { phase: "qr"; qrUrl: string } | { phase: "qr"; qrUrl: string }
| { phase: "connected"; phoneNumber: string } | { phase: "connected"; phoneNumber: string }
| { phase: "timeout" } | { phase: "timeout" };
| { phase: "duplicate"; phoneNumber: string; existingLabel: string };
interface PairLiveProps { interface PairLiveProps {
accountId: string; accountId: string;
@ -113,15 +112,6 @@ export function PairLive({ accountId, label }: PairLiveProps) {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
setPairingState({ phase: "timeout" }); setPairingState({ phase: "timeout" });
}, },
"session.duplicate": (data) => {
if (data.accountId !== accountId) return;
if (timerRef.current) clearInterval(timerRef.current);
setPairingState({
phase: "duplicate",
phoneNumber: data.phoneNumber,
existingLabel: data.existingLabel,
});
},
}); });
// Auto-redirect on connected // Auto-redirect on connected
@ -244,35 +234,6 @@ export function PairLive({ accountId, label }: PairLiveProps) {
</Button> </Button>
</div> </div>
)} )}
{pairingState.phase === "duplicate" && (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-amber-500/15">
<XCircleIcon className="size-8 text-amber-600 dark:text-amber-400" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold">Phone already linked</p>
<p className="text-xs text-muted-foreground">
<span className="font-mono">
+{pairingState.phoneNumber.replace(/^\+/, "")}
</span>{" "}
is already paired to{" "}
<span className="font-medium text-foreground">
{pairingState.existingLabel}
</span>
. Each WhatsApp number can only be linked to one account here.
Unpair the existing account first, or scan with a different
phone.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${accountId}` as any}>
Back to accounts
</Link>
</Button>
</div>
)}
</div> </div>
); );
} }

View File

@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
id="filter-account" id="filter-account"
value={initial.accountId} value={initial.accountId}
onChange={(e) => setParam("accountId", e.target.value)} onChange={(e) => setParam("accountId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:max-w-xs rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<option value="">All accounts</option> <option value="">All accounts</option>
{accounts.map((a) => ( {accounts.map((a) => (

View File

@ -67,11 +67,6 @@ export function SwipeableRow({
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null); const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
// Tracks whether the pointer crossed the click-vs-drag threshold during
// the current gesture. If it did, we swallow the synthetic click that
// browsers fire on pointerup — otherwise a swipe on a Link-wrapped row
// both swipes the shelf open AND navigates to the link target.
const dragMoved = useRef(false);
// Close the shelf when the user taps anywhere outside an open row. // Close the shelf when the user taps anywhere outside an open row.
useEffect(() => { useEffect(() => {
@ -97,17 +92,12 @@ export function SwipeableRow({
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) { function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
if (e.button !== 0 && e.pointerType === "mouse") return; if (e.button !== 0 && e.pointerType === "mouse") return;
dragStart.current = { x: e.clientX, baseOffset: offset }; dragStart.current = { x: e.clientX, baseOffset: offset };
dragMoved.current = false;
setDragging(true); setDragging(true);
} }
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) { function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging || !dragStart.current) return; if (!dragging || !dragStart.current) return;
const dx = e.clientX - dragStart.current.x; const dx = e.clientX - dragStart.current.x;
// 6 px is the standard threshold below which a touch counts as a tap
// rather than a drag. Cross it once and the gesture commits to drag
// for the rest of the pointer's lifetime.
if (Math.abs(dx) > 6) dragMoved.current = true;
setOffset(clamp(dragStart.current.baseOffset + dx)); setOffset(clamp(dragStart.current.baseOffset + dx));
} }
@ -123,28 +113,6 @@ export function SwipeableRow({
rightWidth, rightWidth,
}), }),
); );
if (dragMoved.current) {
// The browser fires a synthetic `click` on the element under the
// pointer right after pointerup. If our row body wraps a <Link>,
// that click navigates away. Add a one-shot capture-phase handler
// that swallows the next click ANYWHERE in the row container
// before it can reach the anchor's onClick.
const swallow = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
};
const node = containerRef.current;
if (node) {
node.addEventListener("click", swallow, { capture: true, once: true });
// Defensive: if for some reason no click fires (e.g. pointerup
// outside the element), strip the listener after a tick so it
// doesn't accidentally eat a future legitimate click.
window.setTimeout(() => {
node.removeEventListener("click", swallow, { capture: true });
}, 350);
}
}
dragMoved.current = false;
} }
return ( return (
@ -182,14 +150,6 @@ export function SwipeableRow({
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp} onPointerCancel={handlePointerUp}
// Anchors (and <img>) are natively draggable. When children
// contain a <Link> wrapping the card, the browser hijacks the
// pointer for a "drag link" operation as soon as the user
// moves horizontally, so the swipe gesture never reaches our
// pointer handlers. Suppress native drag here once and the
// whole row body is unblocked.
onDragStart={(e) => e.preventDefault()}
draggable={false}
style={{ style={{
transform: `translateX(${offset}px)`, transform: `translateX(${offset}px)`,
transition: dragging ? "none" : "transform 200ms ease-out", transition: dragging ? "none" : "transform 200ms ease-out",

View File

@ -9,11 +9,6 @@ export type WebEventMap = {
"session.connected": { accountId: string; phoneNumber: string | null }; "session.connected": { accountId: string; phoneNumber: string | null };
"session.disconnected": { accountId: string }; "session.disconnected": { accountId: string };
"session.timeout": { accountId: string }; "session.timeout": { accountId: string };
"session.duplicate": {
accountId: string;
phoneNumber: string;
existingLabel: string;
};
"groups.synced": { accountId: string; count: number }; "groups.synced": { accountId: string; count: number };
"reminder.fired": { "reminder.fired": {
reminderId: string; reminderId: string;

View File

@ -1,135 +0,0 @@
import { describe, it, expect, beforeAll } from "vitest";
import {
signSession,
verifySession,
COOKIE_NAME,
DEFAULT_TTL_SECONDS,
type SessionPayload,
} from "./auth-cookie";
const SECRET = "test-secret-not-used-anywhere-real";
const NOW = 1_700_000_000; // 2023-11-14 — fixed clock for determinism
beforeAll(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
});
const validPayload = (): SessionPayload => ({
userId: "11111111-1111-1111-1111-111111111111",
role: "admin",
iat: NOW,
exp: NOW + DEFAULT_TTL_SECONDS,
v: 1,
});
describe("auth-cookie (AES-256-GCM)", () => {
it("signSession + verifySession round-trips a valid payload", async () => {
const cookie = await signSession(validPayload(), SECRET);
const verified = await verifySession(cookie, SECRET, NOW);
expect(verified).toEqual(validPayload());
});
it("uses a fresh IV per call so two encryptions of the same payload differ", async () => {
// Repeating the IV under AES-GCM is catastrophic (it leaks the XOR
// of plaintexts and the auth key). Lock in that signSession draws
// a new nonce every time — the byte-for-byte cookies must not match
// even when the inputs are identical.
const a = await signSession(validPayload(), SECRET);
const b = await signSession(validPayload(), SECRET);
expect(a).not.toBe(b);
// Both still decrypt correctly with the same secret.
expect(await verifySession(a, SECRET, NOW)).toEqual(validPayload());
expect(await verifySession(b, SECRET, NOW)).toEqual(validPayload());
});
it("ciphertext does NOT leak the userId in plaintext (confidentiality)", async () => {
const cookie = await signSession(validPayload(), SECRET);
// The whole point of the GCM upgrade: someone with only the cookie
// value should not be able to read the userId / role straight off
// it the way they could with the old base64-encoded JSON.
expect(cookie).not.toContain(validPayload().userId);
expect(cookie).not.toContain("admin");
});
it("rejects when the ciphertext has been tampered with (auth tag mismatch)", async () => {
const cookie = await signSession(validPayload(), SECRET);
const [iv, ct] = cookie.split(".");
// Flip the last character of the ciphertext (still valid base64url).
const lastCh = ct!.slice(-1);
const replacement = lastCh === "A" ? "B" : "A";
const tampered = `${iv}.${ct!.slice(0, -1)}${replacement}`;
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
});
it("rejects when the IV has been swapped for another (auth tag mismatch)", async () => {
const cookie = await signSession(validPayload(), SECRET);
const otherIv = await signSession(validPayload(), SECRET);
const [, ct] = cookie.split(".");
const [otherIvB64] = otherIv.split(".");
const tampered = `${otherIvB64}.${ct}`;
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
});
it("rejects when verified with a different secret", async () => {
const cookie = await signSession(validPayload(), SECRET);
expect(await verifySession(cookie, "different-secret", NOW)).toBeNull();
});
it("rejects an expired cookie (exp <= now)", async () => {
const expired = { ...validPayload(), exp: NOW - 1 };
const cookie = await signSession(expired, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("rejects a cookie issued in the future beyond clock-skew tolerance", async () => {
const future = { ...validPayload(), iat: NOW + 120 };
const cookie = await signSession(future, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("accepts a cookie issued slightly in the future (within 60s skew)", async () => {
const future = { ...validPayload(), iat: NOW + 30 };
const cookie = await signSession(future, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).not.toBeNull();
});
it("rejects a cookie whose v is stale (token-version bumped)", async () => {
const cookie = await signSession({ ...validPayload(), v: 1 }, SECRET);
process.env.OPERATOR_TOKEN_VERSION = "2";
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
process.env.OPERATOR_TOKEN_VERSION = "1";
});
it("rejects a cookie with an unknown role string", async () => {
const cookie = await signSession(
{ ...validPayload(), role: "superadmin" as never },
SECRET,
);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("rejects a cookie that doesn't have a '.' separator", async () => {
expect(await verifySession("not-a-cookie", SECRET, NOW)).toBeNull();
expect(await verifySession("", SECRET, NOW)).toBeNull();
});
it("rejects a cookie whose IV decodes to the wrong byte length", async () => {
// GCM requires a 12-byte nonce. Swap the IV portion for something
// that decodes to a different length and confirm we bounce it
// before handing weird input to crypto.subtle.decrypt.
const cookie = await signSession(validPayload(), SECRET);
const [, ct] = cookie.split(".");
// 8 bytes encoded — too short.
const shortIv = "AAAAAAAAAAA";
expect(await verifySession(`${shortIv}.${ct}`, SECRET, NOW)).toBeNull();
});
it("rejects malformed (non-base64url) input gracefully — no throw, just null", async () => {
expect(await verifySession("$$$.$$$", SECRET, NOW)).toBeNull();
});
it("exposes COOKIE_NAME as 'session'", () => {
expect(COOKIE_NAME).toBe("session");
});
});

View File

@ -1,148 +0,0 @@
/**
* Edge-runtime-safe AES-256-GCM session cookie. Runs in middleware
* and Server Actions. NO database, NO bcrypt, NO Node-only APIs
* pure Web Crypto so it survives Edge runtime.
*
* Why GCM instead of HMAC-then-plaintext: GCM is authenticated
* encryption, so a leaked cookie no longer hands the userId/role to
* an attacker who only sees the bytes. Tampering with either the IV
* or the ciphertext invalidates the auth tag decrypt throws we
* return null. Replay protection comes from the per-payload `exp`
* field plus the global `OPERATOR_TOKEN_VERSION` kill switch.
*
* Cookie format: `<base64url(iv)>.<base64url(ciphertext+tag)>`
* - iv: 12 random bytes (GCM nonce)
* - ciphertext+tag: AES-GCM output (plaintext + 16-byte auth tag)
*/
export const COOKIE_NAME = "session";
export const DEFAULT_TTL_SECONDS = 30 * 86400; // 30 days
export const CLOCK_SKEW_SECONDS = 60;
export type Role = "admin" | "user";
export interface SessionPayload {
userId: string;
role: Role;
iat: number;
exp: number;
v: number;
}
function isValidPayload(x: unknown): x is SessionPayload {
if (typeof x !== "object" || x === null) return false;
const o = x as Record<string, unknown>;
return (
typeof o.userId === "string" &&
(o.role === "admin" || o.role === "user") &&
typeof o.iat === "number" &&
typeof o.exp === "number" &&
typeof o.v === "number"
);
}
function b64urlEncode(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(str: string): Uint8Array {
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
const s = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}
/**
* Derive a 256-bit AES key from the operator-supplied AUTH_SECRET.
* SHA-256 hashes the secret to a fixed-length key so the secret can
* be any printable string in env (no min/max length policing here).
*/
async function deriveKey(secret: string): Promise<CryptoKey> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(secret),
);
return crypto.subtle.importKey(
"raw",
digest,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"],
);
}
export async function signSession(
payload: SessionPayload,
secret: string,
): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(secret);
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
const ct = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
key,
plaintext as BufferSource,
),
);
return `${b64urlEncode(iv)}.${b64urlEncode(ct)}`;
}
export async function verifySession(
cookie: string,
secret: string,
now: number = Math.floor(Date.now() / 1000),
): Promise<SessionPayload | null> {
if (!cookie || typeof cookie !== "string") return null;
const dot = cookie.indexOf(".");
if (dot <= 0 || dot === cookie.length - 1) return null;
let iv: Uint8Array;
let ct: Uint8Array;
try {
iv = b64urlDecode(cookie.slice(0, dot));
ct = b64urlDecode(cookie.slice(dot + 1));
} catch {
return null;
}
// GCM nonces are exactly 12 bytes. A wrong-sized IV would still
// sometimes succeed at the WebCrypto layer on some platforms;
// guard explicitly so callers can't slip a non-standard nonce past us.
if (iv.length !== 12) return null;
let plain: string;
try {
const key = await deriveKey(secret);
// The IV in `AesGcmParams` must be backed by a non-shared
// ArrayBuffer; TS 5.7+ tightened Uint8Array's generic to reject
// `SharedArrayBuffer`-backed views. b64urlDecode produces a
// regular ArrayBuffer, but we cast to BufferSource explicitly so
// future allocator changes don't regress this site.
const buf = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
key,
ct as BufferSource,
);
plain = new TextDecoder().decode(buf);
} catch {
// Auth-tag mismatch (tampered cookie or wrong key) lands here.
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(plain);
} catch {
return null;
}
if (!isValidPayload(parsed)) return null;
if (parsed.exp <= now) return null;
if (parsed.iat > now + CLOCK_SKEW_SECONDS) return null;
const expectedV = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
if (parsed.v !== expectedV) return null;
return parsed;
}

View File

@ -1,89 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const cookiesGetMock = vi.fn();
const findUserMock = vi.fn();
vi.mock("next/headers", () => ({
cookies: async () => ({ get: cookiesGetMock }),
}));
vi.mock("./db", () => ({
db: {
query: {
operators: {
findFirst: (...a: unknown[]) => findUserMock(...a),
},
},
},
}));
const SECRET = "test-secret";
beforeEach(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
cookiesGetMock.mockReset();
findUserMock.mockReset();
});
import { signSession } from "./auth-cookie";
import { getCurrentUser, requireUser, requireAdmin } from "./auth";
const NOW_S = Math.floor(Date.now() / 1000);
const ADMIN = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
displayName: "Admin",
defaultTimezone: "UTC",
passwordHash: null,
};
const USER = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice", role: "user" as const };
async function makeCookie(role: "admin" | "user"): Promise<string> {
return signSession(
{
userId: role === "admin" ? ADMIN.id : USER.id,
role,
iat: NOW_S,
exp: NOW_S + 3600,
v: 1,
},
SECRET,
);
}
describe("auth helpers", () => {
it("getCurrentUser returns null when no cookie is set", async () => {
cookiesGetMock.mockReturnValue(undefined);
const u = await getCurrentUser();
expect(u).toBeNull();
});
it("getCurrentUser returns the user row for a valid admin cookie", async () => {
const cookie = await makeCookie("admin");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(ADMIN);
const u = await getCurrentUser();
expect(u?.id).toBe(ADMIN.id);
expect(u?.role).toBe("admin");
});
it("requireUser throws when there is no session", async () => {
cookiesGetMock.mockReturnValue(undefined);
await expect(requireUser()).rejects.toThrow();
});
it("requireAdmin throws when role is 'user'", async () => {
const cookie = await makeCookie("user");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(USER);
await expect(requireAdmin()).rejects.toThrow();
});
it("requireAdmin returns the user when role is 'admin'", async () => {
const cookie = await makeCookie("admin");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(ADMIN);
const u = await requireAdmin();
expect(u.role).toBe("admin");
});
});

View File

@ -1,66 +0,0 @@
import "server-only";
import { cookies } from "next/headers";
import { db } from "./db";
import { COOKIE_NAME, verifySession } from "./auth-cookie";
export type AuthUser = {
id: string;
username: string;
role: "admin" | "user";
displayName: string;
defaultTimezone: string;
passwordHash: string | null;
};
export class UnauthenticatedError extends Error {
constructor() {
super("Unauthenticated");
this.name = "UnauthenticatedError";
}
}
export class ForbiddenError extends Error {
constructor() {
super("Forbidden");
this.name = "ForbiddenError";
}
}
/**
* Returns the operator row whose userId is encoded in the session
* cookie, or null if the cookie is missing / invalid / the row is
* gone. Never throws call requireUser() if you want a throw.
*/
export async function getCurrentUser(): Promise<AuthUser | null> {
const jar = await cookies();
const cookie = jar.get(COOKIE_NAME)?.value;
if (!cookie) return null;
const secret = process.env.AUTH_SECRET;
if (!secret) return null;
const payload = await verifySession(cookie, secret);
if (!payload) return null;
const row = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.id, payload.userId),
});
if (!row) return null;
if (row.role !== "admin" && row.role !== "user") return null;
return {
id: row.id,
username: row.username,
role: row.role,
displayName: row.displayName,
defaultTimezone: row.defaultTimezone,
passwordHash: row.passwordHash,
};
}
export async function requireUser(): Promise<AuthUser> {
const u = await getCurrentUser();
if (!u) throw new UnauthenticatedError();
return u;
}
export async function requireAdmin(): Promise<AuthUser> {
const u = await requireUser();
if (u.role !== "admin") throw new ForbiddenError();
return u;
}

View File

@ -1,49 +0,0 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Static guard: listAccounts() in apps/web/src/lib/queries.ts MUST
* order rows by createdAt ascending (with id as a deterministic
* tiebreaker) so the operator's earliest-added account stays on top.
*
* Earlier the orderBy used `asc(a.label)` which silently re-shuffled
* the list every time an account was renamed. This test pins the
* fix in source so a future refactor can't quietly bring the rename
* regression back.
*
* It's a static (regex) guard rather than an integration test
* because the live query needs Postgres + a seeded operator;
* pinning the source spelling keeps coverage cheap and CI-friendly.
*/
describe("listAccounts ordering (regression guard)", () => {
const src = readFileSync(
join(__dirname, "queries.ts"),
"utf8",
);
it("orders by created_at ASC", () => {
// Match across whitespace/comments inside listAccounts. Anchors:
// function header → orderBy → asc(a.createdAt).
const fnStart = src.indexOf("export async function listAccounts(");
expect(fnStart).toBeGreaterThan(-1);
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).toMatch(/orderBy:[\s\S]*asc\(a\.createdAt\)/);
});
it("uses id as a deterministic tiebreaker for ties on createdAt", () => {
const fnStart = src.indexOf("export async function listAccounts(");
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).toMatch(/asc\(a\.id\)/);
});
it("does NOT order by label (the regression we're guarding against)", () => {
const fnStart = src.indexOf("export async function listAccounts(");
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).not.toMatch(/asc\(a\.label\)/);
expect(fnBody).not.toMatch(/desc\(a\.label\)/);
});
});

View File

@ -5,10 +5,6 @@ import { db } from "./db";
export type BotCommand = export type BotCommand =
| { type: "account.start_pairing"; accountId: string } | { type: "account.start_pairing"; accountId: string }
| { type: "account.unpair"; accountId: string } | { type: "account.unpair"; accountId: string }
// Like account.unpair, but the bot also calls socket.logout() so
// WhatsApp drops this device from the operator's linked-devices
// list before the row is deleted.
| { type: "account.delete"; accountId: string }
| { type: "account.sync_groups"; accountId: string } | { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string } | { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string } | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }

View File

@ -1,21 +1,16 @@
import "server-only"; import "server-only";
import { getCurrentUser } from "./auth"; import { db } from "./db";
/** /**
* Compatibility shim. The app used to seed a single operator and * Returns the single seeded operator row. Since the app has no auth,
* attribute everything to it; now we have real auth + roles. Existing * every action is attributed to this operator.
* call sites read `.id` and `.defaultTimezone` off the returned
* object both are still present on the AuthUser shape, so the
* swap is mechanical and existing tests that mock @/lib/operator
* keep working unchanged.
*
* New code should call getCurrentUser / requireUser / requireAdmin
* from @/lib/auth directly.
*/ */
export async function getSeededOperator() { export async function getSeededOperator() {
const u = await getCurrentUser(); const op = await db.query.operators.findFirst({
if (!u) { orderBy: (o, { asc }) => [asc(o.createdAt)],
throw new Error("Not authenticated"); });
if (!op) {
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
} }
return u; return op;
} }

View File

@ -1,69 +0,0 @@
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

@ -1,37 +0,0 @@
/**
* 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,18 +6,9 @@ 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),
}); });
// Reminders scoped to this operator's accounts. The previous // All reminder rows so the dashboard can show active/total in one query.
// findMany() with no filter leaked global counts across users — a // Status enum today is active / ended (paused will join in a later phase).
// brand-new user would see another operator's totals on the const allReminders = await db.query.reminders.findMany();
// 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
@ -63,12 +54,9 @@ export async function listAccounts(operatorId: string) {
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows // exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
// by status produced phantom "I created an account but it's gone" // by status produced phantom "I created an account but it's gone"
// bug reports. // bug reports.
// Earliest-added on top, newest at the bottom. Stable across renames
// (a label edit shouldn't reorder the list and confuse muscle memory)
// and matches how other admin tools order accounts that grow over time.
return db.query.whatsappAccounts.findMany({ return db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId), where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.createdAt), asc(a.id)], orderBy: (a, { asc }) => [asc(a.label)],
}); });
} }
@ -199,13 +187,11 @@ export async function listActivityRuns(
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder // exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
// has been deleted but history was preserved) in the list. // has been deleted but history was preserved) in the list.
// The `archived` flag flips the visibility filter: // The `archived` flag flips the visibility filter:
// false (default) — non-archived, non-skipped rows (skipped runs // false (default) — only non-archived rows
// belong to the Archived tab now) // true — only archived rows (for the Archived tab)
// true — archived rows OR skipped rows (they're treated
// as "history" rather than active outcomes)
const archivedClause = opts.archived const archivedClause = opts.archived
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')` ? sql`rr.archived_at IS NOT NULL`
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`; : sql`rr.archived_at IS NULL`;
const rows = await db.execute(sql` const rows = await db.execute(sql`
SELECT SELECT
rr.id, rr.id,

View File

@ -1,43 +0,0 @@
import { describe, it, expect } from "vitest";
import { safeRedirect } from "./safe-redirect";
describe("safeRedirect", () => {
it("preserves a relative path that starts with a single slash", () => {
expect(safeRedirect("/dashboard")).toBe("/dashboard");
expect(safeRedirect("/reminders/new")).toBe("/reminders/new");
});
it("preserves query string and fragment", () => {
expect(safeRedirect("/legit?with=params&extra=fine#hash")).toBe(
"/legit?with=params&extra=fine#hash",
);
});
it("rejects protocol-relative URLs (//evil.com)", () => {
expect(safeRedirect("//evil.com")).toBe("/");
expect(safeRedirect("//evil.com/dashboard")).toBe("/");
});
it("rejects absolute URLs", () => {
expect(safeRedirect("https://evil.com")).toBe("/");
expect(safeRedirect("http://evil.com/dashboard")).toBe("/");
});
it("rejects javascript: and data: schemes", () => {
expect(safeRedirect("javascript:alert(1)")).toBe("/");
expect(safeRedirect("data:text/html,<script>x</script>")).toBe("/");
});
it("falls back to / for empty / null / undefined / whitespace input", () => {
expect(safeRedirect("")).toBe("/");
expect(safeRedirect(null)).toBe("/");
expect(safeRedirect(undefined)).toBe("/");
expect(safeRedirect(" ")).toBe("/");
});
it("rejects paths that don't start with / (relative-relative)", () => {
expect(safeRedirect("dashboard")).toBe("/");
expect(safeRedirect("./dashboard")).toBe("/");
expect(safeRedirect("../dashboard")).toBe("/");
});
});

View File

@ -1,16 +0,0 @@
/**
* Returns `next` if it is a safe relative path, otherwise "/".
*
* Safe means: starts with a single forward slash AND not "//" (which
* is a protocol-relative URL, e.g. //evil.com). Anything else falls
* back to the root including empty input, absolute URLs, javascript:
* URIs, and relative-relative paths like "dashboard" or "../foo".
*/
export function safeRedirect(next: string | null | undefined): string {
if (typeof next !== "string") return "/";
const s = next.trim();
if (s.length < 2) return "/";
if (!s.startsWith("/")) return "/";
if (s.startsWith("//")) return "/";
return s;
}

View File

@ -1,84 +0,0 @@
import { describe, it, expect, beforeAll } from "vitest";
import { NextRequest } from "next/server";
const SECRET = "test-secret";
beforeAll(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
});
import { signSession } from "./lib/auth-cookie";
import { middleware } from "./middleware";
async function makeReq(path: string, cookie?: string): Promise<NextRequest> {
const url = new URL(`https://wabot.04080616.xyz${path}`);
const headers = new Headers();
if (cookie) headers.set("cookie", `session=${cookie}`);
return new NextRequest(url, { headers });
}
async function validCookie(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return signSession(
{
userId: "00000000-0000-0000-0000-000000000000",
role: "admin",
iat: now,
exp: now + 3600,
v: 1,
},
SECRET,
);
}
describe("middleware", () => {
it("page request without a cookie redirects to /login?next=…", async () => {
const r = await middleware(await makeReq("/dashboard"));
expect(r.status).toBe(307);
expect(r.headers.get("location")).toContain("/login");
expect(r.headers.get("location")).toContain("next=%2Fdashboard");
});
it("/api/* request without a cookie returns 401 with no body", async () => {
const r = await middleware(await makeReq("/api/events"));
expect(r.status).toBe(401);
});
it("page request with a valid cookie passes through", async () => {
const r = await middleware(await makeReq("/dashboard", await validCookie()));
// NextResponse.next() returns a 200 with the x-middleware-next header.
expect(r.status).toBe(200);
});
it("page request with a tampered cookie redirects to /login", async () => {
const cookie = (await validCookie()).slice(0, -4) + "AAAA";
const r = await middleware(await makeReq("/dashboard", cookie));
expect(r.status).toBe(307);
expect(r.headers.get("location")).toContain("/login");
});
it("allowlisted paths bypass auth (login, logout, health, manifest, icons)", async () => {
for (const path of [
"/login",
"/logout",
"/api/health",
"/manifest.webmanifest",
"/icon-192.png",
"/favicon.ico",
]) {
const r = await middleware(await makeReq(path));
expect(r.status).toBe(200);
}
});
it("/api/events and /api/qr/<id> are NOT in the allowlist (regression)", async () => {
expect((await middleware(await makeReq("/api/events"))).status).toBe(401);
expect(
(
await middleware(
await makeReq("/api/qr/11111111-1111-1111-1111-111111111111"),
)
).status,
).toBe(401);
});
});

View File

@ -1,41 +1,21 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
const PUBLIC_PATHS = new Set<string>([ export function middleware(req: NextRequest) {
"/login",
"/logout",
"/api/health",
"/manifest.webmanifest",
"/favicon.ico",
"/robots.txt",
]);
function isPublic(path: string): boolean {
if (PUBLIC_PATHS.has(path)) return true;
if (path.startsWith("/icon-")) return true;
if (path.startsWith("/_next/")) return true;
return false;
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname; const path = req.nextUrl.pathname;
if (isPublic(path)) return NextResponse.next();
const cookie = req.cookies.get(COOKIE_NAME)?.value; // Block all /api/* except a small set of read-only endpoints.
const secret = process.env.AUTH_SECRET; // Mutations happen via Server Actions which post to page URLs, not /api/*.
const ok = const allowed =
!!cookie && !!secret && (await verifySession(cookie, secret)) !== null; path === "/api/events" ||
if (ok) return NextResponse.next(); path === "/api/health" ||
path.startsWith("/api/qr/");
if (path.startsWith("/api/")) { if (path.startsWith("/api/") && !allowed) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Not Found", { status: 404 });
} }
const url = req.nextUrl.clone();
url.pathname = "/login"; return NextResponse.next();
url.searchParams.set("next", path + (req.nextUrl.search || ""));
return NextResponse.redirect(url);
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"],
}; };

View File

@ -1,59 +0,0 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
assertJournalMonotonic,
formatJournalViolations,
type JournalEntry,
} from "@cmbot/db/journal-check";
/**
* CI guard against the recurring drizzle journal-skip bug.
*
* Drizzle's migrator orders entries by `when` (not `idx`) and only
* applies entries whose `when` is greater than the latest applied
* row's recorded `created_at`. We've shipped two breaking deploys
* (0010/0011 and 0012/0013) where freshly-generated migrations had
* `when` values older than a prior manually-bumped entry `pnpm
* migrate` printed "Migrations applied." while silently skipping
* the new SQL, and production 500'd until we hand-fixed the journal.
*
* This test reads the committed _journal.json and fails if the
* entries aren't strictly monotonically increasing by `when` in the
* same order as `idx`. Catches a bad commit at PR time instead of
* at the next deploy.
*/
describe("drizzle journal monotonicity (regression guard)", () => {
const journalPath = join(
__dirname,
"..",
"..",
"..",
"..",
"packages",
"db",
"migrations",
"meta",
"_journal.json",
);
const raw = JSON.parse(readFileSync(journalPath, "utf8")) as {
entries: JournalEntry[];
};
it("loads at least one journal entry (sanity)", () => {
expect(raw.entries.length).toBeGreaterThan(0);
});
it("`when` timestamps are strictly increasing in `idx` order", () => {
const result = assertJournalMonotonic(raw.entries);
if (!result.ok) {
// Print the same actionable message migrate.ts prints, so a
// failed CI run reads exactly like a failed local migrate.
// eslint-disable-next-line no-console
console.error(formatJournalViolations(result));
}
expect(result.violations).toEqual([]);
expect(result.ok).toBe(true);
});
});

View File

@ -1,112 +0,0 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
/**
* Static guard: no production `.tsx` file may pass `showCloseButton`
* to `<DialogFooter>`.
*
* Why: the shared DialogFooter renders an EXTRA outline-styled
* <Button>Close</Button> when `showCloseButton` is set. Every dialog
* we have that already provides its own primary action also includes
* a Cancel/dismiss button (either via DialogClose or by closing the
* Dialog state on submit) and Radix's auto-rendered corner X
* already gives users a third way out. The redundant Close button
* cluttered the footer and shipped to production multiple times
* before this guard existed; this test stops it from regressing.
*/
const SRC_ROOT = join(__dirname, "..");
function listTsxFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
out.push(...listTsxFiles(full));
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
interface Hit {
file: string;
line: number;
excerpt: string;
}
function findHits(content: string): Array<{ line: number; excerpt: string }> {
const hits: Array<{ line: number; excerpt: string }> = [];
// Match `<DialogFooter` with `showCloseButton` somewhere in the
// opening tag. Stops at `>` so we don't accidentally cross into the
// children. Multi-line opening tags are handled by `[\s\S]`.
const matches = content.matchAll(
/<DialogFooter\b[\s\S]*?showCloseButton\b[\s\S]*?>/g,
);
for (const m of matches) {
const idx = m.index ?? 0;
const line = content.slice(0, idx).split("\n").length;
hits.push({ line, excerpt: m[0].slice(0, 120).replace(/\s+/g, " ") });
}
return hits;
}
describe("static guard: no <DialogFooter showCloseButton>", () => {
// Skip this test file (it intentionally contains the pattern strings)
// and all other .test.tsx files (they're examples, not production UI).
const files = listTsxFiles(SRC_ROOT).filter(
(f) => !/\.test\.tsx?$/.test(f),
);
it("scans at least one source file (sanity)", () => {
expect(files.length).toBeGreaterThan(0);
});
it("finds no <DialogFooter showCloseButton ...> in any production .tsx file", () => {
const allHits: Hit[] = [];
for (const file of files) {
const content = readFileSync(file, "utf8");
for (const h of findHits(content)) {
allHits.push({ file: relative(SRC_ROOT, file), ...h });
}
}
if (allHits.length > 0) {
const message = allHits
.map((h) => ` ${h.file}:${h.line}${h.excerpt}`)
.join("\n");
throw new Error(
`Redundant Close button detected — <DialogFooter showCloseButton ...>:\n${message}\n` +
`The DialogFooter component injects an extra "Close" button when this prop\n` +
`is set. Every existing caller already has its own Cancel/Close action plus\n` +
`Radix's corner X — a third Close is just visual noise. Remove the prop.`,
);
}
expect(allHits).toEqual([]);
});
});
describe("findHits parser", () => {
it("matches a single-line <DialogFooter showCloseButton>", () => {
expect(
findHits("<DialogFooter showCloseButton>foo</DialogFooter>"),
).toHaveLength(1);
});
it("matches when other props are present alongside showCloseButton", () => {
expect(
findHits('<DialogFooter className="x" showCloseButton>'),
).toHaveLength(1);
});
it("matches across multiple lines", () => {
const src = `<DialogFooter\n className="x"\n showCloseButton\n>`;
expect(findHits(src)).toHaveLength(1);
});
it("does NOT match a clean <DialogFooter>", () => {
expect(findHits("<DialogFooter>x</DialogFooter>")).toHaveLength(0);
});
it("does NOT match a similarly-named prop on an unrelated component", () => {
expect(findHits("<SheetFooter showCloseButton>")).toHaveLength(0);
});
});

View File

@ -19,7 +19,7 @@ services:
MEDIA_DIR: ${MEDIA_DIR:-/data/media} MEDIA_DIR: ${MEDIA_DIR:-/data/media}
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081} BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info} BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
SEED_OPERATOR_USERNAME: ${SEED_OPERATOR_USERNAME:-admin} SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0}
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator} SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
networks: networks:
- cmbot - cmbot
@ -36,8 +36,6 @@ services:
DATA_DIR: ${DATA_DIR} DATA_DIR: ${DATA_DIR}
MEDIA_DIR: ${MEDIA_DIR} MEDIA_DIR: ${MEDIA_DIR}
WEB_PORT: ${WEB_PORT} WEB_PORT: ${WEB_PORT}
AUTH_SECRET: ${AUTH_SECRET}
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
networks: networks:
- cmbot - cmbot

View File

@ -59,7 +59,5 @@ services:
DATA_DIR: ${DATA_DIR} DATA_DIR: ${DATA_DIR}
MEDIA_DIR: ${MEDIA_DIR} MEDIA_DIR: ${MEDIA_DIR}
WEB_PORT: ${WEB_PORT} WEB_PORT: ${WEB_PORT}
AUTH_SECRET: ${AUTH_SECRET}
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
depends_on: depends_on:
- tools - tools

View File

@ -26,11 +26,5 @@ COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/apps/bot /app/apps/bot COPY --from=build /app/apps/bot /app/apps/bot
COPY --from=build /app/packages/db /app/packages/db COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/shared /app/packages/shared COPY --from=build /app/packages/shared /app/packages/shared
RUN addgroup -g 1000 app && \
adduser -D -u 1000 -G app -s /sbin/nologin app && \
mkdir -p /data/sessions /data/media /app && \
chown -R app:app /app /data && \
chmod 700 /data/sessions
USER app
EXPOSE 8081 EXPOSE 8081
CMD ["node", "apps/bot/dist/index.js"] CMD ["node", "apps/bot/dist/index.js"]

View File

@ -29,9 +29,5 @@ ENV HOSTNAME=0.0.0.0
COPY --from=build /app/apps/web/.next/standalone ./ COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public COPY --from=build /app/apps/web/public ./apps/web/public
RUN addgroup -g 1000 app && \
adduser -D -u 1000 -G app -s /sbin/nologin app && \
chown -R app:app /app
USER app
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/web/server.js"] CMD ["node", "apps/web/server.js"]

File diff suppressed because it is too large Load Diff

View File

@ -1,437 +0,0 @@
# Auth + Production Hardening Design
> Spec for closing the production-readiness gap before promoting the
> bot to public-internet exposure at `wabot.04080616.xyz`. Covers the
> session-cookie auth model with username + password + role, plus the
> hygiene work that has to land alongside it (robots, env, container
> non-root) so the public surface is safe in one change.
## Goal
Add operator authentication to the web app so the public URL stops
being a foothold for anyone who finds it, and at the same time close
the highest-risk production gaps surfaced in the v1.1.0 audit:
indexable content, committed credentials, root-running containers,
and four un-rate-limited Server Actions.
## Constraints
- Single-host self-hosted deployment, public-internet via reverse
proxy + TLS at `wabot.04080616.xyz`.
- Up to a handful of users today, with room to grow. One must be
`admin`; the rest are `user`.
- Mobile PWA homescreen workflow: 30-day cookie, no friction at
re-open, no third-party identity provider.
- No new infra dependencies. Postgres + Docker compose stay the
whole platform. No NextAuth / Auth.js, no external KV, no SMS.
- Existing call sites must be cleanly retrofitted without breaking
the 66 call sites that currently use `getSeededOperator()`.
- All code changes covered by unit tests; no test relies on a live
Postgres or browser.
## Approach: roll-our-own session cookie
A library would be heavy for one role gate and one cookie. We pick
up `bcrypt` for password hashing (battle-tested) and Web Crypto's
HMAC for cookie signing (stdlib, edge-runtime compatible). All other
code is domain-owned and exhaustively tested.
The model: the user posts username + password to a Server Action,
the action verifies against a per-user `password_hash` row, and the
response sets a signed cookie carrying `{ userId, role, iat, exp, v }`.
Middleware verifies the cookie on every request; Server Actions
double-check via `requireUser()` / `requireAdmin()` so a forgotten
middleware path can't bypass the gate.
## Schema migration (`0010_add_user_auth.sql`)
```sql
ALTER TABLE operators
ADD COLUMN username text,
ADD COLUMN password_hash text;
CREATE UNIQUE INDEX operators_username_uq
ON operators (lower(username));
-- Backfill the seed row so it has a username; password_hash stays NULL
-- so the operator is forced to set one via the CLI before they can sign
-- in. Sets a clear "you have to do this before going live" gate.
UPDATE operators
SET username = 'admin'
WHERE username IS NULL;
ALTER TABLE operators
ALTER COLUMN username SET NOT NULL;
```
`telegramUserId` stays for now (it's referenced from existing migrations
and seed flow) but no longer drives auth. `defaultTimezone` and `role`
are unchanged. `operators.role` already defaults to `"admin"`.
## Roles
Two values, no enum constraint at the DB layer (text — same as
existing).
| role | can do |
| ----- | ------------------------------------------------------------- |
| admin | everything in the app + user management (CRUD other users) |
| user | everything except `/settings/users` and the user-mgmt actions |
A third "viewer" role isn't worth it today; can be added later by
extending the role check.
## Cookie format
Header value: `session=<base64url(payload)>.<base64url(hmac)>`
```ts
type SessionPayload = {
userId: string; // operators.id (uuid)
role: "admin" | "user";
iat: number; // issued-at, unix seconds
exp: number; // expires-at, unix seconds (iat + 30 days)
v: number; // OPERATOR_TOKEN_VERSION at issue time
};
```
HMAC is HMAC-SHA256 over the base64url-encoded payload string with
`AUTH_SECRET` as the key. Verification rejects on:
- Bad shape (no `.`, base64 decode fails, JSON parse fails).
- HMAC mismatch (uses constant-time compare).
- `exp <= now`.
- `iat > now + 60` (clock-skew guard, 60s tolerance).
- `v !== process.env.OPERATOR_TOKEN_VERSION` (defaults to `"1"`).
- `role` not one of `"admin"` / `"user"`.
Cookie attributes: `HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000`.
`Max-Age=0` on logout to clear.
`OPERATOR_TOKEN_VERSION` env var (default `"1"`) is the global
session-invalidation lever. Bumping it on the host instantly logs out
every user — no DB writes — useful after a host compromise or a
known-shared password.
## Login flow
Page: `apps/web/src/app/login/page.tsx`. Single form with:
- Username input (`type=text`, autocomplete `username`)
- Password input (`type=password`, autocomplete `current-password`)
- Submit button "Sign in"
- Error slot for the generic message
- A small note: "First time? Run `./scripts/set-password.sh <username>`
in your tools container to set a password."
Server action `loginAction(formData: FormData)`:
```text
1. Read username, password from FormData.
2. Reject if either >256 chars (DoS guard, no bcrypt).
3. Reject if either empty.
4. Apply rate limit: checkRateLimit("login:" + ip, { max: 10, windowSec: 300 }).
On exhaustion → return { ok: false, error: "Too many attempts, try later." }
5. Look up user: select * from operators where lower(username)=lower($1)
6. If user not found OR user.password_hash IS NULL:
await bcrypt.compare(password, DUMMY_HASH); // timing equivalence
return { ok: false, error: "Invalid username or password." }
7. await bcrypt.compare(password, user.password_hash)
if false: return { ok: false, error: "Invalid username or password." }
8. Issue cookie: signSession({ userId, role, iat: now, exp: now + 30d, v: TOKEN_VERSION })
9. Redirect to safe(next) ?? "/"
```
`safe(next)`: must be a string starting with `/` AND not starting
with `//`. Otherwise return `null`.
Logout action `logoutAction()`: clear the cookie via
`cookies().set("session", "", { maxAge: 0, ... })` and redirect to
`/login`.
## Middleware gate
`apps/web/src/middleware.ts` extends the existing API allowlist with
the auth check.
```text
For every request:
- If path is in allowlist (auth-free):
/login, /logout, /api/health, /manifest.webmanifest,
/icon-*, /favicon.ico, /_next/static/*, /_next/image
→ NextResponse.next()
- Read session cookie. Verify (HMAC, exp, iat-skew, version, role shape).
- On valid: NextResponse.next()
- On invalid + path starts with /api/: 401, no body
- On invalid + page request: 302 to /login?next=<encoded path>
```
`/api/events` and `/api/qr/[accountId]` are explicitly removed from
the unauth allowlist — middleware now requires a session for them.
The middleware imports the verifier from `@/lib/auth-cookie` (a
dependency-free module that runs on the edge runtime — no bcrypt,
no DB).
## Server-action defense-in-depth
`apps/web/src/lib/auth.ts` (Node runtime — DB access OK):
```ts
export async function getCurrentUser(): Promise<User | null>
export async function requireUser(): Promise<User> // throws Response 401 / redirects
export async function requireAdmin(): Promise<User> // requireUser + role === "admin"
```
`getSeededOperator()` is renamed to `getCurrentUser()` (and rewired
to read the verified cookie + look up the user). All 66 call sites
swap mechanically. Existing typing stays compatible because the
returned shape is a superset.
Every Server Action begins with `await requireUser()` (or
`requireAdmin()` for admin-only). This is the second layer; the
middleware is the first. Both must agree before any state mutates.
## User management surface
Admin-only, gated by `requireAdmin()` at every entry point.
- `/settings/users` (page) — list of users with role chip + createdAt;
inline "Reset password", "Demote/Promote", "Delete" buttons. New
user form at top.
- `createUserAction({ username, password, role })` — validate inputs,
bcrypt the password, insert.
- `setUserRoleAction({ userId, role })` — guard: if `userId === self.id`
AND `role !== "admin"`, refuse with "you can't demote yourself".
- `resetUserPasswordAction({ userId, newPassword })` — bcrypt + update.
Does NOT change cookies — the affected user keeps their existing
session until expiry or a token-version bump.
- `deleteUserAction({ userId })` — guard: refuse self-delete.
Additional guard: if deleting the last admin, refuse with "promote
another user to admin first".
All admin actions fan out a refresh of `/settings/users` via
`revalidatePath`.
## CLI bootstrap
The actual hashing happens in a small TSX script (so it can `import
bcrypt` from the workspace), wrapped by a one-line bash launcher
that runs it through the `tools` container. Two pieces:
`packages/db/src/scripts/set-password.ts` — reads `username` from
argv, prompts for password on stdin (echo off via `readline`'s
`writeMask`), bcrypts at 12 rounds, runs an `UPDATE operators SET
password_hash = $1 WHERE lower(username) = lower($2)`, exits
non-zero if no rows matched.
`packages/db/src/scripts/create-user.ts` — same pattern, but
INSERTs a fresh row with `username`, `role`, `password_hash`,
default timezone, and a synthetic `telegramUserId` (current time-
millis) since the column is still NOT NULL until a future cleanup
migration.
`scripts/set-password.sh` and `scripts/create-user.sh` — thin
wrappers that invoke the TSX scripts via `pnpm --filter @cmbot/db
exec tsx ...` inside the tools container, matching the existing
script-runner pattern.
Used to bootstrap the first admin and to recover when an admin
loses their password. After bootstrap, all user management happens
through the web UI.
## Rate limits added
| action | limit |
| ---------------------------- | -------------------------------- |
| loginAction | 10 / 5 min per IP |
| sendTestAction | 3 / 60 s per groupId |
| resumeReminderRunAction | 30 / 10 s per IP (existing infra)|
| cancelReminderRunAction | 30 / 10 s per IP |
| createUserAction | 5 / 60 s per IP |
| resetUserPasswordAction | 5 / 60 s per IP |
`checkRateLimit` is the existing Postgres-backed helper.
## Robots / noindex
`apps/web/src/app/robots.ts`:
```ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return { rules: [{ userAgent: "*", disallow: "/" }] };
}
```
Plus `metadata.robots = { index: false, follow: false }` in the root
`apps/web/src/app/layout.tsx`. Two layers — robots.txt is advisory,
the meta is authoritative.
## Env hygiene
- Add `.env*` to `.gitignore` (already excludes `.env.local`,
`.env.*.local` — this widens to all `.env*` outside `.env.example`).
- `git rm --cached .env.development` and recreate locally without
committing.
- New `.env.example` documents every required key with placeholder
values, including the new `OPERATOR_TOKEN_VERSION`.
- After this change ships, the operator rotates the leaked
`AUTH_SECRET` and Postgres password (manual step, called out in
the upgrade notes).
## Container hardening
Both Dockerfiles:
```dockerfile
RUN useradd -m -u 1000 -s /usr/sbin/nologin app && \
mkdir -p /data/sessions /data/media && \
chown -R app:app /app /data && \
chmod 700 /data/sessions
USER app
```
The `dev-data:/data` volume mount in `docker-compose.dev.yml` keeps
working since the host UID matches the in-container `app` UID 1000.
## Origin allowlist
`next.config.ts` adds:
```ts
experimental: {
serverActions: {
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
},
},
```
Same-origin Server Action posts already work; this guards against
cross-origin POSTs from another domain attempting to invoke an
action via a known cookie.
## Test plan (38 tests)
### `auth-cookie.test.ts` — pure HMAC + verification logic
1. `signSession` then `verifySession` round-trips.
2. Tampered payload → verify rejects.
3. Tampered signature → verify rejects.
4. Wrong secret → verify rejects.
5. Constant-time compare prevents char-by-char timing leak (assert
`crypto.timingSafeEqual` is used).
6. Cookie expired (`exp <= now`) → reject.
7. Cookie issued in the future (`iat > now + 60`) → reject (clock-skew).
8. Cookie with stale `v` (TOKEN_VERSION bumped after issue) → reject.
9. Cookie with bad `role` value (`"superadmin"`) → reject.
10. Cookie missing fields → reject.
### `login-action.test.ts` — login flow
11. Valid credentials → cookie issued with right shape.
12. Wrong password → no cookie, generic error.
13. Wrong username → no cookie, generic error, dummy-bcrypt called
(timing equivalence).
14. `password_hash IS NULL` user → "set password via CLI" error.
15. Empty username or password → 400-equivalent (no DB hit).
16. Username/password >256 chars → rejected before bcrypt.
17. Username case-insensitive (`Admin` matches `admin`).
18. 11th login attempt within window → 429 (rate-limited).
19. After window expiry, attempts succeed.
20. Failed login logs warning with username + IP, no password.
21. Cookie sets correct attrs (HttpOnly, Secure, SameSite, Path,
Max-Age).
### `middleware.test.ts` — gate behavior
22. No cookie + page request → 302 to `/login?next=<path>`.
23. No cookie + `/api/...` (non-allowlisted) → 401.
24. Valid cookie + page → next().
25. Tampered cookie → 302 to `/login`.
26. Allowlisted (`/login`, `/api/health`, manifest, icons) bypasses.
27. `/api/events` and `/api/qr/[id]` are NOT in allowlist (regression
against the audit's Critical findings).
### `next-param.test.ts` — open-redirect prevention
28. `/dashboard` → preserved.
29. `//evil.com` → falls back to `/`.
30. `https://evil.com` → falls back to `/`.
31. `javascript:alert(1)` → falls back to `/`.
32. `/path?with=query&extra=fine` → preserved verbatim.
### `require-helpers.test.ts` — Server-action gates
33. `requireUser()` throws with no session.
34. `requireUser()` returns the user with valid session.
35. `requireAdmin()` throws when role === "user".
36. `requireAdmin()` returns the user when role === "admin".
### `user-management.test.ts` — admin guards
37. Self-demote (`setUserRoleAction({ userId: self, role: "user" })`)
→ ok:false with clear error.
38. Last-admin delete (deleting only admin user) → ok:false with
"promote another user first".
## Migration risk
`getSeededOperator()` is the one big touch. The 66 call sites are
mostly Server Actions and queries that read `.id` and
`.defaultTimezone` off the returned object — the new shape is a
superset, so the change is mechanical.
To keep churn off the existing test suite (~12 tests mock
`@/lib/operator`), `apps/web/src/lib/operator.ts` keeps its export
but reimplements `getSeededOperator` as a thin pass-through to
`getCurrentUser` from `@/lib/auth`. Existing mocks that target
`@/lib/operator` keep working unchanged. New code uses
`getCurrentUser` / `requireUser` / `requireAdmin` directly; the old
name is kept as a compatibility shim and removed in a follow-up
once all sites are swept.
A `DUMMY_HASH` constant lives at the top of the login action — it's
a precomputed bcrypt hash of a known throwaway string (`"x"`),
generated once at build time and committed. We compare against it
on the user-not-found path so timing is identical to the wrong-
password path. Generating a fresh dummy hash per request would
double the bcrypt work and create its own timing signal.
## Out of scope (deferred)
- WebAuthn / passkeys.
- 2FA / TOTP.
- Email-based password recovery (operator restarts container with
a new env var `OPERATOR_TOKEN_VERSION` if all admins lose their
passwords; CLI helps the rest).
- Account lockout (rate limit is enough for one operator's threat
model).
- SSO / OAuth providers.
- Audit-log surface for "who logged in when". The pino warn line
is the minimum; a structured audit table is later work.
- A "remember this device" feature distinct from the 30-day cookie.
## Acceptance
- The bot can be exposed at `wabot.04080616.xyz` and any
unauthenticated request to a non-allowlisted path returns 401
(API) or redirects to `/login` (page).
- A correct username + password issues a 30-day cookie that survives
reload, browser restart, and PWA homescreen launches.
- A wrong username, a wrong password, and a missing-password user
all produce the same generic "Invalid username or password"
error and the same wall-clock duration (timing-equivalent).
- Bumping `OPERATOR_TOKEN_VERSION` on the host invalidates every
active session immediately.
- An attacker tampering with the cookie payload, signature, or
issued-at can't pass middleware.
- Eleven login attempts from the same IP within five minutes
produce a 429 on the eleventh.
- A `user`-role session can browse, schedule, and resume reminders
but cannot reach `/settings/users`.
- An admin can't demote or delete their own row, and can't delete
the last admin.
- `robots.txt` returns `Disallow: /` and the rendered HTML carries
`<meta name="robots" content="noindex, nofollow">`.
- Both containers run as UID 1000, sessions dir is `chmod 700`.
- `.env.development` is gone from the repo and `.gitignore` excludes
every `.env*` except `.env.example`.
- All 38 tests in the plan pass; existing 471 tests still pass.

View File

@ -1,9 +0,0 @@
-- Add username + password_hash to operators. Backfill the seed row to
-- 'admin' so the NOT NULL constraint succeeds; password_hash stays
-- nullable so the operator is forced to set one via the CLI before
-- they can sign in.
ALTER TABLE "operators" ADD COLUMN "username" text;--> statement-breakpoint
ALTER TABLE "operators" ADD COLUMN "password_hash" text;--> statement-breakpoint
UPDATE "operators" SET "username" = 'admin' WHERE "username" IS NULL;--> statement-breakpoint
ALTER TABLE "operators" ALTER COLUMN "username" SET NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "operators_username_uq" ON "operators" (lower("username"));

View File

@ -1,2 +0,0 @@
DROP INDEX IF EXISTS "operators_telegram_user_id_uq";--> statement-breakpoint
ALTER TABLE "operators" DROP COLUMN IF EXISTS "telegram_user_id";

View File

@ -1,10 +0,0 @@
-- Switch the default to 24 ("no deadline" sentinel) so newly-created
-- reminders are off-by-default for the optional "Pause sending by"
-- toggle, matching the wizard's UX contract.
ALTER TABLE "reminders" ALTER COLUMN "delivery_window_end_hour" SET DEFAULT 24;
-- Existing rows still hold the old default (18). Treat those as
-- "schema-default, never opted in by the operator" and clear them to
-- 24 so editing an old reminder doesn't auto-check the deadline box.
-- Operators who actually wanted a 6pm deadline can re-enable it from
-- the edit form.
UPDATE "reminders" SET "delivery_window_end_hour" = 24 WHERE "delivery_window_end_hour" = 18;

View File

@ -1,2 +0,0 @@
ALTER TABLE "operators" ADD COLUMN "email" text;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "operators_email_uq" ON "operators" USING btree (lower("email")) WHERE "operators"."email" IS NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,104 +1,76 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1778311164225, "when": 1778311164225,
"tag": "0000_conscious_tarantula", "tag": "0000_conscious_tarantula",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1778320434707, "when": 1778320434707,
"tag": "0001_smart_vertigo", "tag": "0001_smart_vertigo",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1778338808600, "when": 1778338808600,
"tag": "0002_left_jimmy_woo", "tag": "0002_left_jimmy_woo",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1778343712901, "when": 1778343712901,
"tag": "0003_messy_bruce_banner", "tag": "0003_messy_bruce_banner",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1778345543406, "when": 1778345543406,
"tag": "0004_next_prowler", "tag": "0004_next_prowler",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1778347437350, "when": 1778347437350,
"tag": "0005_flippant_joystick", "tag": "0005_flippant_joystick",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1778385559051, "when": 1778385559051,
"tag": "0006_adorable_nehzno", "tag": "0006_adorable_nehzno",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1778386591494, "when": 1778386591494,
"tag": "0007_overconfident_menace", "tag": "0007_overconfident_menace",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1778395584234, "when": 1778395584234,
"tag": "0008_greedy_matthew_murdock", "tag": "0008_greedy_matthew_murdock",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1778464000000, "when": 1778464000000,
"tag": "0009_rename_ended_to_inactive", "tag": "0009_rename_ended_to_inactive",
"breakpoints": true "breakpoints": true
}, }
{ ]
"idx": 10,
"version": "7",
"when": 1778464001000,
"tag": "0010_fancy_wolf_cub",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1778464002000,
"tag": "0011_premium_grandmaster",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1778464003000,
"tag": "0012_lucky_masked_marvel",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1778464004000,
"tag": "0013_tricky_yellowjacket",
"breakpoints": true
}
]
} }

View File

@ -13,10 +13,6 @@
"./schema": { "./schema": {
"types": "./dist/schema.d.ts", "types": "./dist/schema.d.ts",
"default": "./dist/schema.js" "default": "./dist/schema.js"
},
"./journal-check": {
"types": "./dist/journal-check.d.ts",
"default": "./dist/journal-check.js"
} }
}, },
"scripts": { "scripts": {
@ -30,12 +26,10 @@
"seed": "tsx src/seed.ts" "seed": "tsx src/seed.ts"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
"pg": "^8.13.0" "pg": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"drizzle-kit": "^0.28.0", "drizzle-kit": "^0.28.0",

View File

@ -1,90 +0,0 @@
/**
* Drizzle journal monotonicity guard.
*
* Background twice already we hit this regression: a `pnpm migrate`
* silently skipped a freshly-generated migration because its `when`
* timestamp was older than the previous migration's `when`. Drizzle's
* migrator orders the entries by `when` (not by `idx`) and only
* applies entries whose `when` is strictly greater than the latest
* row's `created_at` in `pgboss... drizzle.__drizzle_migrations`.
*
* Symptom: migrate prints "Migrations applied." while the schema in
* the live DB is missing whatever 0012 / 0013 were supposed to add.
* Web 500's on every authenticated request because the code expects
* the new columns.
*
* This module is the first line of defence:
* - `assertJournalMonotonic(entries)` is a pure check the test
* suite runs against the committed journal file. CI fails on a
* bad commit before it can ship.
* - migrate.ts calls it on boot. If the live journal in source
* control has slipped out of monotonic order, migrate refuses
* to run and prints the offending entries with the smallest
* bump that would unbreak each one.
*/
export interface JournalEntry {
idx: number;
tag: string;
when: number;
}
export interface JournalCheckResult {
ok: boolean;
/** Entries whose `when` is <= the previous entry's `when`. */
violations: Array<{
idx: number;
tag: string;
when: number;
/** The previous entry's when — the new bound that this one must beat. */
previousWhen: number;
previousTag: string;
/** A `when` value that would make THIS entry monotonic again. */
suggestedWhen: number;
}>;
}
/**
* Walk the journal entries in idx order and report any whose `when`
* is not strictly greater than the previous entry's `when`. The
* journal can have any starting timestamp; we only care about the
* relative ordering matching idx. Equal timestamps are also a
* violation drizzle requires strictly greater.
*/
export function assertJournalMonotonic(entries: JournalEntry[]): JournalCheckResult {
const sorted = [...entries].sort((a, b) => a.idx - b.idx);
const violations: JournalCheckResult["violations"] = [];
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!;
const cur = sorted[i]!;
if (cur.when <= prev.when) {
violations.push({
idx: cur.idx,
tag: cur.tag,
when: cur.when,
previousWhen: prev.when,
previousTag: prev.tag,
suggestedWhen: prev.when + 1000,
});
}
}
return { ok: violations.length === 0, violations };
}
/** Format the check result into a multi-line human message. */
export function formatJournalViolations(result: JournalCheckResult): string {
if (result.ok) return "";
const lines: string[] = [
"Drizzle journal is not monotonic — migrate would silently skip these entries:",
];
for (const v of result.violations) {
lines.push(
` ${v.tag} (idx ${v.idx}) when=${v.when} <= ${v.previousTag}.when=${v.previousWhen}`,
);
lines.push(
` fix: set ${v.tag}.when to >= ${v.suggestedWhen} in ` +
`packages/db/migrations/meta/_journal.json`,
);
}
return lines.join("\n");
}

View File

@ -1,13 +1,5 @@
import { migrate } from "drizzle-orm/node-postgres/migrator"; import { migrate } from "drizzle-orm/node-postgres/migrator";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createClient } from "./index.js"; import { createClient } from "./index.js";
import {
assertJournalMonotonic,
formatJournalViolations,
type JournalEntry,
} from "./journal-check.js";
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) { if (!databaseUrl) {
@ -15,27 +7,6 @@ if (!databaseUrl) {
process.exit(1); process.exit(1);
} }
// --- Pre-flight: refuse to run if the journal is non-monotonic. -----------
// Drizzle silently skips entries whose `when` is older than the previous
// entry's `when`. We've hit this twice now (0010/0011, then 0012/0013),
// each time the symptom was "Migrations applied." with no schema change
// and a 500 in production for the missing column. Catch it before we
// hand the journal to drizzle.
const __dirname = dirname(fileURLToPath(import.meta.url));
const journalPath = join(__dirname, "..", "migrations", "meta", "_journal.json");
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
entries: JournalEntry[];
};
const check = assertJournalMonotonic(journal.entries);
if (!check.ok) {
console.error(formatJournalViolations(check));
console.error(
"\nRefusing to run drizzle migrate. Bump the offending `when` values in\n" +
"_journal.json so they're strictly increasing in the same order as `idx`.",
);
process.exit(2);
}
const { db, pool } = createClient(databaseUrl); const { db, pool } = createClient(databaseUrl);
console.log("Applying migrations..."); console.log("Applying migrations...");
await migrate(db, { migrationsFolder: "./migrations" }); await migrate(db, { migrationsFolder: "./migrations" });

View File

@ -1,4 +1,3 @@
import { sql } from "drizzle-orm";
import { import {
pgTable, pgTable,
uuid, uuid,
@ -17,25 +16,14 @@ export const operators = pgTable(
"operators", "operators",
{ {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
username: text("username").notNull(), telegramUserId: bigint("telegram_user_id", { mode: "number" }).notNull(),
passwordHash: text("password_hash"),
displayName: text("display_name").notNull(), displayName: text("display_name").notNull(),
// Reserved for future contact / recovery flows. Optional + nullable
// so today's operators don't have to backfill anything; admins can
// populate it from the Users page when we wire that up.
email: text("email"),
role: text("role").notNull().default("admin"), role: text("role").notNull().default("admin"),
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"), defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, },
(t) => ({ (t) => ({
usernameUnique: uniqueIndex("operators_username_uq").on(sql`lower(${t.username})`), telegramUserIdUnique: uniqueIndex("operators_telegram_user_id_uq").on(t.telegramUserId),
// Case-insensitive uniqueness only when an email IS set (NULLs
// remain freely insertable). Lets future flows look up operators
// by email without ambiguity.
emailUnique: uniqueIndex("operators_email_uq")
.on(sql`lower(${t.email})`)
.where(sql`${t.email} IS NOT NULL`),
}), }),
); );
@ -57,16 +45,6 @@ export const whatsappAccounts = pgTable(
}), }),
); );
/**
* whatsapp_groups perf notes (production: 3 000+ rows per account):
* - account_jid_uq B-tree (account_id, wa_group_jid).
* Backs the on-conflict upsert during
* group-sync and every per-account
* WHERE-prefix scan.
* - whatsapp_groups_name_trgm GIN trgm index on `name` (migration
* 0002). Powers fuzzy search via the
* `name % term` operator in O(log n).
*/
export const whatsappGroups = pgTable( export const whatsappGroups = pgTable(
"whatsapp_groups", "whatsapp_groups",
{ {
@ -112,11 +90,8 @@ export const reminders = pgTable("reminders", {
// Delivery window (operator timezone). End hour is enforced at runtime // Delivery window (operator timezone). End hour is enforced at runtime
// by fire-reminder when window enforcement lands; start hour is documented // by fire-reminder when window enforcement lands; start hour is documented
// here but not gated in v1. // here but not gated in v1.
// 24 is the "no deadline" sentinel — it's the off-by-default state so a
// reminder created without the operator explicitly opting into "Pause
// sending by" stays unbounded.
deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6), deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6),
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(24), deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(18),
}); });
export const reminderTargets = pgTable( export const reminderTargets = pgTable(

View File

@ -1,42 +0,0 @@
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { createClient } from "../index.js";
async function main() {
const username = process.argv[2];
const role = process.argv[3];
if (!username || (role !== "admin" && role !== "user")) {
console.error("Usage: create-user <username> <admin|user>");
process.exit(2);
}
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL not set");
process.exit(2);
}
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
process.stdout.write("Password: ");
const password = await rl.question("");
rl.close();
process.stdout.write("\n");
if (password.length < 10) {
console.error("Password must be at least 10 characters.");
process.exit(2);
}
const hash = await bcrypt.hash(password, 12);
const { db, pool } = createClient(url);
await db.execute(
sql`INSERT INTO operators (username, password_hash, display_name, role, default_timezone)
VALUES (${username}, ${hash}, ${username}, ${role}, 'Asia/Kuala_Lumpur')`,
);
await pool.end();
console.log(`Created ${role} ${username}.`);
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -1,59 +0,0 @@
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { createClient } from "../index.js";
async function main() {
const username = process.argv[2];
if (!username) {
console.error("Usage: set-password <username>");
process.exit(2);
}
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL not set");
process.exit(2);
}
// Silenced password prompt.
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
process.stdout.write("Password: ");
const password = await rl.question("");
rl.close();
process.stdout.write("\n");
// 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);
const { db, pool } = createClient(url);
const result = await db.execute(
sql`UPDATE operators SET password_hash = ${hash} WHERE lower(username) = lower(${username}) RETURNING id`,
);
await pool.end();
if (result.rows.length === 0) {
console.error(`No user with username ${username}`);
process.exit(1);
}
console.log("Password updated.");
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -1,25 +1,29 @@
import { createClient, operators } from "./index.js"; import { createClient, operators } from "./index.js";
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
const username = process.env.SEED_OPERATOR_USERNAME ?? "admin"; const operatorTelegramId = process.env.SEED_OPERATOR_TELEGRAM_ID;
const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator"; const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator";
if (!databaseUrl) { if (!databaseUrl) {
console.error("DATABASE_URL not set"); console.error("DATABASE_URL not set");
process.exit(1); process.exit(1);
} }
if (!operatorTelegramId || operatorTelegramId === "0") {
console.error("SEED_OPERATOR_TELEGRAM_ID not set");
process.exit(1);
}
const { db, pool } = createClient(databaseUrl); const { db, pool } = createClient(databaseUrl);
await db await db
.insert(operators) .insert(operators)
.values({ .values({
username, telegramUserId: Number(operatorTelegramId),
displayName: operatorName, displayName: operatorName,
role: "admin", role: "admin",
defaultTimezone: "Asia/Kuala_Lumpur", defaultTimezone: "Asia/Kuala_Lumpur",
}) })
.onConflictDoNothing(); .onConflictDoNothing();
console.log(`Seeded operator '${username}'. Set a password via scripts/set-password.sh ${username}`); console.log(`Seeded operator with telegram_user_id=${operatorTelegramId}`);
await pool.end(); await pool.end();

26
pnpm-lock.yaml generated
View File

@ -93,9 +93,6 @@ importers:
'@types/luxon': '@types/luxon':
specifier: ^3.4.2 specifier: ^3.4.2
version: 3.7.1 version: 3.7.1
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@ -169,9 +166,6 @@ importers:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.3.0 version: 4.3.0
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
'@types/node': '@types/node':
specifier: ^22.7.0 specifier: ^22.7.0
version: 22.19.18 version: 22.19.18
@ -208,9 +202,6 @@ importers:
packages/db: packages/db:
dependencies: dependencies:
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
drizzle-orm: drizzle-orm:
specifier: ^0.36.0 specifier: ^0.36.0
version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6) version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6)
@ -218,9 +209,6 @@ importers:
specifier: ^8.13.0 specifier: ^8.13.0
version: 8.20.0 version: 8.20.0
devDependencies: devDependencies:
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
'@types/node': '@types/node':
specifier: ^22.7.0 specifier: ^22.7.0
version: 22.19.18 version: 22.19.18
@ -2382,10 +2370,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@types/bcryptjs@3.0.0':
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -2575,10 +2559,6 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
body-parser@2.2.2: body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -6481,10 +6461,6 @@ snapshots:
'@turbo/windows-arm64@2.9.12': '@turbo/windows-arm64@2.9.12':
optional: true optional: true
'@types/bcryptjs@3.0.0':
dependencies:
bcryptjs: 3.0.3
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
@ -6700,8 +6676,6 @@ snapshots:
baseline-browser-mapping@2.10.28: {} baseline-browser-mapping@2.10.28: {}
bcryptjs@3.0.3: {}
body-parser@2.2.2: body-parser@2.2.2:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2

View File

@ -1,3 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/create-user.ts "$@"

View File

@ -1,3 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/set-password.ts "$@"