Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa7387fca8 | |||
| 7d3d34af7f | |||
| b47c0409ae | |||
| f08b2bcb13 | |||
| 58b249097a | |||
| 6893ca6ba9 | |||
| 49f5c16b19 | |||
| 954d382b54 | |||
| 31cf845030 | |||
| ea7d07b2c8 | |||
| c906a9fa3a | |||
| 47d7c53fda | |||
| 27318888bc | |||
| b988d117a3 | |||
| d731390c9d | |||
| 08f2c0fd27 | |||
| 2fe8459d25 | |||
| f566e4683a | |||
| 7df3ef9c31 | |||
| 0fd581b365 | |||
| f4da1dd510 | |||
| 50b7e61037 | |||
| 89c7b1a84d | |||
| 32f87e1a92 | |||
| e32f633e02 | |||
| 429ae0827f | |||
| 496f882d9c | |||
| 3af0dc7ca7 | |||
| adaf087a5f | |||
| f69652d43b | |||
| 6942745085 | |||
| 2e6fbfa7a5 | |||
| 991b7ae0ab | |||
| b293bbf142 | |||
| a789b61e1f | |||
| e800882d15 | |||
| 5c48e0e85f | |||
| 40d788302c | |||
| d0db248460 | |||
| 7af7aa35d0 | |||
| 68668ef2cd | |||
| fe8e14b7a0 | |||
| dbdb156a09 | |||
| 6759ca8131 | |||
| 5d583d9194 | |||
| c493101b60 | |||
| b92ead3a97 | |||
| 4ddf5c094e | |||
| 797326e062 | |||
| ebbbdbdfb8 | |||
| 7ab51335a4 | |||
| 050292a282 | |||
| 1b7f553e24 | |||
| b29d137c84 | |||
| 67091c294a | |||
| b77a9d106d | |||
| 5b4787d10e | |||
| 4f1056cdcd | |||
| cedd623466 | |||
| d236196476 | |||
| e1ba1da2de | |||
| 27b7a3df1f | |||
| 838e129f37 | |||
| 46c0315559 | |||
| a37b36196d | |||
| 477e09f645 | |||
| feffe419db |
@ -4,7 +4,7 @@ SESSIONS_DIR=/data/sessions
|
||||
MEDIA_DIR=/data/media
|
||||
BOT_HEALTH_PORT=8081
|
||||
BOT_LOG_LEVEL=debug
|
||||
SEED_OPERATOR_TELEGRAM_ID=818380985
|
||||
SEED_OPERATOR_USERNAME=admin
|
||||
SEED_OPERATOR_NAME="yiekheng (dev)"
|
||||
WEB_PORT=9000
|
||||
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -18,6 +18,13 @@ apps/web/public/swe-worker-*.js
|
||||
# ARE committed to this private Gitea. Only ignore example overrides:
|
||||
.env.local
|
||||
.env.*.local
|
||||
# Anything inside envs/ EXCEPT the example template — a real env
|
||||
# file (envs/ENV) leaked once into commit 6893ca6 carrying the DB
|
||||
# password and AUTH_SECRET. Whitelist .env.example explicitly so a
|
||||
# future copy-paste of envs/.env.example into envs/ENV (or any other
|
||||
# name) gets blocked at git add time.
|
||||
envs/*
|
||||
!envs/.env.example
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
125
README.md
125
README.md
@ -6,24 +6,36 @@ the run history all from a phone home-screen icon.
|
||||
|
||||
## Status
|
||||
|
||||
**Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is
|
||||
the primary control surface; the Telegram bot has been removed.
|
||||
**v1 production-ready.** The web app at `wabot.04080616.xyz` is the
|
||||
primary control surface; the Telegram bot has been removed.
|
||||
|
||||
What's working today:
|
||||
|
||||
- **Username + password auth** with role-based access (admin / user).
|
||||
HttpOnly + Secure session cookies, encrypted with AES-256-GCM (so a
|
||||
leaked cookie reveals nothing about userId / role) and bound to the
|
||||
`OPERATOR_TOKEN_VERSION` env so a single env bump kills every
|
||||
outstanding session.
|
||||
- **Three-layer login rate limit** — per-IP + per-username (lower-cased
|
||||
so case-rotation doesn't help) + a global backstop, so a residential-
|
||||
proxy attacker can't brute one account by hopping IPs.
|
||||
- **Self-hosted Next.js 16 PWA** — installable on a phone home screen.
|
||||
Mobile-first single-row header with a slide-out drawer; desktop
|
||||
sidebar.
|
||||
sidebar. Login lives outside the shell on a bare-header surface.
|
||||
- **Live QR pairing** — server-side Baileys session feeds the QR
|
||||
payload directly into the browser via Server-Sent Events. Scan,
|
||||
see "✅ Connected" within seconds, auto-redirect.
|
||||
- **Duplicate-pair detection** — scanning a QR with a phone already
|
||||
linked to another account row surfaces a clear "already paired as
|
||||
<label>" message instead of fighting Baileys for the device.
|
||||
- **Multi-account, multi-group reminders** — 5-step wizard
|
||||
(Account → Message → When → Groups → Review) plus per-section edit
|
||||
pages so you don't have to walk the wizard end-to-end to fix one
|
||||
field. Active recurrence picker covers Daily / Weekly / Monthly /
|
||||
Yearly with multi-rule support and per-rule fire-time pickers; the
|
||||
rendered description reads as plain English ("Every week on Mon,
|
||||
Wed, Fri at 09:00") not raw cron.
|
||||
field. Recurrence picker covers Daily / Weekly / Monthly / Yearly
|
||||
with multi-rule support and per-rule fire-time pickers; the rendered
|
||||
description reads as plain English ("Every week on Mon, Wed, Fri at
|
||||
09:00") not raw cron. Optional "Pause sending by" deadline that
|
||||
defaults OFF — operators have to opt in explicitly.
|
||||
- **Multi-message stacks** — a reminder can carry multiple ordered
|
||||
parts (text + media), fired in sequence with a 1.5 s gap. Media
|
||||
files swap at any time from the Edit Message page.
|
||||
@ -33,19 +45,29 @@ What's working today:
|
||||
as a downloadable file instead of failing silently.
|
||||
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity
|
||||
row left for Delete or right for Pause/Restart/Archive. iOS-Mail
|
||||
style.
|
||||
style. Click vs drag is disambiguated by a 6-px tap threshold so a
|
||||
swipe doesn't accidentally trigger the row's link.
|
||||
- **Activity tab** — last 200 runs with status filters (Success /
|
||||
Partial / Failed / Skipped) plus an Archived tab. Archive a noisy
|
||||
run to keep the main list readable; restore later. Hard-delete
|
||||
always available. Run history survives a reminder deletion.
|
||||
Paused / Failed / Archived). Partial runs surface under both Paused
|
||||
and Failed; Skipped runs collapse into Archived. Hard-delete and
|
||||
archive both available; run history survives a reminder deletion.
|
||||
- **Auto-reconnect on transient drops; restart-survival via Baileys
|
||||
session persistence.** Pair once, the device stays linked across
|
||||
container restarts.
|
||||
- **All actions audited.** Reminder run history queryable from the
|
||||
UI; per-run target results (sent / failed / skipped) preserved
|
||||
even when the underlying group is removed.
|
||||
container restarts. Logout-on-delete cleans the operator's
|
||||
linked-devices list on the WhatsApp side too.
|
||||
- **Hardened pg-boss scheduling** — three-tier dedupe so a triple-
|
||||
click Save or microsecond-spaced enqueue doesn't fire a reminder
|
||||
multiple times. Reschedule cancels stale jobs by singletonKey first
|
||||
so a recurring next-fire never gets silently dropped.
|
||||
- **Drizzle journal monotonicity guard** — `pnpm migrate` refuses to
|
||||
run if the `_journal.json` `when` timestamps aren't strictly
|
||||
increasing (a recurring foot-gun where drizzle would silently skip
|
||||
a freshly-generated migration). CI tests + the migrate runner both
|
||||
enforce.
|
||||
- **All actions audited.** Per-run target results (sent / failed /
|
||||
skipped) preserved even when the underlying group is removed.
|
||||
|
||||
Test count: **249 web + 31 shared + 26 bot = 306** passing.
|
||||
Test count: **482 web + 88 bot = 570** passing.
|
||||
|
||||
## Host requirements
|
||||
|
||||
@ -79,24 +101,28 @@ Prerequisites: Docker, the `wabot` database + `waBot` role on
|
||||
# 1. Configure env
|
||||
cp envs/.env.example .env.development
|
||||
# edit .env.development: real DATABASE_URL, plus the LAN host to expose
|
||||
scripts/gen_auth_secret.sh --write
|
||||
scripts/gen_auth_secret.sh --write # writes AUTH_SECRET to .env.development
|
||||
|
||||
# 2. Bring up the stack, install deps
|
||||
NO_SUDO=1 scripts/dev.sh up
|
||||
NO_SUDO=1 scripts/dev.sh pnpm install
|
||||
|
||||
# 3. Apply migrations and seed your operator row
|
||||
# 3. Apply migrations and seed the bootstrap operator row
|
||||
NO_SUDO=1 scripts/db.sh migrate
|
||||
NO_SUDO=1 scripts/db.sh seed
|
||||
|
||||
# 4. Open the web app
|
||||
# 4. Set the bootstrap admin password (NO password is set by seed)
|
||||
echo 'change-me-now' | scripts/set-password.sh admin
|
||||
|
||||
# 5. Open the web app and sign in as `admin` with the password above
|
||||
# Local: http://localhost:9000
|
||||
# LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000)
|
||||
# Public: https://wabot.04080616.xyz (whatever your reverse proxy serves)
|
||||
# LAN: http://<host-ip>:9000
|
||||
# Public: https://wabot.04080616.xyz
|
||||
```
|
||||
|
||||
Pair an account: `/accounts` → "New Account" → enter a label →
|
||||
"Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".
|
||||
Inside the app: `/settings/users` → Add user → invite teammates with
|
||||
`user` role; promote / demote / reset password / delete from the same
|
||||
page. The "Admin" nav entry is admin-only.
|
||||
|
||||
PWA install: phone Chrome → menu → "Install App" / "Add to Home
|
||||
Screen". Launches fullscreen.
|
||||
@ -104,10 +130,22 @@ Screen". Launches fullscreen.
|
||||
`NO_SUDO=1` is the right setting if your user is in the `docker`
|
||||
group (the default for this repo). Drop it if you need `sudo docker`.
|
||||
|
||||
## Deploying
|
||||
|
||||
- **Local dev** — `NO_SUDO=1 scripts/dev.sh up` (described in Quick
|
||||
start above).
|
||||
- **Portainer** — push images with `scripts/publish.sh`, then deploy
|
||||
the [`docker-compose.portainer.yml`](docker-compose.portainer.yml)
|
||||
stack via the Portainer UI. Full walk-through:
|
||||
[`docs/deploy-portainer.md`](docs/deploy-portainer.md).
|
||||
|
||||
## Manual test runbook
|
||||
|
||||
End-to-end checks that unit tests can't cover (live Baileys,
|
||||
WhatsApp delivery, swipe gestures):
|
||||
[`docs/runbook.md`](docs/runbook.md).
|
||||
|
||||
The earlier wizard-only checklist still lives at
|
||||
[`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md).
|
||||
|
||||
## Layout
|
||||
@ -118,11 +156,14 @@ WhatsApp delivery, swipe gestures):
|
||||
- `packages/db/` — Drizzle schema and migrations
|
||||
- `packages/shared/` — cross-app helpers (rrule, media paths,
|
||||
timezones, WhatsApp media classifier)
|
||||
- `docs/superpowers/specs/` — design specs and manual test runbooks
|
||||
- `docs/runbook.md` — manual end-to-end smoke checklist
|
||||
- `docs/superpowers/specs/` — design specs and earlier manual test
|
||||
runbooks
|
||||
- `docs/superpowers/plans/` — implementation plans
|
||||
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
|
||||
`web.Dockerfile`)
|
||||
- `scripts/` — `dev.sh`, `db.sh`, `gen_auth_secret.sh`
|
||||
- `scripts/` — `dev.sh`, `db.sh`, `gen_auth_secret.sh`,
|
||||
`set-password.sh`, `create-user.sh`
|
||||
|
||||
## Scripts
|
||||
|
||||
@ -134,17 +175,39 @@ container, so no host Node is needed.
|
||||
| `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell |
|
||||
| `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper |
|
||||
| `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) |
|
||||
| `scripts/set-password.sh <username>` | Set / reset a user's password (reads stdin) |
|
||||
| `scripts/create-user.sh <username> <role>` | Create a user from CLI (admin / user) |
|
||||
|
||||
Set `NO_SUDO=1` if your user is in the docker group (recommended).
|
||||
|
||||
## Auth + admin model
|
||||
|
||||
- One bootstrap operator (`admin`) is created by the seed; its
|
||||
password is set via `scripts/set-password.sh admin` on first launch.
|
||||
- Two roles: `admin` (full access including user management) and
|
||||
`user` (everything except `/settings/users`). Role-based nav
|
||||
filtering is enforced in middleware + the AppShell + every server
|
||||
action that mutates user state.
|
||||
- Every user gets an isolated workspace — accounts, reminders,
|
||||
groups, and run history all scope by `operator_id`. The admin
|
||||
panel is the only cross-tenant surface.
|
||||
- Sessions: AES-256-GCM-encrypted cookie keyed off `AUTH_SECRET`,
|
||||
HttpOnly + Secure-in-prod + SameSite=Lax, 30-day TTL. The
|
||||
`OPERATOR_TOKEN_VERSION` env (defaults to `"1"`) is the kill switch
|
||||
— bumping it invalidates every outstanding cookie globally on the
|
||||
next request.
|
||||
- Login rate limits: 10 / 5 min per-IP + 5 / 15 min per-username + a
|
||||
100 / min global backstop. The error message is identical for all
|
||||
three so the limit-which-tripped isn't leaked.
|
||||
|
||||
## Deferred
|
||||
|
||||
- **Standalone media library** browser (currently media is uploaded
|
||||
per-reminder).
|
||||
- **E2E browser tests** (Playwright) on the swipe and pairing flows.
|
||||
- **Auth** (passkeys / email-password) — bring back if URL exposure
|
||||
becomes a concern. Today the app trusts whatever's in front of the
|
||||
reverse proxy.
|
||||
- **Multi-operator** — schema supports `operator_id` on every row,
|
||||
but the seed runs as a single operator and there's no /signup or
|
||||
invite flow yet.
|
||||
- **Search-as-you-type in the wizard's groups picker** — at 3 000+
|
||||
groups per account the picker still loads the alphabetical
|
||||
top-200; operators with >200 groups need to use the list page's
|
||||
search to find anything past 'L'.
|
||||
- **Self-service password reset** (email link, etc.) — out of scope
|
||||
for v1; admins use the Users page.
|
||||
|
||||
@ -3,7 +3,7 @@ import type { Notification } from "pg";
|
||||
import { logger } from "../logger.js";
|
||||
import { env } from "../env.js";
|
||||
import { handleStartPairing } from "./pair-handler.js";
|
||||
import { handleUnpair } from "./unpair-handler.js";
|
||||
import { handleUnpair, handleDelete } from "./unpair-handler.js";
|
||||
import { handleSyncGroups } from "./sync-groups-handler.js";
|
||||
import { handleSendTest } from "./send-test-handler.js";
|
||||
import {
|
||||
@ -14,6 +14,11 @@ import {
|
||||
export type BotCommand =
|
||||
| { type: "account.start_pairing"; 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: "group.send_test"; groupId: string; text: string }
|
||||
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
||||
@ -74,6 +79,9 @@ export function registerDefaultHandlers(): void {
|
||||
registerHandler("account.unpair", async (cmd) => {
|
||||
await handleUnpair(cmd.accountId);
|
||||
});
|
||||
registerHandler("account.delete", async (cmd) => {
|
||||
await handleDelete(cmd.accountId);
|
||||
});
|
||||
registerHandler("account.sync_groups", async (cmd) => {
|
||||
await handleSyncGroups(cmd.accountId);
|
||||
});
|
||||
|
||||
@ -10,6 +10,16 @@ export type WebEvent =
|
||||
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
||||
| { type: "session.disconnected"; 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: "reminder.fired";
|
||||
|
||||
@ -10,11 +10,23 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js";
|
||||
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
||||
import { writeAuditLog } from "../audit.js";
|
||||
import { pgNotifyWeb } from "./notify.js";
|
||||
import {
|
||||
decidePairListenerOnClose,
|
||||
findDuplicateExistingAccount,
|
||||
nextWarmingUpAfterEvent,
|
||||
} from "./pair-state.js";
|
||||
|
||||
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const offByAccount = new Map<string, () => void>();
|
||||
const lastQrPayload = new Map<string, string>();
|
||||
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 }> {
|
||||
const account = await db.query.whatsappAccounts.findFirst({
|
||||
@ -34,6 +46,7 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
|
||||
pairTimeouts.delete(accountId);
|
||||
}
|
||||
lastQrPayload.delete(accountId);
|
||||
pairingWarmingUp.delete(accountId);
|
||||
if (sessionManager.hasSession(accountId)) {
|
||||
await sessionManager.stop(accountId);
|
||||
}
|
||||
@ -80,10 +93,17 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
.set({ lastQrPng: null })
|
||||
.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) => {
|
||||
if (id !== accountId) return;
|
||||
try {
|
||||
if (event.type === "qr") {
|
||||
pairingWarmingUp.delete(id);
|
||||
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
||||
// burst. Different strings (a fresh QR) always pass through, so
|
||||
// the user gets a new QR as soon as Baileys generates one.
|
||||
@ -102,6 +122,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
ts: Date.now(),
|
||||
});
|
||||
} else if (event.type === "open") {
|
||||
pairingWarmingUp.delete(id);
|
||||
const t = pairTimeouts.get(id);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
@ -109,6 +130,53 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
}
|
||||
lastQrPayload.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);
|
||||
let synced = 0;
|
||||
if (session) {
|
||||
@ -134,27 +202,42 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
count: synced,
|
||||
});
|
||||
off();
|
||||
} else if (event.type === "close" && event.restartRequired) {
|
||||
} else if (event.type === "close") {
|
||||
const decision = decidePairListenerOnClose({
|
||||
warmingUp: pairingWarmingUp.has(id),
|
||||
restartRequired: event.restartRequired,
|
||||
});
|
||||
if (decision === "ignore-leaked-close") {
|
||||
logger.info(
|
||||
{ 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 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`.
|
||||
// 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",
|
||||
);
|
||||
// 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") {
|
||||
// During the pairing window, any other close means the QR window
|
||||
// ended without a successful link — Baileys' default is to
|
||||
// close after exhausting QR refs (~2.5 min). Surface this to
|
||||
// the UI so the user gets a "pairing timed out" screen, and
|
||||
// park the row in a stable state so it shows up cleanly on
|
||||
// the accounts list with a "Re-pair" affordance.
|
||||
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);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
|
||||
@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
decideOnPairClose,
|
||||
decideOnPairTimeout,
|
||||
decidePairListenerOnClose,
|
||||
findDuplicateExistingAccount,
|
||||
nextWarmingUpAfterEvent,
|
||||
shouldAutoReconnect,
|
||||
} from "./pair-state.js";
|
||||
|
||||
@ -82,3 +85,225 @@ describe("shouldAutoReconnect", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -80,3 +80,106 @@ export function decideOnPairTimeout({ current }: { current: AccountStatus }): St
|
||||
if (current !== "pending") return null;
|
||||
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;
|
||||
}
|
||||
|
||||
128
apps/bot/src/ipc/unpair-handler.test.ts
Normal file
128
apps/bot/src/ipc/unpair-handler.test.ts
Normal file
@ -0,0 +1,128 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -39,3 +39,41 @@ export async function handleUnpair(accountId: string): Promise<void> {
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
@ -108,6 +108,51 @@ describe("fireReminder", () => {
|
||||
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 () => {
|
||||
// Two pg-boss jobs landing within microseconds for the same
|
||||
// reminder should NOT both fire. The first creates the run; the
|
||||
|
||||
@ -154,6 +154,32 @@ async function fireReminderInner(
|
||||
.set({ status: "pending", errorSummary: null })
|
||||
.where(eq(reminderRuns.id, runId));
|
||||
} 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
|
||||
.insert(reminderRuns)
|
||||
.values({
|
||||
|
||||
119
apps/bot/src/scheduler/reminder-jobs.test.ts
Normal file
119
apps/bot/src/scheduler/reminder-jobs.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -1,21 +1,39 @@
|
||||
import type { PgBoss } from "pg-boss";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { logger } from "../logger.js";
|
||||
import { env } from "../env.js";
|
||||
import { db } from "../db.js";
|
||||
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
|
||||
|
||||
export const REMINDER_FIRE_QUEUE = "reminder.fire";
|
||||
|
||||
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
|
||||
// 'stately' = at most 1 job per (state, singletonKey). Combined with
|
||||
// singletonKey="reminder:<id>" on every send, that means a duplicate
|
||||
// schedule call (e.g. operator double-clicked Save, or the
|
||||
// pg_notify('bot.command') consumer fired twice in the same tick)
|
||||
// is folded into the existing 'created' job instead of producing a
|
||||
// second run. The default 'standard' policy DOES NOT dedupe by
|
||||
// singletonKey — that's how we ended up firing a reminder twice
|
||||
// when two reminder.fire jobs landed within microseconds.
|
||||
// https://github.com/timgit/pg-boss/blob/master/docs/usage.md#queue-policies
|
||||
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "stately" });
|
||||
// 'standard' (the default) lets us enqueue a new fire even when an
|
||||
// older one for the same singletonKey is still 'created'. We need
|
||||
// that for the recurring/edit path: when a reminder is rescheduled,
|
||||
// scheduleReminderFire() first cancels the stale 'created' job for
|
||||
// this reminder and then sends a new one — under 'stately' the
|
||||
// SECOND send returns null (it dedupes against the first across
|
||||
// states), so a reschedule silently dropped the new fire and the
|
||||
// reminder never fired at the new time. Duplicate-fire safety is
|
||||
// covered at the handler level by the inner-mutex recent-run check
|
||||
// in fire-reminder.ts (see DUPLICATE_FIRE_WINDOW_MS), which catches
|
||||
// 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>(
|
||||
REMINDER_FIRE_QUEUE,
|
||||
{
|
||||
@ -43,6 +61,33 @@ export async function scheduleReminderFire(
|
||||
reminderId: string,
|
||||
scheduledAt: Date,
|
||||
): 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(
|
||||
REMINDER_FIRE_QUEUE,
|
||||
{ reminderId },
|
||||
@ -51,8 +96,10 @@ export async function scheduleReminderFire(
|
||||
retryLimit: 3,
|
||||
retryDelay: 30,
|
||||
retryBackoff: true,
|
||||
// Use the reminderId as a singleton key so re-scheduling cancels the old job
|
||||
singletonKey: `reminder:${reminderId}`,
|
||||
// Singleton key kept on the job row for diagnostics + the
|
||||
// pre-send cancel above, even though 'standard' policy doesn't
|
||||
// dedupe by it.
|
||||
singletonKey,
|
||||
},
|
||||
);
|
||||
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
|
||||
|
||||
@ -7,35 +7,45 @@ import { logger } from "../logger.js";
|
||||
export async function syncGroupsForAccount(
|
||||
accountId: string,
|
||||
socket: WASocket,
|
||||
): Promise<{ synced: number; removed: number }> {
|
||||
): Promise<{ synced: number; archived: number }> {
|
||||
const meta = await socket.groupFetchAllParticipating();
|
||||
const entries = Object.values(meta);
|
||||
const liveJids = entries.map((g) => g.id);
|
||||
|
||||
// Remove DB rows for groups that are no longer in the live participant list
|
||||
// (group was deleted, bot was removed, etc.). Only run the delete when we
|
||||
// got at least one live group back — an empty result is more likely a
|
||||
// transient WA fetch failure than a genuine "all groups gone" signal, and
|
||||
// we don't want to nuke valid data on a hiccup.
|
||||
let removed: { id: string }[] = [];
|
||||
// Mark DB rows as archived when they're no longer in the live
|
||||
// participant list (group deleted, bot removed, etc). We don't
|
||||
// physically DELETE because reminder_targets.group_id is a NOT
|
||||
// NULL FK to this row — a hard delete throws "violates foreign
|
||||
// key constraint reminder_targets_group_id_whatsapp_groups_id_fk"
|
||||
// and aborts the WHOLE group-sync transaction (which then strands
|
||||
// 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) {
|
||||
removed = await db
|
||||
.delete(whatsappGroups)
|
||||
const rows = await db
|
||||
.update(whatsappGroups)
|
||||
.set({ isArchived: true, lastSyncedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(whatsappGroups.accountId, accountId),
|
||||
notInArray(whatsappGroups.waGroupJid, liveJids),
|
||||
eq(whatsappGroups.isArchived, false),
|
||||
),
|
||||
)
|
||||
.returning({ id: whatsappGroups.id });
|
||||
archived = rows.length;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
logger.info(
|
||||
{ accountId },
|
||||
"group-sync: empty fetch — skipping delete sweep (treating as transient)",
|
||||
"group-sync: empty fetch — skipping archive sweep (treating as transient)",
|
||||
);
|
||||
return { synced: 0, removed: 0 };
|
||||
return { synced: 0, archived: 0 };
|
||||
}
|
||||
|
||||
const rows = entries.map((g) => ({
|
||||
@ -56,12 +66,16 @@ export async function syncGroupsForAccount(
|
||||
name: sql`excluded.name`,
|
||||
participantCount: sql`excluded.participant_count`,
|
||||
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(
|
||||
{ accountId, count: rows.length, removed: removed.length },
|
||||
{ accountId, count: rows.length, archived },
|
||||
"group-sync: synced",
|
||||
);
|
||||
return { synced: rows.length, removed: removed.length };
|
||||
return { synced: rows.length, archived };
|
||||
}
|
||||
|
||||
@ -120,6 +120,44 @@ class SessionManager {
|
||||
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> {
|
||||
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
||||
}
|
||||
|
||||
27
apps/web/.env.example
Normal file
27
apps/web/.env.example
Normal file
@ -0,0 +1,27 @@
|
||||
# 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
|
||||
@ -21,6 +21,7 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
serverActions: {
|
||||
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
|
||||
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
||||
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
||||
// the action; the per-kind WhatsApp validator
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@serwist/next": "^9.5.11",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
@ -44,6 +45,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
@ -172,8 +172,16 @@ export async function unpairAccountAction(formData: FormData): Promise<void> {
|
||||
.update(whatsappAccounts)
|
||||
.set({ status: "unpaired", phoneNumber: null })
|
||||
.where(eq(whatsappAccounts.id, accountId));
|
||||
// Wipe synced groups too — they belong to a different WA login now.
|
||||
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
|
||||
// Soft-archive synced groups instead of DELETEing. Hard delete
|
||||
// failed with "violates foreign key constraint
|
||||
// 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/${accountId}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -193,8 +201,12 @@ export async function deleteAccountAction(formData: FormData): Promise<void> {
|
||||
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
||||
});
|
||||
if (!account) return;
|
||||
// Stop any live session / clean session files first.
|
||||
await pgNotifyBot({ type: "account.unpair", accountId });
|
||||
// Tell the bot to logout() over the live socket FIRST (so WhatsApp
|
||||
// drops this device from the operator's linked-devices list), then
|
||||
// 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.
|
||||
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
||||
revalidatePath("/accounts");
|
||||
|
||||
367
apps/web/src/actions/auth.test.ts
Normal file
367
apps/web/src/actions/auth.test.ts
Normal file
@ -0,0 +1,367 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
182
apps/web/src/actions/auth.ts
Normal file
182
apps/web/src/actions/auth.ts
Normal file
@ -0,0 +1,182 @@
|
||||
"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);
|
||||
}
|
||||
@ -33,6 +33,12 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis
|
||||
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 group = await db.query.whatsappGroups.findFirst({
|
||||
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
||||
|
||||
@ -271,7 +271,7 @@ const createReminderSchema = z
|
||||
path: ["messages"],
|
||||
},
|
||||
)
|
||||
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 18), {
|
||||
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), {
|
||||
message: "Delivery window start must be earlier than end",
|
||||
path: ["deliveryWindowStartHour"],
|
||||
});
|
||||
@ -328,7 +328,11 @@ export async function createReminderAction(
|
||||
timezone,
|
||||
} = parsed.data;
|
||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
||||
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
|
||||
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
|
||||
// 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 op = await getSeededOperator();
|
||||
@ -442,7 +446,11 @@ export async function updateReminderAction(
|
||||
timezone,
|
||||
} = parsed.data;
|
||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
||||
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
|
||||
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
|
||||
// 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 op = await getSeededOperator();
|
||||
@ -563,6 +571,12 @@ export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string
|
||||
export async function resumeReminderRunAction(input: {
|
||||
runId: string;
|
||||
}): 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 parsed = runIdSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
@ -613,6 +627,12 @@ export type CancelReminderRunResult = { ok: true } | { ok: false; error: string
|
||||
export async function cancelReminderRunAction(input: {
|
||||
runId: string;
|
||||
}): 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 parsed = runIdSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
|
||||
192
apps/web/src/actions/users.test.ts
Normal file
192
apps/web/src/actions/users.test.ts
Normal file
@ -0,0 +1,192 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
139
apps/web/src/actions/users.ts
Normal file
139
apps/web/src/actions/users.ts
Normal file
@ -0,0 +1,139 @@
|
||||
"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 };
|
||||
}
|
||||
104
apps/web/src/app/accounts/[id]/delete-account-card.tsx
Normal file
104
apps/web/src/app/accounts/[id]/delete-account-card.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,6 @@ import {
|
||||
ArrowLeftIcon,
|
||||
SearchIcon,
|
||||
UsersIcon,
|
||||
RefreshCwIcon,
|
||||
Users2Icon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -16,6 +15,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { listGroupsForAccount } from "@/lib/queries";
|
||||
import { RefreshGroupsClient } from "./refresh-groups-client";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
@ -57,13 +57,7 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<RefreshGroupsClient accountId={account.id} />
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
UsersIcon,
|
||||
Trash2Icon,
|
||||
ArrowLeftIcon,
|
||||
SmartphoneIcon,
|
||||
CalendarIcon,
|
||||
@ -10,7 +9,6 @@ import {
|
||||
DatabaseIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
PowerOffIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -20,23 +18,12 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { getAccount } from "@/lib/queries";
|
||||
import {
|
||||
unpairAccountAction,
|
||||
pairAccountAction,
|
||||
deleteAccountAction,
|
||||
} from "@/actions/accounts";
|
||||
import { pairAccountAction } from "@/actions/accounts";
|
||||
import { DeleteAccountCard } from "./delete-account-card";
|
||||
import { UnpairAccountCard } from "./unpair-account-card";
|
||||
|
||||
interface AccountDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@ -156,102 +143,11 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* 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>
|
||||
<UnpairAccountCard 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>
|
||||
<DeleteAccountCard accountId={account.id} accountLabel={account.label} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
102
apps/web/src/app/accounts/[id]/unpair-account-card.tsx
Normal file
102
apps/web/src/app/accounts/[id]/unpair-account-card.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -14,15 +14,6 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -38,7 +29,6 @@ import { getSeededOperator } from "@/lib/operator";
|
||||
import { listActivityRuns } from "@/lib/queries";
|
||||
import {
|
||||
archiveRunAction,
|
||||
clearHistoryAction,
|
||||
deleteRunAction,
|
||||
unarchiveRunAction,
|
||||
} from "@/actions/history";
|
||||
@ -106,24 +96,24 @@ function RunStatusBadge({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
type FilterValue =
|
||||
| "all"
|
||||
| "success"
|
||||
| "paused"
|
||||
| "partial"
|
||||
| "failed"
|
||||
| "skipped"
|
||||
| "archived";
|
||||
type FilterValue = "success" | "paused" | "failed" | "archived";
|
||||
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "success", label: "Success" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "partial", label: "Partial" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "skipped", label: "Skipped" },
|
||||
{ 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 {
|
||||
searchParams: Promise<{ filter?: string }>;
|
||||
}
|
||||
@ -185,76 +175,41 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
const filter: FilterValue =
|
||||
sp.filter === "success" ||
|
||||
sp.filter === "paused" ||
|
||||
sp.filter === "partial" ||
|
||||
sp.filter === "failed" ||
|
||||
sp.filter === "skipped" ||
|
||||
sp.filter === "archived"
|
||||
? sp.filter
|
||||
: "all";
|
||||
: "success";
|
||||
const showingArchived = filter === "archived";
|
||||
|
||||
const op = await getSeededOperator();
|
||||
const runs = await listActivityRuns(op.id, { archived: showingArchived });
|
||||
const filtered =
|
||||
filter === "all" || filter === "archived"
|
||||
filter === "archived"
|
||||
? runs
|
||||
: runs.filter((r) => r.status === filter);
|
||||
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
|
||||
const hasAny = runs.length > 0;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Activity"
|
||||
action={
|
||||
hasAny && !showingArchived ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
|
||||
<Trash2Icon />
|
||||
Clear history
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear all run history?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently removes every reminder run record, including
|
||||
runs from reminders that have already been deleted. Reminders
|
||||
themselves are not affected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<form action={clearHistoryAction}>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
Yes, clear history
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{/* 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. */}
|
||||
<PageShell title="Activity">
|
||||
{/* Filter tabs span the full row and wrap onto a second line when the
|
||||
viewport can't fit them all. Each trigger has a small basis so they
|
||||
share space evenly while still keeping a readable label on mobile. */}
|
||||
<Tabs value={filter}>
|
||||
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList>
|
||||
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
|
||||
{FILTER_TABS.map(({ value, label }) => (
|
||||
<TabsTrigger key={value} value={value} asChild>
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
asChild
|
||||
className="h-8 grow basis-20"
|
||||
>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
|
||||
<Link href={`/activity?filter=${value}` as any}>
|
||||
{label}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
@ -422,11 +377,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title={
|
||||
filter === "all"
|
||||
? "No activity yet."
|
||||
: showingArchived
|
||||
? "No archived runs."
|
||||
: `No ${filter} runs yet.`
|
||||
showingArchived ? "No archived runs." : `No ${filter} runs yet.`
|
||||
}
|
||||
description={
|
||||
hasAny
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// Without these, `next build`'s "Collecting page data" pass invokes
|
||||
// the GET handler in the build container — which has no
|
||||
// DATABASE_URL — and the env access throws ZodError, killing the
|
||||
// docker build. Marking the route force-dynamic + nodejs runtime
|
||||
// tells Next to skip the build-time call and only run at request
|
||||
// time.
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ accountId: string }>;
|
||||
}
|
||||
|
||||
@ -4,12 +4,14 @@ import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { NotificationManager } from "@/components/notification-manager";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "cm WhatsApp Bot",
|
||||
description: "Self-hosted WhatsApp reminder bot",
|
||||
applicationName: "cm WhatsApp Bot",
|
||||
robots: { index: false, follow: false },
|
||||
// PWA wiring: the manifest comes from the dynamic route at
|
||||
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
||||
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
||||
@ -32,7 +34,13 @@ export const viewport: Viewport = {
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async 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 (
|
||||
// `suppressHydrationWarning` here is for *attribute* differences only.
|
||||
// Two sources legitimately mutate <html>/<body> attributes after the
|
||||
@ -45,7 +53,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
||||
<body suppressHydrationWarning>
|
||||
<ThemeProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
<AppShell role={role} username={username}>{children}</AppShell>
|
||||
<Toaster richColors position="top-right" />
|
||||
{/* SSE → browser notification bridge. Renders no DOM. */}
|
||||
<NotificationManager />
|
||||
|
||||
101
apps/web/src/app/login/login-form-client.tsx
Normal file
101
apps/web/src/app/login/login-form-client.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/login/page.tsx
Normal file
25
apps/web/src/app/login/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -217,7 +217,7 @@ export default async function DashboardPage() {
|
||||
themselves are not affected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<DialogFooter>
|
||||
<form action={clearHistoryAction}>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
|
||||
@ -177,7 +177,7 @@ function ConfirmCard(props: ConfirmCardProps) {
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter showCloseButton>
|
||||
<DialogFooter>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
setSubmitting(true);
|
||||
|
||||
@ -230,12 +230,28 @@ export default async function ReminderDetailPage({ params }: Props) {
|
||||
</p>
|
||||
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
||||
{reminder.rrule && reminder.scheduledAt ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-primary/80">
|
||||
// Single-line summary with mid-string ellipsis. Long
|
||||
// descriptions ("Every month on days 4, 6, 11, 13, 18,
|
||||
// 20 +2 more at 11:32") truncate cleanly via `truncate`
|
||||
// (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),
|
||||
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 className="text-xs text-muted-foreground">One-off</p>
|
||||
|
||||
@ -247,15 +247,30 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right space-y-1">
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
{/* Right meta column. Capped at ~14rem so a long
|
||||
recurrence description ("Every month on days
|
||||
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" />
|
||||
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
|
||||
<span className="truncate">
|
||||
{formatWhen(reminder.scheduledAt, tz)}
|
||||
</span>
|
||||
</div>
|
||||
{reminder.rrule && reminder.scheduledAt ? (
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-primary/80">
|
||||
<div
|
||||
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" />
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{describeRecurrence(
|
||||
specFromRrule(reminder.rrule),
|
||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||
@ -264,9 +279,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
) : null}
|
||||
{reminder.groupCount > 0 && (
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
<UsersIcon className="size-3 shrink-0" />
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{reminder.groupCount}{" "}
|
||||
{reminder.groupCount === 1 ? "group" : "groups"}
|
||||
</span>
|
||||
|
||||
5
apps/web/src/app/robots.ts
Normal file
5
apps/web/src/app/robots.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { PageShell } from "@/components/page-shell";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const op = await getSeededOperator();
|
||||
const isAdmin = op.role === "admin";
|
||||
return (
|
||||
<PageShell title="Settings" narrow>
|
||||
<Card>
|
||||
@ -14,13 +15,15 @@ export default async function SettingsPage() {
|
||||
<CardTitle>Operator</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<Row label="Display name" value={op.displayName} />
|
||||
<Separator />
|
||||
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
|
||||
<Row label="Username" value={op.username} mono />
|
||||
<Separator />
|
||||
<Row label="Default timezone" value={op.defaultTimezone} mono />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<Row label="Role" value={op.role} mono />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -47,10 +50,6 @@ export default async function SettingsPage() {
|
||||
<ThemeToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
cm WhatsApp Bot · self-hosted
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
95
apps/web/src/app/settings/users/add-user-form-client.tsx
Normal file
95
apps/web/src/app/settings/users/add-user-form-client.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/app/settings/users/page.tsx
Normal file
62
apps/web/src/app/settings/users/page.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
197
apps/web/src/app/settings/users/user-row-client.tsx
Normal file
197
apps/web/src/app/settings/users/user-row-client.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
|
||||
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<main>page</main>
|
||||
</AppShell>,
|
||||
);
|
||||
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
|
||||
it("brand mark on the left links to /", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
for (const c of cases) {
|
||||
pathnameMock.mockReturnValue(c.path);
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -104,7 +104,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
||||
pathnameMock.mockReturnValue("/unknown-route");
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
|
||||
it("menu button on the right uses aria-label='Open menu'", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -134,7 +134,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
||||
|
||||
it("renders one nav link per NAV_ITEM, in order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -157,7 +157,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
||||
it("marks the active route's link with aria-current='page'", () => {
|
||||
pathnameMock.mockReturnValue("/reminders");
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -174,7 +174,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
||||
// every page. The header uses an exact-match check for "/".
|
||||
pathnameMock.mockReturnValue("/accounts");
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -185,7 +185,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
||||
|
||||
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -195,7 +195,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
||||
|
||||
it("drawer header carries the brand wording and a screen-reader description", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
||||
|
||||
it("renders the sidebar nav with every NAV_ITEM", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -232,21 +232,22 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the theme toggle in the sidebar footer", () => {
|
||||
it("renders a Sign out button in the sidebar footer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
|
||||
// appear in the sidebar (we removed it from the mobile drawer).
|
||||
expect(html).toContain('data-testid="theme-toggle"');
|
||||
// Theme toggle was dropped from the shell per request; the footer
|
||||
// now carries the Sign out affordance + the signed-in username.
|
||||
expect(html).toContain('aria-label="Sign out"');
|
||||
expect(html).toContain("admin");
|
||||
});
|
||||
|
||||
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
||||
pathnameMock.mockReturnValue("/accounts");
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -264,7 +265,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
||||
// reader users on a wide-window split-screen don't hear two
|
||||
// identical announcements when both are visible.
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -273,6 +274,79 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { MenuIcon } from "lucide-react";
|
||||
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logoutAction } from "@/actions/auth";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -14,8 +15,13 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { NAV_ITEMS } from "@/components/nav-config";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import {
|
||||
NAV_ITEMS,
|
||||
navItemsForRole,
|
||||
pickActiveNavKey,
|
||||
type NavItem,
|
||||
type NavRole,
|
||||
} from "@/components/nav-config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile header (sm:hidden)
|
||||
@ -30,8 +36,51 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
||||
// waiting for the page content to render. The menu button on the right
|
||||
// opens a Sheet with the full nav list and the theme toggle.
|
||||
// ---------------------------------------------------------------------------
|
||||
function MobileHeader() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 activeKey = pickActiveNavKey(items, pathname);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Close the drawer when the route changes (i.e. the user picked a nav
|
||||
@ -41,6 +90,10 @@ function MobileHeader() {
|
||||
setOpen(false);
|
||||
}, [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 }) =>
|
||||
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
||||
);
|
||||
@ -90,10 +143,10 @@ function MobileHeader() {
|
||||
|
||||
<nav
|
||||
aria-label="Primary navigation"
|
||||
className="flex flex-col gap-0.5 p-2 flex-1"
|
||||
className="flex flex-col gap-0.5 p-2"
|
||||
>
|
||||
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
{items.map(({ key, href, label, icon: Icon }) => {
|
||||
const active = activeKey === key;
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
@ -117,6 +170,10 @@ function MobileHeader() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto border-t border-border p-3">
|
||||
<SignOutButton username={username} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</header>
|
||||
@ -126,8 +183,15 @@ function MobileHeader() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar (desktop only — hidden below sm)
|
||||
// ---------------------------------------------------------------------------
|
||||
function Sidebar() {
|
||||
function Sidebar({
|
||||
items,
|
||||
username,
|
||||
}: {
|
||||
items: NavItem[];
|
||||
username: string | null;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const activeKey = pickActiveNavKey(items, pathname);
|
||||
|
||||
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">
|
||||
@ -150,7 +214,7 @@ function Sidebar() {
|
||||
|
||||
{/* Nav items */}
|
||||
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
||||
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
||||
{items.map(({ key, href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
@ -172,29 +236,74 @@ function Sidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer: theme toggle */}
|
||||
{/* Footer: signed-in user + sign-out */}
|
||||
<div className="border-t border-sidebar-border p-3">
|
||||
<ThemeToggle />
|
||||
<SignOutButton username={username} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
/** Role of the signed-in user, or null when unauthenticated. */
|
||||
role: NavRole | null;
|
||||
/** Username of the signed-in user, surfaced in the footer + sign-out hint. */
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
export function AppShell({ children, role, username }: 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 (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<Sidebar />
|
||||
<Sidebar items={items} username={username} />
|
||||
|
||||
{/* Mobile header (single row: brand · title · menu) */}
|
||||
<MobileHeader />
|
||||
<MobileHeader items={items} username={username} />
|
||||
|
||||
{/* Main content
|
||||
Mobile: push down for the h-14 header (56px) plus a small gap
|
||||
|
||||
119
apps/web/src/components/nav-config.test.ts
Normal file
119
apps/web/src/components/nav-config.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,11 +1,22 @@
|
||||
import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
|
||||
import {
|
||||
Home,
|
||||
Smartphone,
|
||||
Calendar,
|
||||
Activity,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type NavRole = "admin" | "user";
|
||||
|
||||
export interface NavItem {
|
||||
key: string;
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** When set, only roles listed here will see this nav entry. */
|
||||
visibleTo?: NavRole[];
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
@ -13,5 +24,54 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
||||
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -18,7 +18,8 @@ type PairingState =
|
||||
| { phase: "waiting" }
|
||||
| { phase: "qr"; qrUrl: string }
|
||||
| { phase: "connected"; phoneNumber: string }
|
||||
| { phase: "timeout" };
|
||||
| { phase: "timeout" }
|
||||
| { phase: "duplicate"; phoneNumber: string; existingLabel: string };
|
||||
|
||||
interface PairLiveProps {
|
||||
accountId: string;
|
||||
@ -112,6 +113,15 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
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
|
||||
@ -234,6 +244,35 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
|
||||
id="filter-account"
|
||||
value={initial.accountId}
|
||||
onChange={(e) => setParam("accountId", e.target.value)}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<option value="">All accounts</option>
|
||||
{accounts.map((a) => (
|
||||
|
||||
@ -67,6 +67,11 @@ export function SwipeableRow({
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | 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.
|
||||
useEffect(() => {
|
||||
@ -92,12 +97,17 @@ export function SwipeableRow({
|
||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
dragStart.current = { x: e.clientX, baseOffset: offset };
|
||||
dragMoved.current = false;
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!dragging || !dragStart.current) return;
|
||||
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));
|
||||
}
|
||||
|
||||
@ -113,6 +123,28 @@ export function SwipeableRow({
|
||||
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 (
|
||||
@ -150,6 +182,14 @@ export function SwipeableRow({
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={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={{
|
||||
transform: `translateX(${offset}px)`,
|
||||
transition: dragging ? "none" : "transform 200ms ease-out",
|
||||
|
||||
@ -8,4 +8,25 @@ const envSchema = z.object({
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
// Lazy parse via Proxy. Next.js's `next build` does a
|
||||
// "Collecting page data" pass that imports every route module —
|
||||
// including api/events/route.ts which depends on this env. With a
|
||||
// top-level `envSchema.parse(process.env)` the parse ran during
|
||||
// the build container, where DATABASE_URL isn't (and shouldn't be)
|
||||
// set, and Zod aborted the build with:
|
||||
// ZodError: DATABASE_URL: Required
|
||||
// Deferring the parse until first property access lets the build
|
||||
// finish (no consumer accesses env during page-data collection)
|
||||
// while still failing loudly at runtime if the var is missing.
|
||||
let cached: Env | null = null;
|
||||
function read(): Env {
|
||||
if (cached) return cached;
|
||||
cached = envSchema.parse(process.env);
|
||||
return cached;
|
||||
}
|
||||
export const env: Env = new Proxy({} as Env, {
|
||||
get(_t, prop) {
|
||||
return read()[prop as keyof Env];
|
||||
},
|
||||
}) as Env;
|
||||
|
||||
@ -9,6 +9,11 @@ export type WebEventMap = {
|
||||
"session.connected": { accountId: string; phoneNumber: string | null };
|
||||
"session.disconnected": { accountId: string };
|
||||
"session.timeout": { accountId: string };
|
||||
"session.duplicate": {
|
||||
accountId: string;
|
||||
phoneNumber: string;
|
||||
existingLabel: string;
|
||||
};
|
||||
"groups.synced": { accountId: string; count: number };
|
||||
"reminder.fired": {
|
||||
reminderId: string;
|
||||
|
||||
135
apps/web/src/lib/auth-cookie.test.ts
Normal file
135
apps/web/src/lib/auth-cookie.test.ts
Normal file
@ -0,0 +1,135 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
148
apps/web/src/lib/auth-cookie.ts
Normal file
148
apps/web/src/lib/auth-cookie.ts
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
89
apps/web/src/lib/auth.test.ts
Normal file
89
apps/web/src/lib/auth.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
66
apps/web/src/lib/auth.ts
Normal file
66
apps/web/src/lib/auth.ts
Normal file
@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
49
apps/web/src/lib/list-accounts-order.test.ts
Normal file
49
apps/web/src/lib/list-accounts-order.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
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\)/);
|
||||
});
|
||||
});
|
||||
@ -5,6 +5,10 @@ import { db } from "./db";
|
||||
export type BotCommand =
|
||||
| { type: "account.start_pairing"; 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: "group.send_test"; groupId: string; text: string }
|
||||
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import "server-only";
|
||||
import { db } from "./db";
|
||||
import { getCurrentUser } from "./auth";
|
||||
|
||||
/**
|
||||
* Returns the single seeded operator row. Since the app has no auth,
|
||||
* every action is attributed to this operator.
|
||||
* Compatibility shim. The app used to seed a single operator and
|
||||
* attribute everything to it; now we have real auth + roles. Existing
|
||||
* 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() {
|
||||
const op = await db.query.operators.findFirst({
|
||||
orderBy: (o, { asc }) => [asc(o.createdAt)],
|
||||
});
|
||||
if (!op) {
|
||||
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
|
||||
const u = await getCurrentUser();
|
||||
if (!u) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
return op;
|
||||
return u;
|
||||
}
|
||||
|
||||
69
apps/web/src/lib/password-policy.test.ts
Normal file
69
apps/web/src/lib/password-policy.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validatePassword,
|
||||
MIN_PASSWORD_LEN,
|
||||
MAX_PASSWORD_LEN,
|
||||
} from "./password-policy";
|
||||
|
||||
describe("validatePassword", () => {
|
||||
it("accepts the canonical mixed-case + digit example", () => {
|
||||
expect(validatePassword("hengs3rver").ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the bare minimum length with a number", () => {
|
||||
// 6 chars, letter + digit. Boundary case for MIN_PASSWORD_LEN.
|
||||
expect(validatePassword("abc12!").ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts symbols in place of digits", () => {
|
||||
expect(validatePassword("abcde!").ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects passwords shorter than the minimum", () => {
|
||||
const r = validatePassword("ab1!");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/at least 6/);
|
||||
});
|
||||
|
||||
it("rejects letters-only passwords", () => {
|
||||
const r = validatePassword("abcdefgh");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/letters with numbers or symbols/);
|
||||
});
|
||||
|
||||
it("rejects digits-only passwords", () => {
|
||||
const r = validatePassword("12345678");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/letters/);
|
||||
});
|
||||
|
||||
it("rejects symbols-only passwords (no letters)", () => {
|
||||
const r = validatePassword("!!!!!!!!");
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects passwords longer than MAX_PASSWORD_LEN", () => {
|
||||
const tooLong = "a".repeat(MAX_PASSWORD_LEN) + "1";
|
||||
const r = validatePassword(tooLong);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/too long/);
|
||||
});
|
||||
|
||||
it("rejects empty input", () => {
|
||||
expect(validatePassword("").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-string input defensively", () => {
|
||||
// Server actions are typed but a malformed FormData payload could land
|
||||
// here as null/undefined; the validator must not throw.
|
||||
// @ts-expect-error - defensive runtime guard
|
||||
expect(validatePassword(null).ok).toBe(false);
|
||||
// @ts-expect-error - defensive runtime guard
|
||||
expect(validatePassword(undefined).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes the documented Facebook-aligned thresholds", () => {
|
||||
expect(MIN_PASSWORD_LEN).toBe(6);
|
||||
expect(MAX_PASSWORD_LEN).toBe(256);
|
||||
});
|
||||
});
|
||||
37
apps/web/src/lib/password-policy.ts
Normal file
37
apps/web/src/lib/password-policy.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Password policy modeled after Facebook's documented requirement
|
||||
* (https://www.facebook.com/help/124904560921566): at least 6
|
||||
* characters, with a recommended mix of letters and numbers/punctuation.
|
||||
*
|
||||
* We enforce the hard minimum (6) and the recommended-mix rule on
|
||||
* password creation/reset (admin-only flows). Sign-in itself stays
|
||||
* permissive — old short passwords keep working until they're reset —
|
||||
* since rejecting them at login would lock people out without a recovery
|
||||
* path.
|
||||
*/
|
||||
|
||||
export const MIN_PASSWORD_LEN = 6;
|
||||
export const MAX_PASSWORD_LEN = 256;
|
||||
|
||||
export type PasswordCheck = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export function validatePassword(pw: string): PasswordCheck {
|
||||
if (typeof pw !== "string" || pw.length < MIN_PASSWORD_LEN) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Password must be at least ${MIN_PASSWORD_LEN} characters.`,
|
||||
};
|
||||
}
|
||||
if (pw.length > MAX_PASSWORD_LEN) {
|
||||
return { ok: false, error: "Password is too long." };
|
||||
}
|
||||
const hasLetter = /[A-Za-z]/.test(pw);
|
||||
const hasNonLetter = /[^A-Za-z]/.test(pw);
|
||||
if (!hasLetter || !hasNonLetter) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Password must mix letters with numbers or symbols.",
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@ -6,9 +6,18 @@ export async function getDashboardStats(operatorId: string) {
|
||||
const accounts = await db.query.whatsappAccounts.findMany({
|
||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||
});
|
||||
// All reminder rows so the dashboard can show active/total in one query.
|
||||
// Status enum today is active / ended (paused will join in a later phase).
|
||||
const allReminders = await db.query.reminders.findMany();
|
||||
// Reminders scoped to this operator's accounts. The previous
|
||||
// findMany() with no filter leaked global counts across users — a
|
||||
// brand-new user would see another operator's totals on the
|
||||
// dashboard. INNER JOIN on whatsapp_accounts.operator_id keeps each
|
||||
// user's view isolated.
|
||||
const reminderRows = await db.execute(sql`
|
||||
SELECT r.id, r.status
|
||||
FROM reminders r
|
||||
INNER JOIN whatsapp_accounts wa ON wa.id = r.account_id
|
||||
WHERE wa.operator_id = ${operatorId}
|
||||
`);
|
||||
const allReminders = reminderRows.rows as Array<{ id: string; status: string }>;
|
||||
// LEFT JOIN so runs whose reminder has been deleted still appear. The
|
||||
// ownership filter widens to: either the reminder still exists and the
|
||||
// operator owns its account, OR the reminder is gone but the run row
|
||||
@ -54,9 +63,12 @@ export async function listAccounts(operatorId: string) {
|
||||
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
||||
// by status produced phantom "I created an account but it's gone"
|
||||
// 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({
|
||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||
orderBy: (a, { asc }) => [asc(a.label)],
|
||||
orderBy: (a, { asc }) => [asc(a.createdAt), asc(a.id)],
|
||||
});
|
||||
}
|
||||
|
||||
@ -70,11 +82,19 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
|
||||
const account = await getAccount(operatorId, accountId);
|
||||
if (!account) return null;
|
||||
const trimmed = (q ?? "").trim();
|
||||
// Hide archived groups from the picker by default. They're rows
|
||||
// that disappeared from the live participant list (group deleted,
|
||||
// bot kicked, etc.) but still have reminder_targets pointing at
|
||||
// them — see the soft-archive flow in apps/bot/src/whatsapp/
|
||||
// group-sync.ts. Surfacing archived rows here would let an
|
||||
// operator pick a group the bot can't actually reach.
|
||||
const rows = trimmed
|
||||
? await db.execute(sql`
|
||||
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||
FROM whatsapp_groups
|
||||
WHERE account_id = ${accountId} AND name % ${trimmed}
|
||||
WHERE account_id = ${accountId}
|
||||
AND is_archived = false
|
||||
AND name % ${trimmed}
|
||||
ORDER BY similarity(name, ${trimmed}) DESC
|
||||
LIMIT 50
|
||||
`)
|
||||
@ -82,6 +102,7 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
|
||||
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||
FROM whatsapp_groups
|
||||
WHERE account_id = ${accountId}
|
||||
AND is_archived = false
|
||||
ORDER BY name ASC
|
||||
LIMIT 200
|
||||
`);
|
||||
@ -187,11 +208,13 @@ export async function listActivityRuns(
|
||||
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
||||
// has been deleted but history was preserved) in the list.
|
||||
// The `archived` flag flips the visibility filter:
|
||||
// false (default) — only non-archived rows
|
||||
// true — only archived rows (for the Archived tab)
|
||||
// false (default) — non-archived, non-skipped rows (skipped runs
|
||||
// belong to the Archived tab now)
|
||||
// true — archived rows OR skipped rows (they're treated
|
||||
// as "history" rather than active outcomes)
|
||||
const archivedClause = opts.archived
|
||||
? sql`rr.archived_at IS NOT NULL`
|
||||
: sql`rr.archived_at IS NULL`;
|
||||
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
|
||||
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
|
||||
const rows = await db.execute(sql`
|
||||
SELECT
|
||||
rr.id,
|
||||
|
||||
43
apps/web/src/lib/safe-redirect.test.ts
Normal file
43
apps/web/src/lib/safe-redirect.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
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("/");
|
||||
});
|
||||
});
|
||||
16
apps/web/src/lib/safe-redirect.ts
Normal file
16
apps/web/src/lib/safe-redirect.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
84
apps/web/src/middleware.test.ts
Normal file
84
apps/web/src/middleware.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,21 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
const PUBLIC_PATHS = new Set<string>([
|
||||
"/login",
|
||||
"/logout",
|
||||
"/api/health",
|
||||
"/manifest.webmanifest",
|
||||
"/favicon.ico",
|
||||
"/robots.txt",
|
||||
]);
|
||||
|
||||
// Block all /api/* except a small set of read-only endpoints.
|
||||
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
||||
const allowed =
|
||||
path === "/api/events" ||
|
||||
path === "/api/health" ||
|
||||
path.startsWith("/api/qr/");
|
||||
if (path.startsWith("/api/") && !allowed) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
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;
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
const path = req.nextUrl.pathname;
|
||||
if (isPublic(path)) return NextResponse.next();
|
||||
|
||||
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
const ok =
|
||||
!!cookie && !!secret && (await verifySession(cookie, secret)) !== null;
|
||||
if (ok) return NextResponse.next();
|
||||
|
||||
if (path.startsWith("/api/")) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("next", path + (req.nextUrl.search || ""));
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"],
|
||||
matcher: ["/((?!_next/static|_next/image).*)"],
|
||||
};
|
||||
|
||||
59
apps/web/src/test/drizzle-journal-monotonic.test.ts
Normal file
59
apps/web/src/test/drizzle-journal-monotonic.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
112
apps/web/src/test/no-dialog-footer-show-close-button.test.ts
Normal file
112
apps/web/src/test/no-dialog-footer-show-close-button.test.ts
Normal file
@ -0,0 +1,112 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -19,7 +19,7 @@ services:
|
||||
MEDIA_DIR: ${MEDIA_DIR:-/data/media}
|
||||
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
|
||||
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
||||
SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0}
|
||||
SEED_OPERATOR_USERNAME: ${SEED_OPERATOR_USERNAME:-admin}
|
||||
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
|
||||
networks:
|
||||
- cmbot
|
||||
@ -36,6 +36,8 @@ services:
|
||||
DATA_DIR: ${DATA_DIR}
|
||||
MEDIA_DIR: ${MEDIA_DIR}
|
||||
WEB_PORT: ${WEB_PORT}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
||||
networks:
|
||||
- cmbot
|
||||
|
||||
|
||||
@ -59,5 +59,7 @@ services:
|
||||
DATA_DIR: ${DATA_DIR}
|
||||
MEDIA_DIR: ${MEDIA_DIR}
|
||||
WEB_PORT: ${WEB_PORT}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
||||
depends_on:
|
||||
- tools
|
||||
|
||||
111
docker-compose.portainer.yml
Normal file
111
docker-compose.portainer.yml
Normal file
@ -0,0 +1,111 @@
|
||||
# Portainer-ready stack. Pulls cm-whatsapp-{web,bot} from
|
||||
# gitea.04080616.xyz/yiekheng instead of building from source — drop
|
||||
# this file into a Portainer "Stack" (Repository or Web editor) and
|
||||
# fill the env vars in the Portainer UI.
|
||||
#
|
||||
# Differences vs docker-compose.base.yml:
|
||||
# - No `build:` blocks (Portainer pulls only).
|
||||
# - Named volumes (cmbot-data, cmbot-sessions, cmbot-media) instead
|
||||
# of host bind-mounts so the operator doesn't need shell access
|
||||
# to manage persistent state.
|
||||
# - Ports section on `web` so the operator can route a reverse
|
||||
# proxy / Cloudflare Tunnel directly at the container.
|
||||
# - `restart: unless-stopped` on both services.
|
||||
#
|
||||
# Required env vars (set in Portainer → Stack → Environment variables):
|
||||
# DATABASE_URL postgres://USER:PASS@HOST:5432/wabot
|
||||
# AUTH_SECRET 32-byte random hex (use scripts/gen_auth_secret.sh
|
||||
# on any machine and copy the output)
|
||||
# WEB_PORT host port for the web container (default 9000)
|
||||
#
|
||||
# Optional:
|
||||
# DOCKER_IMAGE_TAG registry tag to deploy (default: latest)
|
||||
# OPERATOR_TOKEN_VERSION session-cookie kill switch (default: 1)
|
||||
# BOT_FIRE_CONCURRENCY pg-boss workers (default: 8)
|
||||
# BOT_GROUP_CONCURRENCY per-account parallel sends (default: 3)
|
||||
# BOT_MAX_SEND_PER_MINUTE per-account token-bucket rate (default: 40)
|
||||
# BOT_LOG_LEVEL pino log level (default: info)
|
||||
#
|
||||
# Registry auth: Portainer needs a pull credential for
|
||||
# gitea.04080616.xyz before you start the stack:
|
||||
# Portainer → Registries → Add registry
|
||||
# Name: gitea.04080616.xyz
|
||||
# URL: gitea.04080616.xyz
|
||||
# Username: <gitea user>
|
||||
# Token: <gitea personal access token, read:packages>
|
||||
# After adding, edit each service in the stack and set "Registry" to
|
||||
# the one you just added so the pull resolves.
|
||||
|
||||
services:
|
||||
bot:
|
||||
image: gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:${DOCKER_IMAGE_TAG:-latest}
|
||||
container_name: cmbot-bot
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
DATA_DIR: /data
|
||||
SESSIONS_DIR: /data/sessions
|
||||
MEDIA_DIR: /data/media
|
||||
BOT_HEALTH_PORT: 8081
|
||||
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
||||
BOT_FIRE_CONCURRENCY: ${BOT_FIRE_CONCURRENCY:-8}
|
||||
BOT_GROUP_CONCURRENCY: ${BOT_GROUP_CONCURRENCY:-3}
|
||||
BOT_MAX_SEND_PER_MINUTE: ${BOT_MAX_SEND_PER_MINUTE:-40}
|
||||
volumes:
|
||||
- cmbot-sessions:/data/sessions
|
||||
- cmbot-media:/data/media
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD-SHELL"
|
||||
- "wget -qO- --timeout=2 http://127.0.0.1:8081/health >/dev/null || exit 1"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- cmbot
|
||||
|
||||
web:
|
||||
image: gitea.04080616.xyz/yiekheng/cm-whatsapp-web:${DOCKER_IMAGE_TAG:-latest}
|
||||
container_name: cmbot-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- bot
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
DATA_DIR: /data
|
||||
MEDIA_DIR: /data/media
|
||||
WEB_PORT: 3000
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
||||
volumes:
|
||||
# Web reads media from the same persistent volume the bot wrote.
|
||||
- cmbot-media:/data/media:ro
|
||||
ports:
|
||||
# Maps the Next.js port (3000 inside the container) to whatever
|
||||
# WEB_PORT the operator set. The reverse proxy / Cloudflare Tunnel
|
||||
# in front of this host points at <host>:${WEB_PORT}.
|
||||
- "${WEB_PORT:-9000}:3000"
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD-SHELL"
|
||||
- "wget -qO- --timeout=2 http://127.0.0.1:3000/api/health >/dev/null || exit 1"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- cmbot
|
||||
|
||||
volumes:
|
||||
cmbot-sessions:
|
||||
name: cmbot-sessions
|
||||
cmbot-media:
|
||||
name: cmbot-media
|
||||
|
||||
networks:
|
||||
cmbot:
|
||||
driver: bridge
|
||||
name: cmbot
|
||||
@ -26,5 +26,13 @@ COPY --from=build /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/apps/bot /app/apps/bot
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
COPY --from=build /app/packages/shared /app/packages/shared
|
||||
# Reuse the `node` user (UID/GID 1000) that node:alpine ships with —
|
||||
# `addgroup -g 1000 app` failed in CI because gid 1000 was already
|
||||
# taken by the node group. Same hardening posture (non-root, no
|
||||
# shell login), one less moving part.
|
||||
RUN mkdir -p /data/sessions /data/media /app && \
|
||||
chown -R node:node /app /data && \
|
||||
chmod 700 /data/sessions
|
||||
USER node
|
||||
EXPOSE 8081
|
||||
CMD ["node", "apps/bot/dist/index.js"]
|
||||
|
||||
@ -18,7 +18,20 @@ COPY tsconfig.base.json turbo.json ./
|
||||
COPY apps/web apps/web
|
||||
COPY packages/db packages/db
|
||||
COPY packages/shared packages/shared
|
||||
RUN pnpm --filter @cmbot/shared build && \
|
||||
# Placeholder env values during `next build`'s "Collecting page data"
|
||||
# pass. Next bundles `lib/db.ts` (`createClient(env.DATABASE_URL)`) and
|
||||
# `actions/auth.ts` (uses AUTH_SECRET) into route bundles; their
|
||||
# top-level env access fires when Next imports the route to inspect
|
||||
# its config (the route's own `export const dynamic = "force-dynamic"`
|
||||
# stops handler execution, NOT module evaluation).
|
||||
#
|
||||
# pg.Pool is lazy — it stores the URL and only connects on the first
|
||||
# query — so a build-time placeholder never opens a socket. The
|
||||
# placeholders are scoped to this RUN layer (each Dockerfile RUN is
|
||||
# its own shell); nothing leaks into the runtime image.
|
||||
RUN export DATABASE_URL=postgres://build:build@localhost:5432/build && \
|
||||
export AUTH_SECRET=build-time-placeholder-not-used-at-runtime && \
|
||||
pnpm --filter @cmbot/shared build && \
|
||||
pnpm --filter @cmbot/db build && \
|
||||
pnpm --filter @cmbot/web build
|
||||
|
||||
@ -29,5 +42,21 @@ ENV HOSTNAME=0.0.0.0
|
||||
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/public ./apps/web/public
|
||||
# pnpm's workspace layout: each packages/<pkg>/node_modules/<dep> is a
|
||||
# symlink into /app/node_modules/.pnpm/<dep>@<ver>/node_modules/<dep>
|
||||
# where the real files live. Copying just packages/<pkg>/node_modules
|
||||
# ships dangling symlinks. Bring the .pnpm content store across too so
|
||||
# every symlink resolves at runtime; this is what unblocks the
|
||||
# `Cannot find module 'rrule'` error from
|
||||
# packages/shared/dist/rrule.js. Use --link to deduplicate the layer
|
||||
# blobs inside docker so the runtime image stays slim despite the
|
||||
# dot-pnpm tree being large.
|
||||
COPY --link --from=build /app/node_modules/.pnpm ./node_modules/.pnpm
|
||||
COPY --link --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --link --from=build /app/packages/db/node_modules ./packages/db/node_modules
|
||||
# Reuse the `node` user (UID/GID 1000) that node:alpine ships with —
|
||||
# `addgroup -g 1000 app` collided with the pre-existing node group.
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
|
||||
172
docs/deploy-portainer.md
Normal file
172
docs/deploy-portainer.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Deploying via Portainer
|
||||
|
||||
End-to-end deploy steps for a fresh Portainer-managed host. Targets
|
||||
the standard cm-whatsapp-bot pair of images published by
|
||||
`scripts/publish.sh`.
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
- Portainer 2.x running on the target host (CE or EE both fine).
|
||||
- A Postgres reachable from that host (the `wabot` database with the
|
||||
pgcrypto / pg_trgm extensions enabled — run migrations from any
|
||||
machine that can reach the DB before the stack is brought up).
|
||||
- A pull credential for `gitea.04080616.xyz` — a Gitea personal
|
||||
access token with the `read:packages` scope. Generate one in
|
||||
Gitea → User Settings → Applications.
|
||||
- A reverse proxy / Cloudflare Tunnel pointing at
|
||||
`http://<portainer-host>:<WEB_PORT>` if the deploy needs to be
|
||||
reachable on the public domain (e.g. `wabot.04080616.xyz`).
|
||||
|
||||
## 1. Add the registry to Portainer
|
||||
|
||||
Portainer → **Registries** → **+ Add registry** → Custom registry.
|
||||
|
||||
| Field | Value |
|
||||
|---------------|-----------------------------|
|
||||
| Name | `gitea.04080616.xyz` |
|
||||
| Registry URL | `gitea.04080616.xyz` |
|
||||
| Authentication | enabled |
|
||||
| Username | your Gitea username |
|
||||
| Password | the read:packages PAT |
|
||||
|
||||
Save. The registry must show as connected before continuing — if the
|
||||
test pull fails, the stack will hang on `pull` later.
|
||||
|
||||
## 2. Push the images (on your dev machine)
|
||||
|
||||
```bash
|
||||
# Login once (sudo path matches scripts/dev.sh by default)
|
||||
sudo docker login gitea.04080616.xyz
|
||||
|
||||
# Push :latest. Tag explicitly with DOCKER_IMAGE_TAG=v1.x.y if you
|
||||
# want pinned-tag deploys (recommended for prod — never deploy
|
||||
# `latest` if you can avoid it; tag versions per release).
|
||||
NO_SUDO=1 ./scripts/publish.sh latest
|
||||
```
|
||||
|
||||
`publish.sh` builds + pushes both images:
|
||||
- `gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:<tag>`
|
||||
- `gitea.04080616.xyz/yiekheng/cm-whatsapp-web:<tag>`
|
||||
|
||||
## 3. Create the Portainer stack
|
||||
|
||||
Portainer → **Stacks** → **+ Add stack**.
|
||||
|
||||
**Name:** `cm-whatsapp-bot`
|
||||
|
||||
**Build method:** "Web editor" or "Repository". Either is fine —
|
||||
"Repository" pointing at this repo's `master` and the file
|
||||
`docker-compose.portainer.yml` is the cleanest path because future
|
||||
deploys are just "Pull and redeploy" inside Portainer.
|
||||
|
||||
**Web editor path:** copy the contents of
|
||||
[`docker-compose.portainer.yml`](../docker-compose.portainer.yml)
|
||||
into the editor verbatim.
|
||||
|
||||
**Repository path:**
|
||||
|
||||
| Field | Value |
|
||||
|------------------|-------------------------------------------------------------|
|
||||
| Repository URL | http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git |
|
||||
| Reference | refs/heads/master |
|
||||
| Compose path | docker-compose.portainer.yml |
|
||||
| Authentication | enabled (same Gitea PAT as step 1) |
|
||||
| Auto-update | optional — enabled lets Portainer redeploy on every push |
|
||||
|
||||
## 4. Set environment variables
|
||||
|
||||
In the same stack form, scroll to **Environment variables** and add:
|
||||
|
||||
| Key | Value |
|
||||
|---------------------------|------------------------------------------------|
|
||||
| `DATABASE_URL` | `postgres://wabot:PASS@192.168.0.210:5432/wabot` |
|
||||
| `AUTH_SECRET` | output of `scripts/gen_auth_secret.sh` |
|
||||
| `WEB_PORT` | host port (e.g. `9000`) |
|
||||
| `DOCKER_IMAGE_TAG` | `latest` (or a pinned `v1.x.y`) |
|
||||
| `OPERATOR_TOKEN_VERSION` | `1` (bump only when you want to invalidate every existing session) |
|
||||
| `BOT_LOG_LEVEL` | `info` |
|
||||
|
||||
Optional tuning (defaults are fine for most installs):
|
||||
|
||||
| Key | Default | When to bump |
|
||||
|---------------------------|---------|--------------|
|
||||
| `BOT_FIRE_CONCURRENCY` | `8` | More accounts firing in parallel |
|
||||
| `BOT_GROUP_CONCURRENCY` | `3` | More groups per fire — but careful with WhatsApp rate caps |
|
||||
| `BOT_MAX_SEND_PER_MINUTE` | `40` | Aged accounts can push toward 60 |
|
||||
|
||||
## 5. Run database migrations
|
||||
|
||||
The stack does NOT auto-migrate on boot. Apply migrations from any
|
||||
machine that can reach the same Postgres:
|
||||
|
||||
```bash
|
||||
DATABASE_URL='postgres://...' \
|
||||
./scripts/db.sh migrate
|
||||
```
|
||||
|
||||
If the journal is non-monotonic, the migrate runner refuses with a
|
||||
clear error and prints which `_journal.json` entry to bump (the
|
||||
guard added in commit 47d7c53 + the CI test in
|
||||
`apps/web/src/test/drizzle-journal-monotonic.test.ts`).
|
||||
|
||||
Then seed the bootstrap operator + set its password:
|
||||
|
||||
```bash
|
||||
DATABASE_URL='postgres://...' SEED_OPERATOR_USERNAME=admin \
|
||||
./scripts/db.sh seed
|
||||
DATABASE_URL='postgres://...' \
|
||||
./scripts/set-password.sh admin # reads the password from stdin
|
||||
```
|
||||
|
||||
## 6. Deploy the stack
|
||||
|
||||
In Portainer → click **Deploy the stack**. Watch the container list
|
||||
in **Containers**:
|
||||
|
||||
- `cmbot-bot` should show *running, healthy* within ~20 s.
|
||||
- `cmbot-web` should show *running, healthy* within ~30 s (Next.js
|
||||
cold boot is the bottleneck).
|
||||
|
||||
If a container shows *unhealthy*, check **Logs**:
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|----------------------------------------------|--------------|
|
||||
| `column "email" does not exist` | Migrations weren't applied. Run step 5. |
|
||||
| `Server is not configured for sign-in` | `AUTH_SECRET` blank or missing. Set it in stack env. |
|
||||
| `pg-boss: queue policy ...standard` | Harmless first-boot log; the bot force-flips it. |
|
||||
| `Stream Errored (restart required)` (Baileys) | Upstream noise; ignore unless pairing fails. |
|
||||
|
||||
## 7. First sign-in
|
||||
|
||||
Visit `https://<your-domain>/login`, sign in as `admin` with the
|
||||
password set in step 5, and walk the
|
||||
[`docs/runbook.md`](runbook.md) smoke checklist before declaring
|
||||
the deploy good.
|
||||
|
||||
## 8. Future redeploys
|
||||
|
||||
Two paths depending on how you set up step 3:
|
||||
|
||||
**Web editor flow:**
|
||||
1. Run `scripts/publish.sh <tag>` on your dev machine.
|
||||
2. In Portainer → Stack → "Update the stack" → "Re-pull image and
|
||||
redeploy".
|
||||
|
||||
**Repository flow:**
|
||||
1. Run `scripts/publish.sh <tag>`.
|
||||
2. Commit any compose / env changes to master.
|
||||
3. Portainer → Stack → "Pull and redeploy". (If auto-update is on,
|
||||
skip this — Portainer redeploys on every push.)
|
||||
|
||||
Always pin a tag (`v1.4.2`) instead of `latest` for production —
|
||||
makes rollback a one-field stack edit instead of a republish.
|
||||
|
||||
## Rolling back
|
||||
|
||||
In Portainer → Stack → set `DOCKER_IMAGE_TAG=v1.4.1` (or whatever
|
||||
the previous good tag was) → Re-pull and redeploy. The cmbot-* data
|
||||
volumes (sessions, media) are preserved across image swaps, so a
|
||||
rollback doesn't lose pairings or uploaded media.
|
||||
|
||||
If the schema also rolled back, run the corresponding `down` SQL by
|
||||
hand — drizzle's migrator only goes forward, by design.
|
||||
200
docs/runbook.md
Normal file
200
docs/runbook.md
Normal file
@ -0,0 +1,200 @@
|
||||
# Manual end-to-end runbook (v1)
|
||||
|
||||
Smoke checklist for verifying a fresh deploy. Unit tests don't catch
|
||||
the live-Baileys / live-Postgres / browser-gesture path; this is what
|
||||
you run before declaring a release good.
|
||||
|
||||
Time budget: ~10 minutes if everything works, ~30 if a step fails.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
- [ ] **Stack up.**
|
||||
`docker ps | grep cmbot` → expect `cmbot-tools`, `cmbot-bot`,
|
||||
`cmbot-web` all `Up`.
|
||||
- [ ] **Migrations clean.**
|
||||
`NO_SUDO=1 scripts/db.sh migrate` → "Migrations applied." (and
|
||||
*not* "Refusing to run drizzle migrate" — that's the journal
|
||||
monotonicity guard tripping).
|
||||
- [ ] **Web reachable.**
|
||||
`curl -sf http://localhost:9000/api/health` → 200.
|
||||
- [ ] **Bot reachable.**
|
||||
`curl -sf http://localhost:8081/health` → 200.
|
||||
|
||||
If any pre-flight fails, fix before continuing.
|
||||
|
||||
---
|
||||
|
||||
## 1. Auth bootstrap
|
||||
|
||||
- [ ] `scripts/db.sh seed` (idempotent — only inserts the `admin`
|
||||
operator if missing).
|
||||
- [ ] `echo 'change-me-now' | scripts/set-password.sh admin` → "Password
|
||||
updated."
|
||||
- [ ] Open `http://localhost:9000/login` → enter `admin` / the password
|
||||
→ redirected to `/`.
|
||||
- [ ] **Wrong password three times in a row** still rate-limits but
|
||||
with the generic "Too many attempts" message — no leak about
|
||||
which limit (IP / username / global) tripped.
|
||||
- [ ] Hit `/admin` URL while signed out → redirected to `/login` with
|
||||
`?next=/admin`. After a successful login, lands back on `/admin`.
|
||||
|
||||
---
|
||||
|
||||
## 2. User management (admin-only)
|
||||
|
||||
- [ ] **Sidebar / drawer**: only one nav entry highlights at a time.
|
||||
On `/settings/users`, only `Admin` lights up; `Settings` does
|
||||
not.
|
||||
- [ ] `/settings/users` → Add user → username `alice`, password
|
||||
`alpha7!`, role `user` → "User created."
|
||||
- [ ] `alice` row shows: username + `you` chip if applicable, role
|
||||
pill, Promote / Reset / Delete buttons on row 2.
|
||||
- [ ] Promote `alice` to admin → page revalidates, badge flips to
|
||||
`admin`.
|
||||
- [ ] Demote back to `user`.
|
||||
- [ ] **Last-admin guard**: Demote / Delete on the only remaining
|
||||
admin row are both disabled.
|
||||
- [ ] Delete `alice` via the confirm dialog (Cancel + Delete user
|
||||
buttons; **no third "Close" button** — the static guard test
|
||||
catches that regression but eyeball it anyway).
|
||||
|
||||
---
|
||||
|
||||
## 3. Account pairing
|
||||
|
||||
- [ ] `/accounts` → New Account → label `WaBot Test` → Pair WhatsApp.
|
||||
Land on the live QR page within ~2 s.
|
||||
- [ ] Login screen header is JUST the centered brand mark — no nav,
|
||||
no menu drawer.
|
||||
- [ ] Scan with WhatsApp → "Linked Devices" → "Link a device".
|
||||
- [ ] **Connection success.** Page transitions through `qr` → (brief
|
||||
`restart-required` close handled silently) → `connected` with
|
||||
a green check and `+60xxx` phone number → auto-redirect to
|
||||
`/accounts/<id>` after 3 s.
|
||||
- [ ] **Refresh Groups** button on `/accounts/<id>/groups` → spinner
|
||||
during the sync, page auto-refreshes when the bot pushes
|
||||
`groups.synced` over SSE. No manual reload needed.
|
||||
|
||||
### Pair regression checks (these caught real bugs)
|
||||
|
||||
- [ ] **Back → Re-pair**: from a live QR, click ← Back → Pair again
|
||||
from the account detail page. Should NOT instantly flash
|
||||
"Pairing timed out". A new QR appears and the countdown
|
||||
restarts at 5:00.
|
||||
- [ ] **Duplicate phone**: with one phone already paired, scan its QR
|
||||
from a *second* account row → see the amber "Phone already
|
||||
linked" panel naming the existing account. The original
|
||||
account's session stays intact.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reminder lifecycle
|
||||
|
||||
- [ ] `/reminders` → New Reminder → walk the wizard:
|
||||
- Step 1: pick `WaBot Test`.
|
||||
- Step 2: enter a short text message ("smoke test <timestamp>").
|
||||
- Step 3: pick `Daily` recurrence, fire ~2 minutes from now.
|
||||
Confirm "Pause sending by" checkbox is **unchecked by default**.
|
||||
- Step 4: select 1 group.
|
||||
- Step 5: review → Save.
|
||||
- [ ] Reminder appears on `/reminders` with status `Active`.
|
||||
Recurrence column shows the human-readable description; long
|
||||
descriptions truncate with `…`.
|
||||
- [ ] **Wait for the fire window.** When the time hits, the message
|
||||
lands in the WhatsApp group **exactly once**.
|
||||
- [ ] `/activity` → the run shows under `Success`. Default tab is
|
||||
Success (no `All` tab).
|
||||
- [ ] Swipe-left a row → Delete shelf appears. Swipe-right → Pause /
|
||||
Restart shelf. Tapping a row navigates to its detail; dragging
|
||||
does NOT navigate (6-px threshold).
|
||||
- [ ] Pause the reminder → status flips to `Paused` immediately and
|
||||
the next-fire-time disappears.
|
||||
- [ ] Restart → fires on the next scheduled occurrence.
|
||||
|
||||
### Reminder regression checks
|
||||
|
||||
- [ ] **Triple-fire repro** (only if you have a tame group): edit
|
||||
the reminder repeatedly within microseconds of each other (e.g.
|
||||
the wizard Save button hammered three times). The message must
|
||||
land **exactly once**. The bot logs should show
|
||||
"duplicate fire detected inside mutex" warnings on the second
|
||||
and third attempts.
|
||||
- [ ] **Reschedule under existing job**: edit a recurring reminder's
|
||||
schedule to a NEW time before its next-fire arrives. The new
|
||||
time must fire (the old `created` job is now `cancelled` in
|
||||
`pgboss.job`; verify with `select state, count(*) from
|
||||
pgboss.job where name='reminder.fire' group by state`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Account lifecycle
|
||||
|
||||
- [ ] **Unpair** the account from `/accounts/<id>`. Confirm dialog
|
||||
(Cancel + Yes, unpair). The account row stays in the list with
|
||||
"Unpaired" status; groups disappear from the picker (they're
|
||||
soft-archived, not deleted).
|
||||
- [ ] **Re-pair** the same account → groups come back via the
|
||||
on-conflict upsert flipping `is_archived` back to false.
|
||||
- [ ] **Delete** the account from `/accounts/<id>` → Confirm dialog →
|
||||
the account vanishes from `/accounts`. Check on the *phone*'s
|
||||
WhatsApp Linked Devices list — the entry is gone (the
|
||||
logout-before-stop flow tells WhatsApp to drop it).
|
||||
|
||||
---
|
||||
|
||||
## 6. Sign-out + session lifetime
|
||||
|
||||
- [ ] **Sign out** from the sidebar / drawer footer → land on `/login`.
|
||||
- [ ] Hit any protected URL → redirected to login.
|
||||
- [ ] **Token-version kill switch**: set `OPERATOR_TOKEN_VERSION=2`
|
||||
in `.env.development`, restart the web container. Every
|
||||
previously-issued cookie is now invalid; every authenticated
|
||||
request bounces to `/login`. Reset to `1` after.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-tenant isolation
|
||||
|
||||
- [ ] Sign in as `admin`. Note dashboard counter values.
|
||||
- [ ] As admin, create a second user `bob` and give them a fresh
|
||||
account / reminder / fire it once.
|
||||
- [ ] Sign out, sign in as `bob`. Dashboard counters MUST show only
|
||||
bob's numbers (not admin's). `/reminders` lists only bob's
|
||||
reminders. `/accounts` only bob's accounts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sweep
|
||||
|
||||
- [ ] `docker logs cmbot-web --since 10m | grep -iE 'error|⨯'` — no
|
||||
output (or only Baileys "Stream Errored (restart required)"
|
||||
noise; that's upstream).
|
||||
- [ ] `docker logs cmbot-bot --since 10m | grep -iE 'error|fatal'` —
|
||||
no output beyond the same Baileys upstream noise.
|
||||
- [ ] `git status` clean (no leftover `_check.ts` or temp files).
|
||||
|
||||
---
|
||||
|
||||
## When a step fails
|
||||
|
||||
- **Migration refused** with "Refusing to run drizzle migrate":
|
||||
open `packages/db/migrations/meta/_journal.json` and bump the
|
||||
flagged entry's `when` to the suggested value. Re-run.
|
||||
- **Pair shows immediate timeout**: bot logs should mention "ignoring
|
||||
close from previous attempt while warming up" — that's the fix
|
||||
working, but check a stale Baileys session isn't gummed up. Last
|
||||
resort: `rm -rf dev-data/sessions/<accountId>` and re-pair.
|
||||
- **Reminder fires twice**: check `pgboss.queue.policy` for
|
||||
`reminder.fire` — must be `standard`, not `stately` (stately drops
|
||||
reschedules silently). The `registerReminderJobs` boot hook
|
||||
force-flips this on every bot start.
|
||||
- **Delete didn't remove the linked-device entry on the phone**:
|
||||
the bot's `socket.logout()` is best-effort — if the socket was
|
||||
already disconnected when delete fired, the operator removes the
|
||||
entry manually from WhatsApp's UI.
|
||||
|
||||
If any of the regression checks (Back→Re-pair, duplicate phone,
|
||||
triple-fire, reschedule) fail, that's a real bug — capture the bot
|
||||
log and file an issue before shipping.
|
||||
2314
docs/superpowers/plans/2026-05-10-auth-and-prod-hardening.md
Normal file
2314
docs/superpowers/plans/2026-05-10-auth-and-prod-hardening.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,437 @@
|
||||
# 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.
|
||||
@ -10,12 +10,41 @@ MEDIA_DIR=/data/media
|
||||
BOT_HEALTH_PORT=8081
|
||||
BOT_LOG_LEVEL=info
|
||||
|
||||
# Reminder fan-out tuning. Defaults aim for an established WhatsApp
|
||||
# account (~30-60 msg/min safe band). Bump cautiously.
|
||||
# BOT_FIRE_CONCURRENCY pg-boss workers; max accounts firing in parallel.
|
||||
# BOT_GROUP_CONCURRENCY per-account parallel group sends; parts within a
|
||||
# group stay serial.
|
||||
# BOT_MAX_SEND_PER_MINUTE per-account token-bucket rate.
|
||||
BOT_FIRE_CONCURRENCY=8
|
||||
BOT_GROUP_CONCURRENCY=3
|
||||
BOT_MAX_SEND_PER_MINUTE=40
|
||||
|
||||
# === Seed (used by scripts/db.sh seed) ===
|
||||
SEED_OPERATOR_TELEGRAM_ID=
|
||||
# The bootstrap operator's username. After seed, set their password
|
||||
# via: echo 'change-me-now' | scripts/set-password.sh admin
|
||||
SEED_OPERATOR_USERNAME=admin
|
||||
SEED_OPERATOR_NAME=Operator
|
||||
|
||||
# === Web ===
|
||||
# === Web / Auth ===
|
||||
# Port the Next.js container exposes on the host. Production deployment
|
||||
# (rexwa.04080616.xyz) uses 8100; dev/staging (test.04080616.xyz) uses 9000.
|
||||
# (wabot.04080616.xyz) uses 8100; dev/staging (test.04080616.xyz) uses 9000.
|
||||
WEB_PORT=9000
|
||||
|
||||
# 32-byte secret used to derive the AES-256-GCM key for session cookies.
|
||||
# DO NOT leave blank — the web container will refuse to issue cookies.
|
||||
# Generate via: scripts/gen_auth_secret.sh --write
|
||||
AUTH_SECRET=
|
||||
|
||||
# Bumping this invalidates every outstanding session cookie globally on
|
||||
# the next request. Treat it as a kill switch (e.g. after a key leak)
|
||||
# rather than a routine value.
|
||||
OPERATOR_TOKEN_VERSION=1
|
||||
|
||||
# === Docker Registry (used by scripts/publish.sh) ===
|
||||
# Tag pushed alongside latest. Override with the CLI arg or
|
||||
# DOCKER_IMAGE_TAG=v1.2.3 scripts/publish.sh.
|
||||
DOCKER_IMAGE_TAG=latest
|
||||
# Buildx target platforms. linux/amd64 is the prod host arch; add
|
||||
# linux/arm64 if you cross-build for an Apple-silicon runner.
|
||||
CM_IMAGE_PLATFORMS=linux/amd64
|
||||
|
||||
9
packages/db/migrations/0010_fancy_wolf_cub.sql
Normal file
9
packages/db/migrations/0010_fancy_wolf_cub.sql
Normal file
@ -0,0 +1,9 @@
|
||||
-- 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"));
|
||||
2
packages/db/migrations/0011_premium_grandmaster.sql
Normal file
2
packages/db/migrations/0011_premium_grandmaster.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS "operators_telegram_user_id_uq";--> statement-breakpoint
|
||||
ALTER TABLE "operators" DROP COLUMN IF EXISTS "telegram_user_id";
|
||||
10
packages/db/migrations/0012_lucky_masked_marvel.sql
Normal file
10
packages/db/migrations/0012_lucky_masked_marvel.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- 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;
|
||||
2
packages/db/migrations/0013_tricky_yellowjacket.sql
Normal file
2
packages/db/migrations/0013_tricky_yellowjacket.sql
Normal file
@ -0,0 +1,2 @@
|
||||
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;
|
||||
1
packages/db/migrations/0014_lame_puck.sql
Normal file
1
packages/db/migrations/0014_lame_puck.sql
Normal file
@ -0,0 +1 @@
|
||||
CREATE INDEX IF NOT EXISTS "whatsapp_groups_account_name_idx" ON "whatsapp_groups" USING btree ("account_id","name");
|
||||
1071
packages/db/migrations/meta/0010_snapshot.json
Normal file
1071
packages/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1050
packages/db/migrations/meta/0011_snapshot.json
Normal file
1050
packages/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1050
packages/db/migrations/meta/0012_snapshot.json
Normal file
1050
packages/db/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1072
packages/db/migrations/meta/0013_snapshot.json
Normal file
1072
packages/db/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1093
packages/db/migrations/meta/0014_snapshot.json
Normal file
1093
packages/db/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -71,6 +71,41 @@
|
||||
"when": 1778464000000,
|
||||
"tag": "0009_rename_ended_to_inactive",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1778464005000,
|
||||
"tag": "0014_lame_puck",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -13,6 +13,10 @@
|
||||
"./schema": {
|
||||
"types": "./dist/schema.d.ts",
|
||||
"default": "./dist/schema.js"
|
||||
},
|
||||
"./journal-check": {
|
||||
"types": "./dist/journal-check.d.ts",
|
||||
"default": "./dist/journal-check.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@ -26,10 +30,12 @@
|
||||
"seed": "tsx src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"pg": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"drizzle-kit": "^0.28.0",
|
||||
|
||||
90
packages/db/src/journal-check.ts
Normal file
90
packages/db/src/journal-check.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@ -1,5 +1,13 @@
|
||||
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 {
|
||||
assertJournalMonotonic,
|
||||
formatJournalViolations,
|
||||
type JournalEntry,
|
||||
} from "./journal-check.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
@ -7,6 +15,27 @@ if (!databaseUrl) {
|
||||
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);
|
||||
console.log("Applying migrations...");
|
||||
await migrate(db, { migrationsFolder: "./migrations" });
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
@ -9,6 +10,7 @@ import {
|
||||
jsonb,
|
||||
primaryKey,
|
||||
uniqueIndex,
|
||||
index,
|
||||
inet,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
@ -16,14 +18,25 @@ export const operators = pgTable(
|
||||
"operators",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
telegramUserId: bigint("telegram_user_id", { mode: "number" }).notNull(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash"),
|
||||
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"),
|
||||
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
telegramUserIdUnique: uniqueIndex("operators_telegram_user_id_uq").on(t.telegramUserId),
|
||||
usernameUnique: uniqueIndex("operators_username_uq").on(sql`lower(${t.username})`),
|
||||
// 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`),
|
||||
}),
|
||||
);
|
||||
|
||||
@ -45,6 +58,16 @@ 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(
|
||||
"whatsapp_groups",
|
||||
{
|
||||
@ -58,6 +81,16 @@ export const whatsappGroups = pgTable(
|
||||
},
|
||||
(t) => ({
|
||||
accountJidUnique: uniqueIndex("whatsapp_groups_account_jid_uq").on(t.accountId, t.waGroupJid),
|
||||
// Backs `WHERE account_id=? ORDER BY name ASC LIMIT 200` on the
|
||||
// groups list page. Without this, PG falls back to the unique
|
||||
// (account_id, wa_group_jid) index for the WHERE clause and then
|
||||
// does an explicit sort on `name` — fine at small scale, slow
|
||||
// when an operator has 3 000+ groups. Drizzle import is `index`,
|
||||
// declared in this same file's import block.
|
||||
accountNameIdx: index("whatsapp_groups_account_name_idx").on(
|
||||
t.accountId,
|
||||
t.name,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@ -90,8 +123,11 @@ export const reminders = pgTable("reminders", {
|
||||
// Delivery window (operator timezone). End hour is enforced at runtime
|
||||
// by fire-reminder when window enforcement lands; start hour is documented
|
||||
// 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),
|
||||
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(18),
|
||||
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(24),
|
||||
});
|
||||
|
||||
export const reminderTargets = pgTable(
|
||||
|
||||
42
packages/db/src/scripts/create-user.ts
Normal file
42
packages/db/src/scripts/create-user.ts
Normal file
@ -0,0 +1,42 @@
|
||||
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);
|
||||
});
|
||||
59
packages/db/src/scripts/set-password.ts
Normal file
59
packages/db/src/scripts/set-password.ts
Normal file
@ -0,0 +1,59 @@
|
||||
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);
|
||||
});
|
||||
@ -1,29 +1,25 @@
|
||||
import { createClient, operators } from "./index.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const operatorTelegramId = process.env.SEED_OPERATOR_TELEGRAM_ID;
|
||||
const username = process.env.SEED_OPERATOR_USERNAME ?? "admin";
|
||||
const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator";
|
||||
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL not set");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!operatorTelegramId || operatorTelegramId === "0") {
|
||||
console.error("SEED_OPERATOR_TELEGRAM_ID not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { db, pool } = createClient(databaseUrl);
|
||||
|
||||
await db
|
||||
.insert(operators)
|
||||
.values({
|
||||
telegramUserId: Number(operatorTelegramId),
|
||||
username,
|
||||
displayName: operatorName,
|
||||
role: "admin",
|
||||
defaultTimezone: "Asia/Kuala_Lumpur",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
console.log(`Seeded operator with telegram_user_id=${operatorTelegramId}`);
|
||||
console.log(`Seeded operator '${username}'. Set a password via scripts/set-password.sh ${username}`);
|
||||
await pool.end();
|
||||
|
||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@ -93,6 +93,9 @@ importers:
|
||||
'@types/luxon':
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.1
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@ -166,6 +169,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.0
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
'@types/node':
|
||||
specifier: ^22.7.0
|
||||
version: 22.19.18
|
||||
@ -202,6 +208,9 @@ importers:
|
||||
|
||||
packages/db:
|
||||
dependencies:
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
drizzle-orm:
|
||||
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)
|
||||
@ -209,6 +218,9 @@ importers:
|
||||
specifier: ^8.13.0
|
||||
version: 8.20.0
|
||||
devDependencies:
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
'@types/node':
|
||||
specifier: ^22.7.0
|
||||
version: 22.19.18
|
||||
@ -2370,6 +2382,10 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@ -2559,6 +2575,10 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bcryptjs@3.0.3:
|
||||
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||
hasBin: true
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
@ -6461,6 +6481,10 @@ snapshots:
|
||||
'@turbo/windows-arm64@2.9.12':
|
||||
optional: true
|
||||
|
||||
'@types/bcryptjs@3.0.0':
|
||||
dependencies:
|
||||
bcryptjs: 3.0.3
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
@ -6676,6 +6700,8 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.28: {}
|
||||
|
||||
bcryptjs@3.0.3: {}
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
|
||||
3
scripts/create-user.sh
Executable file
3
scripts/create-user.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/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 "$@"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user